@eventra_dev/eventra-cli 0.1.24 → 0.1.26

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.
@@ -1,3 +1,6 @@
1
+ import path from "path";
2
+ import fs from "fs";
3
+
1
4
  import { TSService } from "./languageService";
2
5
  import { ScanResult, scanSource } from "./scanner";
3
6
  import { DependencyGraph } from "./graph";
@@ -19,39 +22,56 @@ export class EventraEngine {
19
22
  this.graph = new DependencyGraph(baseUrl, paths);
20
23
  }
21
24
 
25
+ private normalize(file: string) {
26
+ return path.resolve(file).replace(/\\/g, "/");
27
+ }
28
+
29
+ private empty(): ScanResult {
30
+ return {
31
+ events: new Set(),
32
+ detectedFunctionWrappers: new Set(),
33
+ detectedComponentWrappers: new Map(),
34
+ };
35
+ }
36
+
37
+ // PRELOAD
38
+ preloadFile(file: string, content: string) {
39
+ file = this.normalize(file);
40
+
41
+ this.ts.updateFile(file, content);
42
+
43
+ const source = this.ts.getSourceFile(file);
44
+ if (source) {
45
+ this.graph.update(file, source);
46
+ }
47
+ }
48
+
22
49
  // SCAN FILE
23
50
  scanFile(
24
51
  file: string,
25
52
  content: string,
26
53
  config: EventraConfig
27
54
  ): ScanResult {
55
+ file = this.normalize(file);
56
+
28
57
  const h = hash(content);
29
58
 
30
- // skip unchanged
31
59
  if (this.fileHash.get(file) === h) {
32
- return (
33
- this.fileResults.get(file) ?? {
34
- events: new Set(),
35
- detectedFunctionWrappers: new Set(),
36
- detectedComponentWrappers: new Map(),
37
- }
38
- );
60
+ return this.fileResults.get(file) ?? this.empty();
39
61
  }
40
62
 
41
63
  this.fileHash.set(file, h);
64
+
42
65
  this.ts.updateFile(file, content);
43
66
 
44
67
  const source = this.ts.getSourceFile(file);
45
68
  const checker = this.ts.getChecker();
46
69
 
47
70
  if (!source || !checker) {
48
- return {
49
- events: new Set(),
50
- detectedFunctionWrappers: new Set(),
51
- detectedComponentWrappers: new Map(),
52
- };
71
+ return this.empty();
53
72
  }
54
73
 
74
+ // обновляем graph
55
75
  this.graph.update(file, source);
56
76
 
57
77
  const res = scanSource(source, checker, config);
@@ -66,33 +86,46 @@ export class EventraEngine {
66
86
  content: string,
67
87
  config: EventraConfig
68
88
  ) {
69
- // сначала обновляем текущий файл
89
+ file = this.normalize(file);
90
+
70
91
  const res = this.scanFile(file, content, config);
71
92
 
72
93
  const dependents = this.graph.getAllDependentsDeep(file);
73
94
 
74
95
  for (const dep of dependents) {
75
- if (isExternalFile(dep)) continue;
96
+ const abs = this.normalize(dep);
97
+
98
+ if (isExternalFile(abs)) continue;
76
99
 
77
- const depContent = this.ts.getFileContent(dep);
78
- if (!depContent) continue;
100
+ let depContent = this.ts.getFileContent(abs);
101
+
102
+ if (!depContent) {
103
+ try {
104
+ depContent = fs.readFileSync(abs, "utf-8");
105
+ } catch {
106
+ continue;
107
+ }
108
+ }
79
109
 
80
110
  const h = hash(depContent);
81
111
 
82
- if (this.fileHash.get(dep) === h) continue;
112
+ if (this.fileHash.get(abs) === h) {
113
+ continue;
114
+ }
83
115
 
84
- this.fileHash.set(dep, h);
85
- this.ts.updateFile(dep, depContent);
116
+ this.fileHash.set(abs, h);
86
117
 
87
- const source = this.ts.getSourceFile(dep);
118
+ this.ts.updateFile(abs, depContent);
119
+
120
+ const source = this.ts.getSourceFile(abs);
88
121
  const checker = this.ts.getChecker();
89
122
 
90
123
  if (!source || !checker) continue;
91
124
 
92
- this.graph.update(dep, source);
125
+ this.graph.update(abs, source);
93
126
 
94
127
  const r = scanSource(source, checker, config);
95
- this.fileResults.set(dep, r);
128
+ this.fileResults.set(abs, r);
96
129
  }
97
130
 
98
131
  return res;
@@ -111,31 +144,45 @@ export class EventraEngine {
111
144
 
112
145
  // REMOVE FILE
113
146
  removeFile(file: string, config?: EventraConfig) {
147
+ file = this.normalize(file);
148
+
149
+ const dependents = this.graph.getAllDependentsDeep(file);
150
+
114
151
  this.ts.removeFile(file);
115
152
  this.fileResults.delete(file);
116
153
  this.fileHash.delete(file);
117
154
  this.graph.remove(file);
118
155
 
119
- const dependents = this.graph.getAllDependentsDeep(file);
120
-
121
156
  for (const dep of dependents) {
122
- if (isExternalFile(dep)) continue;
157
+ const abs = this.normalize(dep);
123
158
 
124
- const content = this.ts.getFileContent(dep);
125
- if (!content) continue;
159
+ if (isExternalFile(abs)) continue;
160
+
161
+ let content = this.ts.getFileContent(abs);
162
+
163
+ if (!content) {
164
+ try {
165
+ content = fs.readFileSync(abs, "utf-8");
166
+ } catch {
167
+ continue;
168
+ }
169
+ }
126
170
 
127
171
  const h = hash(content);
128
172
 
129
- if (this.fileHash.get(dep) === h) continue;
173
+ if (this.fileHash.get(abs) === h) continue;
174
+
175
+ this.fileHash.set(abs, h);
130
176
 
131
- this.fileHash.set(dep, h);
132
- this.ts.updateFile(dep, content);
177
+ this.ts.updateFile(abs, content);
133
178
 
134
- const source = this.ts.getSourceFile(dep);
179
+ const source = this.ts.getSourceFile(abs);
135
180
  const checker = this.ts.getChecker();
136
181
 
137
182
  if (!source || !checker) continue;
138
183
 
184
+ this.graph.update(abs, source);
185
+
139
186
  const r = scanSource(
140
187
  source,
141
188
  checker,
@@ -147,7 +194,7 @@ export class EventraEngine {
147
194
  }
148
195
  );
149
196
 
150
- this.fileResults.set(dep, r);
197
+ this.fileResults.set(abs, r);
151
198
  }
152
199
  }
153
200
  }
@@ -1,5 +1,13 @@
1
1
  import ts from "typescript";
2
2
 
3
+ function isRealDeclaration(node: ts.Node) {
4
+ return (
5
+ ts.isFunctionDeclaration(node) ||
6
+ ts.isVariableDeclaration(node) ||
7
+ ts.isClassDeclaration(node)
8
+ );
9
+ }
10
+
3
11
  export function resolveExportedSymbol(
4
12
  symbol: ts.Symbol,
5
13
  checker: ts.TypeChecker,
@@ -8,20 +16,29 @@ export function resolveExportedSymbol(
8
16
 
9
17
  if (visited.has(symbol)) return null;
10
18
  visited.add(symbol);
19
+
20
+ // unwrap alias
11
21
  if (symbol.flags & ts.SymbolFlags.Alias) {
12
22
  const aliased = checker.getAliasedSymbol(symbol);
13
23
  return resolveExportedSymbol(aliased, checker, visited);
14
24
  }
15
25
 
16
- if (symbol.getDeclarations()?.length) {
17
- return symbol;
26
+ // export symbol
27
+ const exportSymbol = checker.getExportSymbolOfSymbol(symbol);
28
+ if (exportSymbol && exportSymbol !== symbol) {
29
+ return resolveExportedSymbol(exportSymbol, checker, visited);
18
30
  }
19
31
 
20
- const declarations = symbol.getDeclarations() ?? [];
32
+ const decls = symbol.getDeclarations() ?? [];
33
+
34
+ if (decls.some(isRealDeclaration)) {
35
+ return symbol;
36
+ }
21
37
 
22
- for (const decl of declarations) {
38
+ // exports (re-export)
39
+ for (const decl of decls) {
23
40
  const source = decl.getSourceFile();
24
- const moduleSymbol = checker.getSymbolAtLocation(source);
41
+ const moduleSymbol = (source as any).symbol;
25
42
 
26
43
  if (!moduleSymbol) continue;
27
44
 
@@ -8,24 +8,95 @@ export function findTrackParamIndex(
8
8
  fn.parameters.forEach((p, i) => {
9
9
  if (ts.isIdentifier(p.name)) {
10
10
  paramIndex.set(p.name.text, i);
11
+
12
+ // default param: (name = "x")
13
+ if (p.initializer && ts.isStringLiteral(p.initializer)) {
14
+ if (i === 0) return 0;
15
+ }
11
16
  }
12
17
  });
13
18
 
14
19
  let result: number | null = null;
15
20
 
21
+ function unwrap(node: ts.Expression): ts.Expression {
22
+ // as / parentheses
23
+ while (
24
+ ts.isAsExpression(node) ||
25
+ ts.isParenthesizedExpression(node)
26
+ ) {
27
+ node = node.expression;
28
+ }
29
+ return node;
30
+ }
31
+
32
+ function checkArg(arg: ts.Expression) {
33
+ arg = unwrap(arg);
34
+
35
+ // identifier → param
36
+ if (ts.isIdentifier(arg)) {
37
+ const idx = paramIndex.get(arg.text);
38
+ if (idx !== undefined) {
39
+ result = idx;
40
+ return true;
41
+ }
42
+ }
43
+
44
+ // string literal
45
+ if (ts.isStringLiteral(arg)) {
46
+ result = 0;
47
+ return true;
48
+ }
49
+
50
+ // template `${name}`
51
+ if (ts.isTemplateExpression(arg)) {
52
+ for (const span of arg.templateSpans) {
53
+ if (ts.isIdentifier(span.expression)) {
54
+ const idx = paramIndex.get(span.expression.text);
55
+ if (idx !== undefined) {
56
+ result = idx;
57
+ return true;
58
+ }
59
+ }
60
+ }
61
+ result = 0;
62
+ return true;
63
+ }
64
+
65
+ // object { event: name }
66
+ if (ts.isObjectLiteralExpression(arg)) {
67
+ for (const prop of arg.properties) {
68
+ if (!ts.isPropertyAssignment(prop)) continue;
69
+
70
+ const name = prop.name.getText();
71
+
72
+ if (["event", "name", "type"].includes(name)) {
73
+ return checkArg(prop.initializer);
74
+ }
75
+ }
76
+ }
77
+
78
+ // call: String(name)
79
+ if (ts.isCallExpression(arg)) {
80
+ for (const inner of arg.arguments) {
81
+ if (checkArg(inner)) return true;
82
+ }
83
+ }
84
+
85
+ return false;
86
+ }
87
+
16
88
  function visit(node: ts.Node) {
89
+ if (result !== null) return;
90
+
17
91
  if (ts.isCallExpression(node)) {
18
92
  let isTrackCall = false;
19
93
 
20
94
  if (ts.isPropertyAccessExpression(node.expression)) {
21
- const method = node.expression.name.text;
22
-
23
- if (method === "track") {
95
+ if (node.expression.name.text === "track") {
24
96
  isTrackCall = true;
25
97
  }
26
98
  }
27
99
 
28
- // fallback
29
100
  if (ts.isIdentifier(node.expression)) {
30
101
  if (node.expression.text === "track") {
31
102
  isTrackCall = true;
@@ -34,18 +105,7 @@ export function findTrackParamIndex(
34
105
 
35
106
  if (isTrackCall) {
36
107
  for (const arg of node.arguments) {
37
- if (ts.isIdentifier(arg)) {
38
- const idx = paramIndex.get(arg.text);
39
- if (idx !== undefined) {
40
- result = idx;
41
- return;
42
- }
43
- }
44
-
45
- if (ts.isStringLiteral(arg)) {
46
- result = 0;
47
- return;
48
- }
108
+ if (checkArg(arg)) return;
49
109
  }
50
110
  }
51
111
  }
@@ -13,15 +13,18 @@ export class DependencyGraph {
13
13
  ) {}
14
14
 
15
15
  private normalize(file: string) {
16
- return path.resolve(file);
16
+ return path.resolve(file).replace(/\\/g, "/");
17
17
  }
18
18
 
19
+ // MODULE RESOLVE
19
20
  private resolveModule(module: string, from: string): string | null {
21
+ // relative import
20
22
  if (module.startsWith(".")) {
21
23
  const abs = path.resolve(path.dirname(from), module);
22
24
  return this.resolveFile(abs);
23
25
  }
24
26
 
27
+ // tsconfig paths
25
28
  for (const key in this.paths) {
26
29
  const pattern = key.replace(/\*/g, "(.*)");
27
30
  const regex = new RegExp("^" + pattern + "$");
@@ -44,10 +47,21 @@ export class DependencyGraph {
44
47
  }
45
48
  }
46
49
 
50
+ // node resolution (safe)
51
+ try {
52
+ const resolved = require.resolve(module, {
53
+ paths: [path.dirname(from)],
54
+ });
55
+
56
+ return this.normalize(resolved);
57
+ } catch {}
58
+
59
+ // fallback
47
60
  const abs = path.resolve(this.baseUrl, module);
48
61
  return this.resolveFile(abs);
49
62
  }
50
63
 
64
+ // FILE RESOLVE
51
65
  private resolveFile(base: string): string | null {
52
66
  if (this.fileCache.has(base)) {
53
67
  return this.fileCache.get(base)!;
@@ -59,6 +73,12 @@ export class DependencyGraph {
59
73
  base + ".tsx",
60
74
  base + ".js",
61
75
  base + ".jsx",
76
+ base + ".mjs",
77
+ base + ".cjs",
78
+ base + ".d.ts",
79
+ base + ".vue",
80
+ base + ".svelte",
81
+ base + ".astro",
62
82
  path.join(base, "index.ts"),
63
83
  path.join(base, "index.tsx"),
64
84
  path.join(base, "index.js"),
@@ -76,16 +96,28 @@ export class DependencyGraph {
76
96
  return null;
77
97
  }
78
98
 
99
+ // UPDATE GRAPH
79
100
  update(file: string, source: ts.SourceFile) {
80
101
  const normalizedFile = this.normalize(file);
81
102
 
103
+ for (const deps of this.reverse.values()) {
104
+ deps.delete(normalizedFile);
105
+ }
106
+
82
107
  this.exports.set(normalizedFile, new Set());
83
108
 
84
109
  for (const stmt of source.statements) {
110
+
111
+ // IMPORT
85
112
  if (ts.isImportDeclaration(stmt)) {
86
- const module = stmt.moduleSpecifier.getText().replace(/['"]/g, "");
87
- const resolved = this.resolveModule(module, normalizedFile);
113
+ // ignore import type
114
+ if ((stmt.importClause as any)?.importKind === "type") continue;
115
+
116
+ const module = stmt.moduleSpecifier
117
+ .getText()
118
+ .replace(/['"]/g, "");
88
119
 
120
+ const resolved = this.resolveModule(module, normalizedFile);
89
121
  if (!resolved) continue;
90
122
 
91
123
  if (!this.reverse.has(resolved)) {
@@ -95,10 +127,15 @@ export class DependencyGraph {
95
127
  this.reverse.get(resolved)!.add(normalizedFile);
96
128
  }
97
129
 
98
- if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier) {
99
- const module = stmt.moduleSpecifier.getText().replace(/['"]/g, "");
100
- const resolved = this.resolveModule(module, normalizedFile);
130
+ // EXPORT (re-export)
131
+ if (ts.isExportDeclaration(stmt)) {
132
+ if (!stmt.moduleSpecifier) continue;
133
+
134
+ const module = stmt.moduleSpecifier
135
+ .getText()
136
+ .replace(/['"]/g, "");
101
137
 
138
+ const resolved = this.resolveModule(module, normalizedFile);
102
139
  if (!resolved) continue;
103
140
 
104
141
  this.exports.get(normalizedFile)?.add(resolved);
@@ -112,6 +149,7 @@ export class DependencyGraph {
112
149
  }
113
150
  }
114
151
 
152
+ // DEPENDENTS
115
153
  getAllDependentsDeep(file: string): string[] {
116
154
  const start = this.normalize(file);
117
155
  const visited = new Set<string>();
@@ -136,6 +174,7 @@ export class DependencyGraph {
136
174
  return [...result];
137
175
  }
138
176
 
177
+ // REMOVE
139
178
  remove(file: string) {
140
179
  const normalized = this.normalize(file);
141
180
 
@@ -7,9 +7,12 @@ export class TSService {
7
7
  private fileNames = new Set<string>();
8
8
  private service: ts.LanguageService;
9
9
 
10
- private readonly compilerOptions: ts.CompilerOptions = {};
11
- private readonly baseUrl: string = "";
12
- private readonly paths: Record<string, string[]> = {};
10
+ private compilerOptions: ts.CompilerOptions;
11
+ private baseUrl: string;
12
+ private paths: Record<string, string[]>;
13
+ private usage = new Map<string, number>();
14
+ private tick = 0;
15
+ private MAX_FILES = 2000;
13
16
 
14
17
  constructor(private root: string) {
15
18
  const configPath = ts.findConfigFile(
@@ -20,13 +23,18 @@ export class TSService {
20
23
 
21
24
  let compilerOptions: ts.CompilerOptions = {
22
25
  allowJs: true,
23
- jsx: ts.JsxEmit.Preserve,
26
+ jsx: ts.JsxEmit.React,
27
+ jsxImportSource: "react",
24
28
  moduleResolution: ts.ModuleResolutionKind.Node16,
25
29
  target: ts.ScriptTarget.ESNext,
26
30
  esModuleInterop: true,
27
31
  allowSyntheticDefaultImports: true,
32
+ skipLibCheck: true,
28
33
  };
29
34
 
35
+ let baseUrl = root;
36
+ let paths: Record<string, string[]> = {};
37
+
30
38
  if (configPath) {
31
39
  const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
32
40
 
@@ -41,56 +49,108 @@ export class TSService {
41
49
  ...parsed.options,
42
50
  };
43
51
 
44
- this.paths = parsed.options.paths || {};
45
- this.baseUrl = parsed.options.baseUrl
52
+ paths = parsed.options.paths || {};
53
+ baseUrl = parsed.options.baseUrl
46
54
  ? path.resolve(root, parsed.options.baseUrl)
47
55
  : root;
48
- } else {
49
- this.baseUrl = root;
50
56
  }
51
57
 
52
58
  this.compilerOptions = compilerOptions;
59
+ this.baseUrl = baseUrl;
60
+ this.paths = paths;
53
61
 
54
62
  const host: ts.LanguageServiceHost = {
55
- getScriptFileNames: () => {
56
- return [...new Set(this.fileNames)].sort((a, b) =>
57
- a.localeCompare(b)
58
- );
59
- },
63
+ getScriptFileNames: () => Array.from(this.fileNames),
60
64
 
61
- getScriptVersion: (file) =>
62
- this.files.get(file)?.version.toString() ?? "0",
65
+ getScriptVersion: (file) => {
66
+ file = this.normalize(file);
67
+ return this.files.get(file)?.version.toString() ?? "0";
68
+ },
63
69
 
64
70
  getScriptSnapshot: (file) => {
65
- const f = this.files.get(file);
71
+ file = this.normalize(file);
66
72
 
73
+ const f = this.files.get(file);
67
74
  if (f) {
75
+ this.touch(file);
68
76
  return ts.ScriptSnapshot.fromString(f.content);
69
77
  }
70
78
 
71
- if (fs.existsSync(file)) {
72
- return ts.ScriptSnapshot.fromString(
73
- fs.readFileSync(file, "utf-8")
74
- );
75
- }
79
+ try {
80
+ if (fs.existsSync(file)) {
81
+ const content = fs.readFileSync(file, "utf-8");
82
+
83
+ this.files.set(file, {
84
+ content,
85
+ version: 1,
86
+ });
87
+
88
+ this.fileNames.add(file);
89
+ this.touch(file);
90
+ this.evictIfNeeded();
76
91
 
77
- return undefined;
92
+ return ts.ScriptSnapshot.fromString(content);
93
+ }
94
+ } catch {}
95
+
96
+ // fallback
97
+ return ts.ScriptSnapshot.fromString("");
78
98
  },
79
99
 
80
100
  getCurrentDirectory: () => this.root,
81
101
  getCompilationSettings: () => this.compilerOptions,
82
102
  getDefaultLibFileName: (opts) =>
83
103
  ts.getDefaultLibFilePath(opts),
84
-
85
- readFile: ts.sys.readFile,
86
104
  fileExists: ts.sys.fileExists,
105
+ readFile: ts.sys.readFile,
87
106
  readDirectory: ts.sys.readDirectory,
88
107
  };
89
108
 
90
109
  this.service = ts.createLanguageService(host);
91
110
  }
92
111
 
112
+ // INTERNAL
113
+ private normalize(file: string) {
114
+ return path.resolve(file).replace(/\\/g, "/");
115
+ }
116
+
117
+ private touch(file: string) {
118
+ this.tick++;
119
+ this.usage.set(file, this.tick);
120
+ }
121
+
122
+ private evictIfNeeded() {
123
+ if (this.fileNames.size <= this.MAX_FILES) return;
124
+
125
+ let oldestFile: string | null = null;
126
+ let oldestTick = Infinity;
127
+
128
+ for (const f of this.fileNames) {
129
+ const t = this.usage.get(f) ?? 0;
130
+
131
+ if (t < oldestTick) {
132
+ oldestTick = t;
133
+ oldestFile = f;
134
+ }
135
+ }
136
+
137
+ if (oldestFile) {
138
+ this.files.delete(oldestFile);
139
+ this.fileNames.delete(oldestFile);
140
+ this.usage.delete(oldestFile);
141
+ }
142
+ }
143
+
144
+ private ensureProgram() {
145
+ try {
146
+ this.service.getProgram();
147
+ } catch {}
148
+ }
149
+
150
+ // PUBLIC API
93
151
  updateFile(file: string, content: string) {
152
+ file = this.normalize(file);
153
+
94
154
  const prev = this.files.get(file);
95
155
 
96
156
  this.files.set(file, {
@@ -99,6 +159,20 @@ export class TSService {
99
159
  });
100
160
 
101
161
  this.fileNames.add(file);
162
+ this.touch(file);
163
+ this.evictIfNeeded();
164
+
165
+ this.ensureProgram();
166
+ }
167
+
168
+ removeFile(file: string) {
169
+ file = this.normalize(file);
170
+
171
+ this.files.delete(file);
172
+ this.fileNames.delete(file);
173
+ this.usage.delete(file);
174
+
175
+ this.ensureProgram();
102
176
  }
103
177
 
104
178
  getProgram() {
@@ -110,18 +184,15 @@ export class TSService {
110
184
  }
111
185
 
112
186
  getSourceFile(file: string) {
187
+ file = this.normalize(file);
113
188
  return this.getProgram()?.getSourceFile(file);
114
189
  }
115
190
 
116
191
  getFileContent(file: string) {
192
+ file = this.normalize(file);
117
193
  return this.files.get(file)?.content;
118
194
  }
119
195
 
120
- removeFile(file: string) {
121
- this.files.delete(file);
122
- this.fileNames.delete(file);
123
- }
124
-
125
196
  getModuleResolutionConfig() {
126
197
  return {
127
198
  baseUrl: this.baseUrl,