@eventra_dev/eventra-cli 2.0.10 → 2.2.0

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.
@@ -11,10 +11,20 @@ const documentRegistry_1 = require("./documentRegistry");
11
11
  class CompilerContext {
12
12
  rootDir;
13
13
  registry = new documentRegistry_1.DocumentRegistry();
14
+ // Parsed files survive a program rebuild: a flush changes a handful of files,
15
+ // and parsing every other file (and the default lib .d.ts files) again was
16
+ // most of the rebuild cost. Reusing a SourceFile object across programs is
17
+ // what the TypeScript language service does too.
18
+ sourceFileCache = new Map();
14
19
  compilerOptions;
15
20
  host;
16
- program = typescript_1.default.createProgram([], {});
21
+ // Built on first use (or by flushUpdates): the engine stages every file of a
22
+ // scan and flushes once, so an eager build in the constructor was thrown away.
23
+ programInstance;
17
24
  rootNames = new Set();
25
+ // Only set while withResolutionCache() runs: it memoises fileExists results,
26
+ // so it must never outlive one batch of resolutions against one program.
27
+ activeResolutionCache;
18
28
  constructor(rootDir) {
19
29
  this.rootDir = rootDir;
20
30
  const localTsconfig = path_1.default.join(rootDir, "tsconfig.json");
@@ -52,7 +62,6 @@ class CompilerContext {
52
62
  this.compilerOptions = compilerOptions;
53
63
  this.host = typescript_1.default.createCompilerHost(this.compilerOptions, true);
54
64
  this.patchCompilerHost();
55
- this.rebuildProgram();
56
65
  }
57
66
  normalize(fileName) {
58
67
  return this.registry.normalize(fileName);
@@ -75,7 +84,17 @@ class CompilerContext {
75
84
  if (!snapshot) {
76
85
  return undefined;
77
86
  }
78
- const sourceFile = typescript_1.default.createSourceFile(normalized, snapshot.getText(0, snapshot.getLength()), languageVersion, true, this.getScriptKind(normalized));
87
+ const content = snapshot.getText(0, snapshot.getLength());
88
+ const scriptKind = this.getScriptKind(normalized);
89
+ const cached = this.sourceFileCache.get(normalized);
90
+ if (cached &&
91
+ cached.content === content &&
92
+ cached.languageVersion === languageVersion &&
93
+ cached.scriptKind === scriptKind) {
94
+ return this.attachVersion(cached.sourceFile, normalized);
95
+ }
96
+ const sourceFile = typescript_1.default.createSourceFile(normalized, content, languageVersion, true, scriptKind);
97
+ this.sourceFileCache.set(normalized, { content, languageVersion, scriptKind, sourceFile });
79
98
  return this.attachVersion(sourceFile, normalized);
80
99
  }
81
100
  patchCompilerHost() {
@@ -127,7 +146,30 @@ class CompilerContext {
127
146
  return typescript_1.default.ScriptKind.Unknown;
128
147
  }
129
148
  rebuildProgram() {
130
- this.program = typescript_1.default.createProgram([...this.rootNames], this.compilerOptions, this.host);
149
+ this.programInstance = typescript_1.default.createProgram([...this.rootNames], this.compilerOptions, this.host);
150
+ }
151
+ get program() {
152
+ if (!this.programInstance) {
153
+ this.rebuildProgram();
154
+ }
155
+ return this.programInstance;
156
+ }
157
+ /**
158
+ * Run `fn` with one shared module resolution cache, so resolving the imports
159
+ * of many files in a row (import graph after a flush) does not repeat the
160
+ * same directory lookups. The cache is dropped when `fn` returns.
161
+ */
162
+ withResolutionCache(fn) {
163
+ if (this.activeResolutionCache) {
164
+ return fn();
165
+ }
166
+ this.activeResolutionCache = typescript_1.default.createModuleResolutionCache(this.rootDir, (fileName) => this.normalize(fileName), this.compilerOptions);
167
+ try {
168
+ return fn();
169
+ }
170
+ finally {
171
+ this.activeResolutionCache = undefined;
172
+ }
131
173
  }
132
174
  /** Register file content without rebuilding the TS program (batch via {@link flushUpdates}). */
133
175
  stageFile(fileName, content) {
@@ -147,9 +189,65 @@ class CompilerContext {
147
189
  removeFile(fileName) {
148
190
  const normalized = this.normalize(fileName);
149
191
  this.registry.remove(normalized);
192
+ this.sourceFileCache.delete(normalized);
150
193
  this.rootNames.delete(normalized);
151
194
  this.rebuildProgram();
152
195
  }
196
+ /**
197
+ * Re-reads files that are in the program only because something imports them
198
+ * (they are not scanned sources): the disk content replaces whatever the
199
+ * registry cached and the program is rebuilt ONCE for the whole batch. A file
200
+ * that no longer exists is dropped. Returns the files whose content really
201
+ * changed (normalized); none of them ever becomes a root.
202
+ */
203
+ refreshFromDisk(fileNames) {
204
+ const changed = [];
205
+ for (const fileName of fileNames) {
206
+ const normalized = this.normalize(fileName);
207
+ const before = this.registry.getContent(normalized);
208
+ let content;
209
+ try {
210
+ content = fs_1.default.readFileSync(normalized, "utf8");
211
+ }
212
+ catch {
213
+ content = undefined;
214
+ }
215
+ if (before === content)
216
+ continue;
217
+ if (content === undefined) {
218
+ this.registry.remove(normalized);
219
+ this.sourceFileCache.delete(normalized);
220
+ }
221
+ else {
222
+ this.registry.update(normalized, content);
223
+ }
224
+ changed.push(normalized);
225
+ }
226
+ if (changed.length === 0) {
227
+ return changed;
228
+ }
229
+ this.rebuildProgram();
230
+ // a file nothing imports must not linger in the registry
231
+ for (const normalized of changed) {
232
+ if (this.registry.has(normalized) && !this.programInstance.getSourceFile(normalized)) {
233
+ this.registry.remove(normalized);
234
+ this.sourceFileCache.delete(normalized);
235
+ }
236
+ }
237
+ return changed;
238
+ }
239
+ /** True when the file is part of the current program (scanned or only imported). */
240
+ hasFile(fileName) {
241
+ return this.program.getSourceFile(this.normalize(fileName)) !== undefined;
242
+ }
243
+ /** Project files of the program (no TypeScript libs, no node_modules), declaration files included. */
244
+ getProjectFileNames() {
245
+ const program = this.program;
246
+ return program
247
+ .getSourceFiles()
248
+ .filter((sf) => !program.isSourceFileDefaultLibrary(sf) && !program.isSourceFileFromExternalLibrary(sf))
249
+ .map((sf) => sf.fileName);
250
+ }
153
251
  getProgram() {
154
252
  return this.program;
155
253
  }
@@ -173,7 +271,7 @@ class CompilerContext {
173
271
  ];
174
272
  }
175
273
  resolveModule(moduleName, fromFile) {
176
- const resolved = typescript_1.default.resolveModuleName(moduleName, this.normalize(fromFile), this.compilerOptions, this.host);
274
+ const resolved = typescript_1.default.resolveModuleName(moduleName, this.normalize(fromFile), this.compilerOptions, this.host, this.activeResolutionCache);
177
275
  return resolved.resolvedModule?.resolvedFileName;
178
276
  }
179
277
  getResolvedModules(fileName) {
@@ -9,8 +9,22 @@ const path_1 = __importDefault(require("path"));
9
9
  const typescript_1 = __importDefault(require("typescript"));
10
10
  class DocumentRegistry {
11
11
  documents = new Map();
12
+ // path.resolve + regex ran thousands of times per scan on the same inputs;
13
+ // keyed on cwd because relative inputs resolve against it
14
+ normalizedCache = new Map();
15
+ normalizedCwd = "";
12
16
  normalize(fileName) {
13
- return path_1.default.resolve(fileName).replace(/\\/g, "/");
17
+ const cwd = process.cwd();
18
+ if (cwd !== this.normalizedCwd) {
19
+ this.normalizedCache.clear();
20
+ this.normalizedCwd = cwd;
21
+ }
22
+ let normalized = this.normalizedCache.get(fileName);
23
+ if (normalized === undefined) {
24
+ normalized = path_1.default.resolve(fileName).replace(/\\/g, "/");
25
+ this.normalizedCache.set(fileName, normalized);
26
+ }
27
+ return normalized;
14
28
  }
15
29
  has(fileName) {
16
30
  return this.documents.has(this.normalize(fileName));
@@ -7,6 +7,13 @@ class ImportGraph {
7
7
  imports = new Map();
8
8
  // import -> importers
9
9
  reverse = new Map();
10
+ hasFile(file) {
11
+ return this.imports.has(file);
12
+ }
13
+ getFiles() {
14
+ return [...this.imports.keys()];
15
+ }
16
+ /** Returns true when the set of files this file imports changed. */
10
17
  updateFile(file, nextImports) {
11
18
  const previous = this.imports.get(file) ?? new Set();
12
19
  const next = new Set(nextImports);
@@ -36,6 +43,7 @@ class ImportGraph {
36
43
  importers.add(file);
37
44
  }
38
45
  this.imports.set(file, next);
46
+ return previous.size !== next.size || [...next].some((imported) => !previous.has(imported));
39
47
  }
40
48
  removeFile(file) {
41
49
  const imports = this.imports.get(file);
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.LOCAL_CONFIG_NAME = exports.CONFIG_NAME = void 0;
7
7
  exports.normalizeConfig = normalizeConfig;
8
8
  exports.loadConfig = loadConfig;
9
+ exports.describeConfigProblem = describeConfigProblem;
10
+ exports.requireConfig = requireConfig;
9
11
  exports.saveConfig = saveConfig;
10
12
  exports.resolveApiKey = resolveApiKey;
11
13
  exports.saveLocalApiKey = saveLocalApiKey;
@@ -21,6 +23,11 @@ exports.CONFIG_NAME = "eventra.json";
21
23
  exports.LOCAL_CONFIG_NAME = "eventra.local.json";
22
24
  const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
23
25
  const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
26
+ function normalizeSinks(sinks) {
27
+ if (!Array.isArray(sinks))
28
+ return [];
29
+ return sinks.filter((s) => typeof s === "object" && s !== null && !Array.isArray(s));
30
+ }
24
31
  function normalizeConfig(config) {
25
32
  const functionWrappers = (config.functionWrappers ?? [])
26
33
  .filter((w) => w?.name)
@@ -32,12 +39,16 @@ function normalizeConfig(config) {
32
39
  seen.add(w.name);
33
40
  return true;
34
41
  });
42
+ const sinks = normalizeSinks(config.sinks);
35
43
  return {
36
44
  apiKey: config.apiKey ?? "",
37
45
  endpoint: config.endpoint ?? "",
38
46
  events: [...(config.events ?? [])].sort(),
39
- functionWrappers: uniqueWrappers.sort((a, b) => a.name.localeCompare(b.name)),
47
+ // plain comparison, not localeCompare: the order in the committed file must
48
+ // not depend on the machine's ICU locale
49
+ functionWrappers: uniqueWrappers.sort((a, b) => (a.name < b.name ? -1 : 1)),
40
50
  plugins: [...(config.plugins ?? [])],
51
+ ...(sinks.length > 0 ? { sinks } : {}),
41
52
  sync: {
42
53
  include: config.sync?.include ?? DEFAULT_INCLUDE,
43
54
  exclude: config.sync?.exclude ?? DEFAULT_EXCLUDE,
@@ -57,9 +68,48 @@ async function loadConfig() {
57
68
  return null;
58
69
  }
59
70
  }
71
+ /**
72
+ * Says why `loadConfig` returned null, for the CLI's error message: a missing
73
+ * file and a malformed one are different problems.
74
+ */
75
+ async function describeConfigProblem() {
76
+ const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
77
+ if (!(await fs_extra_1.default.pathExists(configPath)))
78
+ return `${exports.CONFIG_NAME} not found`;
79
+ try {
80
+ await fs_extra_1.default.readJSON(configPath);
81
+ return `${exports.CONFIG_NAME} could not be read`;
82
+ }
83
+ catch (err) {
84
+ // readJSON only rejects with an Error (ENOENT was ruled out above, the rest is a parse error)
85
+ return `${exports.CONFIG_NAME} is not valid JSON: ${err.message}`;
86
+ }
87
+ }
88
+ /**
89
+ * Loads the config for a command that cannot run without it. On failure it
90
+ * prints the reason and sets a non-zero exit code, and returns null, so every
91
+ * command ends the same way when there is no usable config.
92
+ */
93
+ async function requireConfig() {
94
+ const config = await loadConfig();
95
+ if (config)
96
+ return config;
97
+ console.error(await describeConfigProblem());
98
+ process.exitCode = 1;
99
+ return null;
100
+ }
101
+ /** Writes eventra.json only when its content actually changed (watch mode re-saves often). */
60
102
  async function saveConfig(config) {
61
103
  const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
62
- await fs_extra_1.default.writeJSON(configPath, normalizeConfig(config), { spaces: 2 });
104
+ const next = `${JSON.stringify(normalizeConfig(config), null, 2)}\n`;
105
+ try {
106
+ if ((await fs_extra_1.default.readFile(configPath, "utf8")) === next)
107
+ return;
108
+ }
109
+ catch {
110
+ // missing or unreadable: write it
111
+ }
112
+ await fs_extra_1.default.writeFile(configPath, next);
63
113
  }
64
114
  async function readLocalSecrets() {
65
115
  const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
@@ -15,7 +15,6 @@ const resolvedExportCache_1 = require("../analysis/cache/resolvedExportCache");
15
15
  const returnPropagationCache_1 = require("../analysis/cache/returnPropagationCache");
16
16
  const analyzeFile_1 = require("../analysis/engine/analyzeFile");
17
17
  const wrapperRegistry_1 = require("../analysis/symbols/wrapperRegistry");
18
- const processFile_1 = require("../filesystem/processFile");
19
18
  const loadPlugins_1 = require("../plugin/loadPlugins");
20
19
  const EMPTY_RESULT = () => ({
21
20
  events: new Set(),
@@ -34,6 +33,10 @@ class EventraEngine {
34
33
  fileIndices = new Map();
35
34
  fileResults = new Map();
36
35
  normalizedPathCache = new Map();
36
+ // files whose events count (the scanned sources); files that are in the
37
+ // program only because something imports them are never extracted, as in a
38
+ // fresh scan
39
+ scannedFiles = new Set();
37
40
  plugins;
38
41
  isPreloading = false;
39
42
  lastConfig = {
@@ -47,14 +50,32 @@ class EventraEngine {
47
50
  constructor(rootDir, plugins = (0, loadPlugins_1.createBuiltinPluginRegistry)()) {
48
51
  this.plugins = plugins;
49
52
  this.compiler = new compilerContext_1.CompilerContext(rootDir);
50
- this.wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(this.compiler.getChecker(), this.resolvedExportCache);
53
+ // resolved lazily: asking for the checker now would build the program
54
+ this.wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(() => this.compiler.getChecker(), this.resolvedExportCache);
51
55
  this.scheduler = new scheduler_1.Scheduler(async (updates) => {
52
56
  const changedFiles = new Set();
57
+ // a file the graph has not seen yet changes what other files' imports resolve to
58
+ let fileSetChanged = false;
59
+ // Stage every file, rebuild the program ONCE, then refresh the import
60
+ // graph against that final program (one rebuild per flush, not per file).
53
61
  for (const [file, content] of updates) {
54
- await this.compiler.updateFile(file, content);
55
- this.updateImportGraph(file);
62
+ if (!this.importGraph.hasFile(file))
63
+ fileSetChanged = true;
64
+ this.scannedFiles.add(file);
65
+ this.compiler.stageFile(file, content);
56
66
  changedFiles.add(file);
57
67
  }
68
+ this.compiler.flushUpdates();
69
+ const edgeChanged = new Set();
70
+ this.compiler.withResolutionCache(() => {
71
+ for (const file of changedFiles) {
72
+ this.updateImportGraph(file);
73
+ }
74
+ });
75
+ if (fileSetChanged) {
76
+ for (const file of this.refreshImportEdges(changedFiles))
77
+ edgeChanged.add(file);
78
+ }
58
79
  // Program was rebuilt → every ts.Symbol from the previous program is now
59
80
  // dead. WeakMap-keyed caches (incl. WrapperRegistry) must be dropped before
60
81
  // we touch any NEW symbols, otherwise cross-file wrapper lookups miss.
@@ -64,18 +85,20 @@ class EventraEngine {
64
85
  // symbols for every file, otherwise affected files cannot resolve
65
86
  // wrappers defined in unaffected files (this is the watch-corruption bug).
66
87
  this.reindexAll(this.lastConfig);
67
- // Incremental extract: only re-extract changed files + their dependents.
88
+ // Incremental extract: only re-extract changed files + their dependents
89
+ // (+ files whose imports now resolve differently, and their dependents).
68
90
  // Unaffected files keep their previously-computed Set<string> events,
69
91
  // which is safe (just strings, no AST refs).
70
92
  const affected = new Set();
71
- for (const file of changedFiles) {
93
+ for (const file of [...changedFiles, ...edgeChanged]) {
72
94
  affected.add(file);
73
95
  for (const dependent of this.importGraph.collectDependents(file)) {
74
96
  affected.add(dependent);
75
97
  }
76
98
  }
77
99
  for (const file of affected) {
78
- this.extractFile(file, this.lastConfig);
100
+ if (this.scannedFiles.has(file))
101
+ this.extractFile(file, this.lastConfig);
79
102
  }
80
103
  });
81
104
  }
@@ -107,12 +130,14 @@ class EventraEngine {
107
130
  this.normalizedPathCache.set(fileName, normalized);
108
131
  return normalized;
109
132
  }
133
+ // true when the file's resolved imports changed
110
134
  updateImportGraph(fileName) {
111
135
  const normalized = this.normalize(fileName);
112
136
  const source = this.compiler.getSourceFile(normalized);
113
137
  if (!source) {
138
+ const known = this.importGraph.hasFile(normalized);
114
139
  this.importGraph.removeFile(normalized);
115
- return;
140
+ return known;
116
141
  }
117
142
  const imports = [];
118
143
  for (const statement of source.statements) {
@@ -132,22 +157,45 @@ class EventraEngine {
132
157
  }
133
158
  imports.push(this.normalize(resolved));
134
159
  }
135
- this.importGraph.updateFile(normalized, imports);
160
+ return this.importGraph.updateFile(normalized, imports);
161
+ }
162
+ /**
163
+ * Creating or deleting a file can change what OTHER files' imports resolve to,
164
+ * without those files changing: an import written before its target existed
165
+ * starts to resolve, a deleted target stops. Their edges are re-resolved
166
+ * against the current program; the files whose imports changed are returned
167
+ * so they get analysed again (the graph alone cannot know about them: the
168
+ * reverse edge of a file that did not exist was never recorded).
169
+ */
170
+ refreshImportEdges(skip) {
171
+ const changed = new Set();
172
+ this.compiler.withResolutionCache(() => {
173
+ for (const file of this.importGraph.getFiles()) {
174
+ if (skip.has(file))
175
+ continue;
176
+ if (this.updateImportGraph(file))
177
+ changed.add(file);
178
+ }
179
+ });
180
+ return changed;
136
181
  }
137
182
  async preloadFile(fileName, content) {
138
183
  if (!this.isPreloading) {
139
184
  throw new Error("preload phase not active");
140
185
  }
141
186
  const normalized = this.normalize(fileName);
142
- const processed = await (0, processFile_1.processFile)(normalized, content);
143
- this.compiler.stageFile(processed.fileName, processed.content);
187
+ // processFile only echoed fileName/content back (its dependency list was
188
+ // unused here), so stage directly instead of parsing the file a second time.
189
+ this.compiler.stageFile(normalized, content);
144
190
  }
145
191
  endPreload() {
146
192
  this.isPreloading = false;
147
193
  this.compiler.flushUpdates();
148
- for (const sourceFile of this.compiler.getAllSourceFiles()) {
149
- this.updateImportGraph(sourceFile.fileName);
150
- }
194
+ this.compiler.withResolutionCache(() => {
195
+ for (const sourceFile of this.compiler.getAllSourceFiles()) {
196
+ this.updateImportGraph(sourceFile.fileName);
197
+ }
198
+ });
151
199
  }
152
200
  async updateFile(fileName, content, config) {
153
201
  this.lastConfig = config;
@@ -162,6 +210,7 @@ class EventraEngine {
162
210
  this.refreshChecker();
163
211
  const normalized = fileNames.map((f) => this.normalize(f));
164
212
  for (const file of normalized) {
213
+ this.scannedFiles.add(file);
165
214
  this.indexFile(file, config);
166
215
  }
167
216
  for (const file of normalized) {
@@ -178,8 +227,62 @@ class EventraEngine {
178
227
  this.reindexAll(config);
179
228
  const normalized = fileNames.map((f) => this.normalize(f));
180
229
  for (const file of normalized) {
181
- this.extractFile(file, config);
230
+ if (this.scannedFiles.has(file))
231
+ this.extractFile(file, config);
232
+ }
233
+ }
234
+ /**
235
+ * Files that are only imported (not scanned, e.g. `shared/events.ts` outside
236
+ * `include`) changed on disk, appeared or vanished. Their content is re-read
237
+ * (one program rebuild for the whole batch), the import graph is refreshed and
238
+ * the scanned files that depend on them are analysed again, so a long-running
239
+ * engine agrees with a fresh scan. Returns false when no content changed.
240
+ */
241
+ async refreshExternalFiles(fileNames, config) {
242
+ this.lastConfig = config;
243
+ const normalized = fileNames.map((f) => this.normalize(f));
244
+ // dependents are read before the edges change
245
+ const affected = new Set();
246
+ for (const file of normalized) {
247
+ for (const dependent of this.importGraph.collectDependents(file)) {
248
+ affected.add(dependent);
249
+ }
250
+ }
251
+ const changed = this.compiler.refreshFromDisk(normalized);
252
+ if (changed.length === 0) {
253
+ return false;
254
+ }
255
+ this.compiler.withResolutionCache(() => {
256
+ for (const file of changed) {
257
+ this.updateImportGraph(file);
258
+ }
259
+ });
260
+ // importers that had no edge before (a missing target appeared) or lost it
261
+ // (the target vanished), and everything depending on them
262
+ for (const file of this.refreshImportEdges(new Set(changed))) {
263
+ affected.add(file);
264
+ for (const dependent of this.importGraph.collectDependents(file)) {
265
+ affected.add(dependent);
266
+ }
182
267
  }
268
+ for (const file of changed) {
269
+ for (const dependent of this.importGraph.collectDependents(file)) {
270
+ affected.add(dependent);
271
+ }
272
+ }
273
+ for (const file of changed) {
274
+ affected.delete(file);
275
+ }
276
+ await this.reanalyzeFiles([...affected], config);
277
+ return true;
278
+ }
279
+ /** True when the file is in the program (scanned or only imported). */
280
+ hasProgramFile(fileName) {
281
+ return this.compiler.hasFile(this.normalize(fileName));
282
+ }
283
+ /** Project files in the program, for watching those that live outside the project root. */
284
+ getProjectFileNames() {
285
+ return this.compiler.getProjectFileNames();
183
286
  }
184
287
  indexFile(fileName, _config) {
185
288
  const normalized = this.normalize(fileName);
@@ -204,6 +307,7 @@ class EventraEngine {
204
307
  }
205
308
  async scanFile(fileName, config) {
206
309
  this.lastConfig = config;
310
+ this.scannedFiles.add(this.normalize(fileName));
207
311
  this.refreshChecker();
208
312
  this.indexFile(fileName, config);
209
313
  return this.extractFile(fileName, config);
@@ -215,8 +319,16 @@ class EventraEngine {
215
319
  this.compiler.removeFile(normalized);
216
320
  this.fileIndices.delete(normalized);
217
321
  this.fileResults.delete(normalized);
322
+ this.scannedFiles.delete(normalized);
218
323
  this.normalizedPathCache.delete(fileName);
219
324
  this.normalizedPathCache.delete(normalized);
325
+ // importers of the removed file now resolve differently (and another file
326
+ // may now win a resolution the removed one was shadowing)
327
+ for (const file of this.refreshImportEdges(new Set([normalized]))) {
328
+ for (const dependent of this.importGraph.collectDependents(file)) {
329
+ affected.add(dependent);
330
+ }
331
+ }
220
332
  const nextConfig = config ?? this.lastConfig;
221
333
  const toReanalyze = [...affected].filter((f) => f !== normalized);
222
334
  // reanalyzeFiles already resets caches + reindexes all sources,
@@ -259,7 +371,8 @@ class EventraEngine {
259
371
  for (const result of this.fileResults.values()) {
260
372
  occurrences.push(...result.dynamicOccurrences);
261
373
  }
262
- return occurrences.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.line - b.line);
374
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
375
+ return occurrences.sort((a, b) => byText(a.fileName, b.fileName) || a.line - b.line || a.character - b.character);
263
376
  }
264
377
  }
265
378
  exports.EventraEngine = EventraEngine;
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isPullRequestBuild = isPullRequestBuild;
4
+ exports.detectBranch = detectBranch;
5
+ exports.countUnresolved = countUnresolved;
6
+ exports.collectCatalogMeta = collectCatalogMeta;
7
+ const child_process_1 = require("child_process");
8
+ const projectScanner_1 = require("./projectScanner");
9
+ const MAX_BRANCH_LENGTH = 200;
10
+ // CI systems usually check out a detached HEAD, so git cannot name the branch
11
+ // there; these variables can. First one set wins.
12
+ const BRANCH_ENV_VARS = [
13
+ "GITHUB_REF_NAME",
14
+ "CI_COMMIT_REF_NAME",
15
+ "BRANCH_NAME",
16
+ "BITBUCKET_BRANCH",
17
+ "CIRCLE_BRANCH",
18
+ "BUILDKITE_BRANCH",
19
+ "VERCEL_GIT_COMMIT_REF",
20
+ ];
21
+ // A pull / merge request build checks out the PROPOSED code: a PR that deletes
22
+ // an event must not mark it removed on the project catalog.
23
+ const PULL_REQUEST_MARKERS = [
24
+ (env) => env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target",
25
+ (env) => (env.GITHUB_REF ?? "").startsWith("refs/pull/"),
26
+ (env) => Boolean(env.CI_MERGE_REQUEST_IID) || env.CI_PIPELINE_SOURCE === "merge_request_event",
27
+ (env) => Boolean(env.BITBUCKET_PR_ID),
28
+ (env) => Boolean(env.CIRCLE_PULL_REQUEST || env.CIRCLE_PR_NUMBER),
29
+ (env) => Boolean(env.BUILDKITE_PULL_REQUEST) && env.BUILDKITE_PULL_REQUEST !== "false",
30
+ (env) => Boolean(env.VERCEL_GIT_PULL_REQUEST_ID),
31
+ (env) => Boolean(env.TRAVIS_PULL_REQUEST) && env.TRAVIS_PULL_REQUEST !== "false",
32
+ (env) => Boolean(env.SYSTEM_PULLREQUEST_PULLREQUESTID) || env.BUILD_REASON === "PullRequest",
33
+ (env) => Boolean(env.CHANGE_ID),
34
+ ];
35
+ /** True when the environment says this run is a pull / merge request build. */
36
+ function isPullRequestBuild(env = process.env) {
37
+ return PULL_REQUEST_MARKERS.some((marker) => marker(env));
38
+ }
39
+ /** Best-effort current branch. Never throws; returns undefined when unknown. */
40
+ function detectBranch(env = process.env, cwd = process.cwd()) {
41
+ for (const name of BRANCH_ENV_VARS) {
42
+ const value = env[name]?.trim();
43
+ if (value)
44
+ return value.slice(0, MAX_BRANCH_LENGTH);
45
+ }
46
+ try {
47
+ const out = (0, child_process_1.execFileSync)("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
48
+ cwd,
49
+ encoding: "utf8",
50
+ timeout: 3000,
51
+ stdio: ["ignore", "pipe", "ignore"],
52
+ }).trim();
53
+ // "HEAD" is what git prints for a detached checkout: not a branch name
54
+ if (out && out !== "HEAD")
55
+ return out.slice(0, MAX_BRANCH_LENGTH);
56
+ }
57
+ catch {
58
+ // not a git checkout, git missing or too slow
59
+ }
60
+ return undefined;
61
+ }
62
+ function sameSet(a, b) {
63
+ if (a.length !== b.length)
64
+ return false;
65
+ const set = new Set(a);
66
+ return b.every((value) => set.has(value));
67
+ }
68
+ // An Angular template module embeds a copy of its component class, so the same
69
+ // call is reported twice: once from the class file and once from that copy
70
+ // (inGeneratedCode). Only the real one is counted.
71
+ function countUnresolved(occurrences) {
72
+ return occurrences.filter((o) => o.resolvedValues.length === 0 && !o.inGeneratedCode).length;
73
+ }
74
+ /**
75
+ * Scans the project again to find out whether the event list about to be sent
76
+ * is complete. Never throws: any failure means complete = false.
77
+ */
78
+ async function collectCatalogMeta(config) {
79
+ const branch = detectBranch();
80
+ const withBranch = (meta) => (branch ? { ...meta, branch } : meta);
81
+ try {
82
+ const { engine, skipped } = await (0, projectScanner_1.scanProject)(config);
83
+ const scanned = engine.getAllEvents();
84
+ const dynamicCount = countUnresolved(engine.getAllDynamicOccurrences());
85
+ const complete = skipped.length === 0 && !isPullRequestBuild() && sameSet(config.events ?? [], scanned);
86
+ return withBranch({ complete, dynamicCount });
87
+ }
88
+ catch {
89
+ return withBranch({ complete: false });
90
+ }
91
+ }
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OVERSIZED_REASON = exports.MAX_FILE_CHARS = void 0;
4
+ exports.isOversized = isOversized;
5
+ // Files above this size are generated or vendored output (bundles, minified
6
+ // code, big fixtures), not something to analyse. One limit for every command,
7
+ // so `sync` and `watch` can never disagree about a file.
8
+ exports.MAX_FILE_CHARS = 2_000_000;
9
+ exports.OVERSIZED_REASON = "file is larger than 2 MB";
10
+ function isOversized(content) {
11
+ return content.length > exports.MAX_FILE_CHARS;
12
+ }