@eventra_dev/eventra-cli 0.1.6 → 0.1.8

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
  }
@@ -62,6 +62,10 @@ export class TSService {
62
62
  this.files.get(file)?.version.toString() ?? "0",
63
63
 
64
64
  getScriptSnapshot: (file) => {
65
+ if (!this.fileNames.has(file)) {
66
+ return undefined;
67
+ }
68
+
65
69
  const f = this.files.get(file);
66
70
 
67
71
  if (f) {
@@ -113,6 +117,15 @@ export class TSService {
113
117
  return this.getProgram()?.getSourceFile(file);
114
118
  }
115
119
 
120
+ getFileContent(file: string) {
121
+ return this.files.get(file)?.content;
122
+ }
123
+
124
+ removeFile(file: string) {
125
+ this.files.delete(file);
126
+ this.fileNames.delete(file);
127
+ }
128
+
116
129
  getModuleResolutionConfig() {
117
130
  return {
118
131
  baseUrl: this.baseUrl,
@@ -1,5 +1,5 @@
1
1
  import ts from "typescript";
2
- import {resolveExportedSymbol} from "./exportResolver";
2
+ import { resolveExportedSymbol } from "./exportResolver";
3
3
 
4
4
  export type ResolveResult = {
5
5
  values: string[];
@@ -21,7 +21,8 @@ export function resolveNodeValue(
21
21
  return resolveNodeValue(
22
22
  paramMap.get(node.text)!,
23
23
  checker,
24
- paramMap
24
+ paramMap,
25
+ seen
25
26
  );
26
27
  }
27
28
 
@@ -54,15 +55,35 @@ export function resolveNodeValue(
54
55
  let symbol = checker.getSymbolAtLocation(node);
55
56
  if (!symbol) return null;
56
57
 
58
+ // variable inline resolve FIRST
59
+ for (const decl of symbol.getDeclarations() ?? []) {
60
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
61
+ return resolveNodeValue(
62
+ decl.initializer,
63
+ checker,
64
+ paramMap,
65
+ seen
66
+ );
67
+ }
68
+ }
69
+
70
+ const original = symbol;
71
+
57
72
  symbol = resolveExportedSymbol(symbol, checker) ?? symbol;
58
73
 
59
- if (symbol.flags & ts.SymbolFlags.Alias) {
74
+ // avoid alias loop
75
+ if (symbol !== original && (symbol.flags & ts.SymbolFlags.Alias)) {
60
76
  symbol = checker.getAliasedSymbol(symbol);
61
77
  }
62
78
 
63
79
  for (const decl of symbol.getDeclarations() ?? []) {
64
80
  if (ts.isVariableDeclaration(decl) && decl.initializer) {
65
- return resolveNodeValue(decl.initializer, checker, paramMap, seen);
81
+ return resolveNodeValue(
82
+ decl.initializer,
83
+ checker,
84
+ paramMap,
85
+ seen
86
+ );
66
87
  }
67
88
 
68
89
  if (ts.isEnumMember(decl) && decl.initializer) {
@@ -1,120 +1,247 @@
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
+ import { isExternalFile } from "./boundary";
8
+
9
+ const TRACKING_NAMES = new Set(["track", "event", "capture", "send"]);
5
10
 
6
11
  function getCallName(expr: ts.Expression): string {
7
12
  if (ts.isIdentifier(expr)) return expr.text;
8
-
9
- if (ts.isPropertyAccessExpression(expr)) {
10
- return expr.name.text;
11
- }
12
-
13
+ if (ts.isPropertyAccessExpression(expr)) return expr.name.text;
13
14
  return "";
14
15
  }
15
16
 
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;
17
+ function getJsxAttrName(name: ts.JsxAttributeName): string | null {
18
+ if (ts.isIdentifier(name)) return name.text;
19
+ if (ts.isJsxNamespacedName(name)) return name.name.text;
20
+ return null;
21
+ }
22
22
 
23
- for (const arg of args) {
24
- const res = resolveNodeValue(arg, checker, paramMap);
23
+ function isValidEvent(v: string) {
24
+ return (
25
+ v &&
26
+ v.length > 1 &&
27
+ v.length < 100 &&
28
+ !v.includes(" ") &&
29
+ /^[a-zA-Z0-9._:-]+$/.test(v)
30
+ );
31
+ }
25
32
 
26
- if (!res?.values?.length) continue;
33
+ function isPropUsedInTracking(
34
+ fn: ts.FunctionLikeDeclaration,
35
+ prop: string
36
+ ) {
37
+ let found = false;
27
38
 
28
- let score = 0;
39
+ function visit(node: ts.Node) {
40
+ if (ts.isCallExpression(node)) {
41
+ const name = getCallName(node.expression);
29
42
 
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;
43
+ if (!TRACKING_NAMES.has(name)) return;
33
44
 
34
- if (!best || score > best.score) {
35
- best = { score, node: arg };
45
+ for (const arg of node.arguments) {
46
+ if (
47
+ (ts.isIdentifier(arg) && arg.text === prop) ||
48
+ (ts.isPropertyAccessExpression(arg) && 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 && fn && !isExternalFile(fn.getSourceFile().fileName));
103
+
104
+ if (!isTracking) return;
105
+
106
+ if (!isKnownWrapper && isAutoWrapper && !TRACKING_NAMES.has(name)) {
107
+ detectedFunctionWrappers.add(name);
108
+ }
109
+
110
+ const paramMap = new Map<string, ts.Expression>();
111
+
112
+ if (fn && ts.isFunctionLike(fn)) {
113
+ fn.parameters.forEach((param, i) => {
114
+ const arg = node.arguments[i];
115
+ if (!arg) return;
116
+
117
+ if (ts.isIdentifier(param.name)) {
118
+ paramMap.set(param.name.text, arg);
119
+ }
120
+ });
121
+ }
122
+
123
+ let handled = false;
124
+
125
+ if (fn) {
126
+ const idx = findTrackParamIndex(fn);
61
127
 
62
- const paramMap = new Map<string, ts.Expression>();
128
+ if (idx !== null && node.arguments[idx]) {
129
+ const res = resolveNodeValue(
130
+ node.arguments[idx],
131
+ checker,
132
+ paramMap
133
+ );
63
134
 
64
- if (fn && ts.isFunctionLike(fn)) {
65
- fn.parameters.forEach((param, index) => {
66
- const arg = node.arguments[index];
67
- if (!arg) return;
135
+ res?.values.forEach(v => {
136
+ if (isValidEvent(v)) events.add(v);
137
+ });
138
+
139
+ handled = true;
140
+ }
141
+ }
68
142
 
69
- if (ts.isIdentifier(param.name)) {
70
- paramMap.set(param.name.text, arg);
71
- }
143
+ if (!handled) {
144
+ for (const arg of node.arguments) {
145
+ const res = resolveNodeValue(arg, checker, paramMap);
146
+
147
+ res?.values.forEach(v => {
148
+ if (isValidEvent(v)) events.add(v);
72
149
  });
73
150
  }
151
+ }
152
+
153
+ if (fn) {
154
+ const sf = fn.getSourceFile();
74
155
 
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
- }
156
+ if (!visited.has(sf.fileName)) {
157
+ const res = scanSource(
158
+ sf,
159
+ checker,
160
+ config,
161
+ visited,
162
+ depth + 1
163
+ );
164
+
165
+ res.events.forEach(e => events.add(e));
166
+ }
167
+ }
168
+ }
169
+
170
+ // JSX
171
+ if (
172
+ ts.isJsxSelfClosingElement(node) ||
173
+ ts.isJsxOpeningElement(node)
174
+ ) {
175
+ const tagNode =
176
+ ts.isPropertyAccessExpression(node.tagName)
177
+ ? node.tagName.name
178
+ : node.tagName;
179
+
180
+ let symbol = checker.getSymbolAtLocation(tagNode);
181
+
182
+ if (symbol) {
183
+ symbol = resolveExportedSymbol(symbol, checker) ?? symbol;
184
+ }
185
+
186
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) {
187
+ symbol = checker.getAliasedSymbol(symbol);
188
+ }
189
+
190
+ if (!symbol) return;
191
+
192
+ const tagName =
193
+ ts.isPropertyAccessExpression(node.tagName)
194
+ ? node.tagName.name.getText()
195
+ : node.tagName.getText();
196
+
197
+ for (const decl of symbol.getDeclarations() ?? []) {
198
+ let fn: ts.FunctionLikeDeclaration | null = null;
199
+
200
+ if (ts.isFunctionDeclaration(decl)) fn = decl;
201
+
202
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
203
+ const init = decl.initializer;
204
+ if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) {
205
+ fn = init;
98
206
  }
99
207
  }
100
208
 
101
- // fallback (heuristic)
102
- const target = pickBestArg(node.arguments, checker, paramMap);
209
+ if (!fn) continue;
210
+
211
+ const param = fn.parameters[0];
212
+ if (!param || !ts.isObjectBindingPattern(param.name)) continue;
103
213
 
104
- if (target) {
105
- const res = resolveNodeValue(target, checker, paramMap);
214
+ for (const el of param.name.elements) {
215
+ const prop = el.name.getText();
216
+
217
+ const isEvent =
218
+ isPropUsedInTracking(fn, prop) ||
219
+ config.wrappers.some(
220
+ w => w.name === tagName && w.prop === prop
221
+ );
222
+
223
+ if (!isEvent) continue;
224
+
225
+ const attr = node.attributes.properties.find(p => {
226
+ if (!ts.isJsxAttribute(p)) return false;
227
+ return getJsxAttrName(p.name) === prop;
228
+ }) as ts.JsxAttribute | undefined;
229
+
230
+ if (!attr || !attr.initializer) continue;
231
+
232
+ const expr = ts.isJsxExpression(attr.initializer)
233
+ ? attr.initializer.expression
234
+ : attr.initializer;
235
+
236
+ if (!expr) continue;
237
+
238
+ const res = resolveNodeValue(expr, checker);
106
239
 
107
240
  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
- }
241
+ if (isValidEvent(v)) events.add(v);
117
242
  });
243
+
244
+ detectedComponentWrappers.set(tagName, prop);
118
245
  }
119
246
  }
120
247
  }
@@ -124,5 +251,9 @@ export function scanSource(
124
251
 
125
252
  visit(source);
126
253
 
127
- return events;
254
+ return {
255
+ events,
256
+ detectedFunctionWrappers,
257
+ detectedComponentWrappers,
258
+ };
128
259
  }