@eventra_dev/eventra-cli 2.1.0 → 2.3.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.
@@ -14,8 +14,8 @@ const resolvedCallCache_1 = require("../analysis/cache/resolvedCallCache");
14
14
  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
+ const wrapperChains_1 = require("../analysis/scanner/wrapperChains");
17
18
  const wrapperRegistry_1 = require("../analysis/symbols/wrapperRegistry");
18
- const processFile_1 = require("../filesystem/processFile");
19
19
  const loadPlugins_1 = require("../plugin/loadPlugins");
20
20
  const EMPTY_RESULT = () => ({
21
21
  events: new Set(),
@@ -34,6 +34,10 @@ class EventraEngine {
34
34
  fileIndices = new Map();
35
35
  fileResults = new Map();
36
36
  normalizedPathCache = new Map();
37
+ // files whose events count (the scanned sources); files that are in the
38
+ // program only because something imports them are never extracted, as in a
39
+ // fresh scan
40
+ scannedFiles = new Set();
37
41
  plugins;
38
42
  isPreloading = false;
39
43
  lastConfig = {
@@ -47,14 +51,32 @@ class EventraEngine {
47
51
  constructor(rootDir, plugins = (0, loadPlugins_1.createBuiltinPluginRegistry)()) {
48
52
  this.plugins = plugins;
49
53
  this.compiler = new compilerContext_1.CompilerContext(rootDir);
50
- this.wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(this.compiler.getChecker(), this.resolvedExportCache);
54
+ // resolved lazily: asking for the checker now would build the program
55
+ this.wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(() => this.compiler.getChecker(), this.resolvedExportCache);
51
56
  this.scheduler = new scheduler_1.Scheduler(async (updates) => {
52
57
  const changedFiles = new Set();
58
+ // a file the graph has not seen yet changes what other files' imports resolve to
59
+ let fileSetChanged = false;
60
+ // Stage every file, rebuild the program ONCE, then refresh the import
61
+ // graph against that final program (one rebuild per flush, not per file).
53
62
  for (const [file, content] of updates) {
54
- await this.compiler.updateFile(file, content);
55
- this.updateImportGraph(file);
63
+ if (!this.importGraph.hasFile(file))
64
+ fileSetChanged = true;
65
+ this.scannedFiles.add(file);
66
+ this.compiler.stageFile(file, content);
56
67
  changedFiles.add(file);
57
68
  }
69
+ this.compiler.flushUpdates();
70
+ const edgeChanged = new Set();
71
+ this.compiler.withResolutionCache(() => {
72
+ for (const file of changedFiles) {
73
+ this.updateImportGraph(file);
74
+ }
75
+ });
76
+ if (fileSetChanged) {
77
+ for (const file of this.refreshImportEdges(changedFiles))
78
+ edgeChanged.add(file);
79
+ }
58
80
  // Program was rebuilt → every ts.Symbol from the previous program is now
59
81
  // dead. WeakMap-keyed caches (incl. WrapperRegistry) must be dropped before
60
82
  // we touch any NEW symbols, otherwise cross-file wrapper lookups miss.
@@ -64,18 +86,20 @@ class EventraEngine {
64
86
  // symbols for every file, otherwise affected files cannot resolve
65
87
  // wrappers defined in unaffected files (this is the watch-corruption bug).
66
88
  this.reindexAll(this.lastConfig);
67
- // Incremental extract: only re-extract changed files + their dependents.
89
+ // Incremental extract: only re-extract changed files + their dependents
90
+ // (+ files whose imports now resolve differently, and their dependents).
68
91
  // Unaffected files keep their previously-computed Set<string> events,
69
92
  // which is safe (just strings, no AST refs).
70
93
  const affected = new Set();
71
- for (const file of changedFiles) {
94
+ for (const file of [...changedFiles, ...edgeChanged]) {
72
95
  affected.add(file);
73
96
  for (const dependent of this.importGraph.collectDependents(file)) {
74
97
  affected.add(dependent);
75
98
  }
76
99
  }
77
100
  for (const file of affected) {
78
- this.extractFile(file, this.lastConfig);
101
+ if (this.scannedFiles.has(file))
102
+ this.extractFile(file, this.lastConfig);
79
103
  }
80
104
  });
81
105
  }
@@ -94,6 +118,17 @@ class EventraEngine {
94
118
  for (const sf of sources) {
95
119
  this.indexFile(sf.fileName, config);
96
120
  }
121
+ this.resolveWrapperChains();
122
+ }
123
+ // Wrappers that reach a sink only through another wrapper (`trackEvent` -> `capture` -> the SDK
124
+ // call) need every file indexed first, so this runs after the files are indexed, never per file.
125
+ resolveWrapperChains() {
126
+ const found = (0, wrapperChains_1.resolveWrapperChains)(this.fileIndices.values(), this.compiler.getChecker(), this.wrapperRegistry, this.resolvedCallCache, this.resolvedExportCache);
127
+ for (const [file, wrappers] of found) {
128
+ const index = this.fileIndices.get(file);
129
+ const kept = index.wrappers.filter((w) => !wrappers.some((n) => n.declaration === w.declaration));
130
+ this.fileIndices.set(file, { ...index, wrappers: [...kept, ...wrappers] });
131
+ }
97
132
  }
98
133
  beginPreload() {
99
134
  this.isPreloading = true;
@@ -107,12 +142,14 @@ class EventraEngine {
107
142
  this.normalizedPathCache.set(fileName, normalized);
108
143
  return normalized;
109
144
  }
145
+ // true when the file's resolved imports changed
110
146
  updateImportGraph(fileName) {
111
147
  const normalized = this.normalize(fileName);
112
148
  const source = this.compiler.getSourceFile(normalized);
113
149
  if (!source) {
150
+ const known = this.importGraph.hasFile(normalized);
114
151
  this.importGraph.removeFile(normalized);
115
- return;
152
+ return known;
116
153
  }
117
154
  const imports = [];
118
155
  for (const statement of source.statements) {
@@ -132,22 +169,45 @@ class EventraEngine {
132
169
  }
133
170
  imports.push(this.normalize(resolved));
134
171
  }
135
- this.importGraph.updateFile(normalized, imports);
172
+ return this.importGraph.updateFile(normalized, imports);
173
+ }
174
+ /**
175
+ * Creating or deleting a file can change what OTHER files' imports resolve to,
176
+ * without those files changing: an import written before its target existed
177
+ * starts to resolve, a deleted target stops. Their edges are re-resolved
178
+ * against the current program; the files whose imports changed are returned
179
+ * so they get analysed again (the graph alone cannot know about them: the
180
+ * reverse edge of a file that did not exist was never recorded).
181
+ */
182
+ refreshImportEdges(skip) {
183
+ const changed = new Set();
184
+ this.compiler.withResolutionCache(() => {
185
+ for (const file of this.importGraph.getFiles()) {
186
+ if (skip.has(file))
187
+ continue;
188
+ if (this.updateImportGraph(file))
189
+ changed.add(file);
190
+ }
191
+ });
192
+ return changed;
136
193
  }
137
194
  async preloadFile(fileName, content) {
138
195
  if (!this.isPreloading) {
139
196
  throw new Error("preload phase not active");
140
197
  }
141
198
  const normalized = this.normalize(fileName);
142
- const processed = await (0, processFile_1.processFile)(normalized, content);
143
- this.compiler.stageFile(processed.fileName, processed.content);
199
+ // processFile only echoed fileName/content back (its dependency list was
200
+ // unused here), so stage directly instead of parsing the file a second time.
201
+ this.compiler.stageFile(normalized, content);
144
202
  }
145
203
  endPreload() {
146
204
  this.isPreloading = false;
147
205
  this.compiler.flushUpdates();
148
- for (const sourceFile of this.compiler.getAllSourceFiles()) {
149
- this.updateImportGraph(sourceFile.fileName);
150
- }
206
+ this.compiler.withResolutionCache(() => {
207
+ for (const sourceFile of this.compiler.getAllSourceFiles()) {
208
+ this.updateImportGraph(sourceFile.fileName);
209
+ }
210
+ });
151
211
  }
152
212
  async updateFile(fileName, content, config) {
153
213
  this.lastConfig = config;
@@ -162,8 +222,10 @@ class EventraEngine {
162
222
  this.refreshChecker();
163
223
  const normalized = fileNames.map((f) => this.normalize(f));
164
224
  for (const file of normalized) {
225
+ this.scannedFiles.add(file);
165
226
  this.indexFile(file, config);
166
227
  }
228
+ this.resolveWrapperChains();
167
229
  for (const file of normalized) {
168
230
  this.extractFile(file, config);
169
231
  }
@@ -178,8 +240,62 @@ class EventraEngine {
178
240
  this.reindexAll(config);
179
241
  const normalized = fileNames.map((f) => this.normalize(f));
180
242
  for (const file of normalized) {
181
- this.extractFile(file, config);
243
+ if (this.scannedFiles.has(file))
244
+ this.extractFile(file, config);
245
+ }
246
+ }
247
+ /**
248
+ * Files that are only imported (not scanned, e.g. `shared/events.ts` outside
249
+ * `include`) changed on disk, appeared or vanished. Their content is re-read
250
+ * (one program rebuild for the whole batch), the import graph is refreshed and
251
+ * the scanned files that depend on them are analysed again, so a long-running
252
+ * engine agrees with a fresh scan. Returns false when no content changed.
253
+ */
254
+ async refreshExternalFiles(fileNames, config) {
255
+ this.lastConfig = config;
256
+ const normalized = fileNames.map((f) => this.normalize(f));
257
+ // dependents are read before the edges change
258
+ const affected = new Set();
259
+ for (const file of normalized) {
260
+ for (const dependent of this.importGraph.collectDependents(file)) {
261
+ affected.add(dependent);
262
+ }
263
+ }
264
+ const changed = this.compiler.refreshFromDisk(normalized);
265
+ if (changed.length === 0) {
266
+ return false;
267
+ }
268
+ this.compiler.withResolutionCache(() => {
269
+ for (const file of changed) {
270
+ this.updateImportGraph(file);
271
+ }
272
+ });
273
+ // importers that had no edge before (a missing target appeared) or lost it
274
+ // (the target vanished), and everything depending on them
275
+ for (const file of this.refreshImportEdges(new Set(changed))) {
276
+ affected.add(file);
277
+ for (const dependent of this.importGraph.collectDependents(file)) {
278
+ affected.add(dependent);
279
+ }
280
+ }
281
+ for (const file of changed) {
282
+ for (const dependent of this.importGraph.collectDependents(file)) {
283
+ affected.add(dependent);
284
+ }
285
+ }
286
+ for (const file of changed) {
287
+ affected.delete(file);
182
288
  }
289
+ await this.reanalyzeFiles([...affected], config);
290
+ return true;
291
+ }
292
+ /** True when the file is in the program (scanned or only imported). */
293
+ hasProgramFile(fileName) {
294
+ return this.compiler.hasFile(this.normalize(fileName));
295
+ }
296
+ /** Project files in the program, for watching those that live outside the project root. */
297
+ getProjectFileNames() {
298
+ return this.compiler.getProjectFileNames();
183
299
  }
184
300
  indexFile(fileName, _config) {
185
301
  const normalized = this.normalize(fileName);
@@ -204,8 +320,10 @@ class EventraEngine {
204
320
  }
205
321
  async scanFile(fileName, config) {
206
322
  this.lastConfig = config;
323
+ this.scannedFiles.add(this.normalize(fileName));
207
324
  this.refreshChecker();
208
325
  this.indexFile(fileName, config);
326
+ this.resolveWrapperChains();
209
327
  return this.extractFile(fileName, config);
210
328
  }
211
329
  async removeFile(fileName, config) {
@@ -215,8 +333,16 @@ class EventraEngine {
215
333
  this.compiler.removeFile(normalized);
216
334
  this.fileIndices.delete(normalized);
217
335
  this.fileResults.delete(normalized);
336
+ this.scannedFiles.delete(normalized);
218
337
  this.normalizedPathCache.delete(fileName);
219
338
  this.normalizedPathCache.delete(normalized);
339
+ // importers of the removed file now resolve differently (and another file
340
+ // may now win a resolution the removed one was shadowing)
341
+ for (const file of this.refreshImportEdges(new Set([normalized]))) {
342
+ for (const dependent of this.importGraph.collectDependents(file)) {
343
+ affected.add(dependent);
344
+ }
345
+ }
220
346
  const nextConfig = config ?? this.lastConfig;
221
347
  const toReanalyze = [...affected].filter((f) => f !== normalized);
222
348
  // reanalyzeFiles already resets caches + reindexes all sources,
@@ -259,7 +385,8 @@ class EventraEngine {
259
385
  for (const result of this.fileResults.values()) {
260
386
  occurrences.push(...result.dynamicOccurrences);
261
387
  }
262
- return occurrences.sort((a, b) => a.fileName.localeCompare(b.fileName) || a.line - b.line);
388
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
389
+ return occurrences.sort((a, b) => byText(a.fileName, b.fileName) || a.line - b.line || a.character - b.character);
263
390
  }
264
391
  }
265
392
  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
+ }
@@ -12,59 +12,47 @@ const EventraEngine_1 = require("./EventraEngine");
12
12
  const processFile_1 = require("../filesystem/processFile");
13
13
  const getVirtualFile_1 = require("../filesystem/getVirtualFile");
14
14
  const loadPlugins_1 = require("../plugin/loadPlugins");
15
+ const scanFilters_1 = require("./scanFilters");
16
+ const skipped_1 = require("./skipped");
17
+ const fileLimits_1 = require("./fileLimits");
15
18
  const SDK_TYPES_FILE = "__eventra_sdk_types__.d.ts";
16
- async function getParsedFile(file, cache) {
17
- const existing = cache.get(file);
18
- if (existing)
19
- return existing;
20
- const raw = await promises_1.default.readFile(file, "utf8");
21
- const parsed = await (0, processFile_1.processFile)(file, raw);
22
- cache.set(file, parsed);
23
- return parsed;
24
- }
25
19
  async function scanProject(config) {
26
20
  const plugins = await (0, loadPlugins_1.createPluginRegistry)(config);
27
21
  const include = [...config.sync.include, ...plugins.getIncludePatterns()];
28
22
  const files = await (0, fast_glob_1.default)(include, {
29
- ignore: config.sync.exclude,
23
+ ignore: (0, scanFilters_1.expandExcludePatterns)(config.sync.exclude),
30
24
  absolute: true,
31
25
  });
32
26
  const engine = new EventraEngine_1.EventraEngine(process.cwd(), plugins);
33
27
  const cache = new Map();
34
28
  const toScan = new Set();
29
+ const skipped = [];
30
+ const ignored = [];
35
31
  for (const file of files) {
36
32
  try {
37
33
  const raw = await promises_1.default.readFile(file, "utf8");
38
- if (raw.length > 2_000_000)
34
+ if ((0, fileLimits_1.isOversized)(raw)) {
35
+ ignored.push({ file, reason: fileLimits_1.OVERSIZED_REASON });
39
36
  continue;
37
+ }
40
38
  const virtualFiles = await plugins.preprocessFile({ fileName: file, content: raw });
41
39
  for (const virtualFile of virtualFiles) {
42
40
  const parsed = await (0, processFile_1.processFile)(virtualFile.fileName, virtualFile.content);
43
41
  const scanPath = path_1.default.resolve(virtualFile.fileName);
44
42
  cache.set(scanPath, parsed);
45
43
  toScan.add(scanPath);
46
- for (const dep of parsed.dependencies) {
47
- if (dep.startsWith(".") || dep.startsWith("/")) {
48
- toScan.add(path_1.default.resolve(path_1.default.dirname(scanPath), dep));
49
- }
50
- }
51
44
  }
52
45
  }
53
46
  catch (err) {
54
- console.error(`skip: ${file}`, err instanceof Error ? err.message : err);
47
+ skipped.push((0, skipped_1.skippedFrom)(file, err));
55
48
  }
56
49
  }
57
50
  const virtualFiles = [SDK_TYPES_FILE, ...toScan].map((f) => (0, getVirtualFile_1.getVirtualFile)(f));
58
51
  engine.beginPreload();
59
52
  await engine.preloadFile(SDK_TYPES_FILE, eventraSdk_1.EVENTRA_SDK_SHIM);
60
53
  for (const file of toScan) {
61
- try {
62
- const parsed = await getParsedFile(file, cache);
63
- await engine.preloadFile((0, getVirtualFile_1.getVirtualFile)(file), parsed.content);
64
- }
65
- catch (err) {
66
- console.error(`preload skip: ${file}`, err instanceof Error ? err.message : err);
67
- }
54
+ // every path in toScan was parsed and cached in the loop above
55
+ await engine.preloadFile((0, getVirtualFile_1.getVirtualFile)(file), cache.get(file).content);
68
56
  }
69
57
  engine.endPreload();
70
58
  await engine.runFullAnalysis(virtualFiles, config);
@@ -77,5 +65,7 @@ async function scanProject(config) {
77
65
  results,
78
66
  files,
79
67
  plugins,
68
+ skipped,
69
+ ignored,
80
70
  };
81
71
  }
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.expandExcludePatterns = expandExcludePatterns;
7
+ exports.globToRegExp = globToRegExp;
8
+ exports.toPosix = toPosix;
9
+ exports.createFileFilter = createFileFilter;
10
+ const path_1 = __importDefault(require("path"));
11
+ const GLOB_CHARS = /[*?[\]{}()!]/;
12
+ /**
13
+ * `sync.exclude` entries are written as bare directory names (`node_modules`,
14
+ * `dist`). fast-glob only matches a bare name against the repo root, so a
15
+ * nested `packages/x/node_modules` would still be scanned. A plain name is
16
+ * therefore expanded to match at any depth; entries that already contain a
17
+ * `/` or glob characters are left exactly as written.
18
+ */
19
+ function expandExcludePatterns(exclude) {
20
+ const out = new Set();
21
+ for (const entry of exclude) {
22
+ const pattern = entry.trim();
23
+ if (!pattern)
24
+ continue;
25
+ if (pattern.includes("/") || GLOB_CHARS.test(pattern)) {
26
+ out.add(pattern);
27
+ }
28
+ else {
29
+ out.add(`**/${pattern}`);
30
+ out.add(`**/${pattern}/**`);
31
+ }
32
+ }
33
+ return [...out];
34
+ }
35
+ // fast-glob runs with dot:false: `*`, `**` and `?` never match a path segment
36
+ // that starts with a dot (.svelte-kit, .nuxt, .turbo, ...) unless the glob
37
+ // spells the dot itself (`.github/**`). The watch filter must agree, or
38
+ // generated output in those directories leaks in while `sync` ignores it.
39
+ const NO_DOT = "(?!\\.)";
40
+ /** Converts the glob subset used by eventra configs (`**`, `*`, `?`, `{a,b}`, `[..]`) to a RegExp. */
41
+ function globToRegExp(glob) {
42
+ const source = glob.replace(/^\.\//, "");
43
+ let out = "";
44
+ let braceDepth = 0;
45
+ // a segment starts at the beginning, after a slash, or at a brace alternative
46
+ const atSegmentStart = (i) => i === 0 || "/{,".includes(source[i - 1]);
47
+ for (let i = 0; i < source.length; i++) {
48
+ const ch = source[i];
49
+ if (ch === "*") {
50
+ if (source[i + 1] === "*") {
51
+ if (source[i + 2] === "/" && atSegmentStart(i)) {
52
+ // zero or more whole segments, none of them hidden
53
+ out += `(?:${NO_DOT}[^/]+/)*`;
54
+ i += 2;
55
+ }
56
+ else if (i + 2 >= source.length && atSegmentStart(i)) {
57
+ // trailing `**`: everything below, again without hidden segments
58
+ out += `(?:${NO_DOT}[^/]+/)*(?:${NO_DOT}[^/]*)?`;
59
+ i += 1;
60
+ }
61
+ else {
62
+ out += ".*";
63
+ i += 1;
64
+ }
65
+ }
66
+ else {
67
+ out += `${atSegmentStart(i) ? NO_DOT : ""}[^/]*`;
68
+ }
69
+ }
70
+ else if (ch === "?") {
71
+ out += `${atSegmentStart(i) ? NO_DOT : ""}[^/]`;
72
+ }
73
+ else if (ch === "{") {
74
+ braceDepth++;
75
+ out += "(?:";
76
+ }
77
+ else if (ch === "}" && braceDepth > 0) {
78
+ braceDepth--;
79
+ out += ")";
80
+ }
81
+ else if (ch === "," && braceDepth > 0) {
82
+ out += "|";
83
+ }
84
+ else if (ch === "[") {
85
+ const end = source.indexOf("]", i + 1);
86
+ if (end === -1) {
87
+ out += "\\[";
88
+ }
89
+ else {
90
+ out += source.slice(i, end + 1);
91
+ i = end;
92
+ }
93
+ }
94
+ else {
95
+ out += ch.replace(/[.+^$()|\\\]]/g, "\\$&");
96
+ }
97
+ }
98
+ return new RegExp(`^${out}$`);
99
+ }
100
+ function toPosix(file) {
101
+ return file.replace(/\\/g, "/");
102
+ }
103
+ /**
104
+ * Mirrors what the initial fast-glob scan accepts, so watch mode reacts to the
105
+ * same files as `sync`.
106
+ */
107
+ function createFileFilter(include, exclude, cwd, alwaysExcluded = []) {
108
+ const includeRes = include.filter((p) => !p.startsWith("!")).map(globToRegExp);
109
+ const negatedRes = include
110
+ .filter((p) => p.startsWith("!"))
111
+ .map((p) => globToRegExp(p.slice(1)));
112
+ const excludeRes = exclude
113
+ .map((p) => p.trim())
114
+ .filter(Boolean)
115
+ .map((p) => {
116
+ const globbed = p.includes("/") || GLOB_CHARS.test(p) ? p : `**/${p}`;
117
+ return globToRegExp(globbed.replace(/\/+$/, ""));
118
+ });
119
+ const relative = (absPath) => toPosix(path_1.default.relative(cwd, absPath));
120
+ const isExcluded = (absPath) => {
121
+ const rel = relative(absPath);
122
+ if (alwaysExcluded.includes(rel))
123
+ return true;
124
+ const segments = rel.split("/");
125
+ // test every leading prefix so a match on a directory excludes what is inside it
126
+ for (let n = 1; n <= segments.length; n++) {
127
+ const prefix = segments.slice(0, n).join("/");
128
+ if (excludeRes.some((re) => re.test(prefix)))
129
+ return true;
130
+ }
131
+ return false;
132
+ };
133
+ return {
134
+ isExcluded,
135
+ isIncluded(absPath) {
136
+ const rel = relative(absPath);
137
+ if (rel === ".." || rel.startsWith("../") || path_1.default.isAbsolute(rel))
138
+ return false;
139
+ if (isExcluded(absPath))
140
+ return false;
141
+ if (negatedRes.some((re) => re.test(rel)))
142
+ return false;
143
+ return includeRes.some((re) => re.test(rel));
144
+ },
145
+ };
146
+ }
@@ -3,16 +3,30 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildConfigFromScan = buildConfigFromScan;
4
4
  exports.persistScanResults = persistScanResults;
5
5
  const config_1 = require("../config/config");
6
- function buildConfigFromScan(config, engine) {
7
- const functionWrappers = engine
8
- .getAllFunctionWrappers()
9
- .map((name) => ({ name }));
6
+ const uniqueSorted = (values) => [...new Set(values)].sort();
7
+ function buildConfigFromScan(config, engine, options = {}) {
8
+ const foundWrappers = engine.getAllFunctionWrappers();
9
+ const foundEvents = engine.getAllEvents();
10
+ if (options.preserveKnown) {
11
+ return {
12
+ ...config,
13
+ events: uniqueSorted([...(config.events ?? []), ...foundEvents]),
14
+ functionWrappers: uniqueSorted([
15
+ ...(config.functionWrappers ?? []).map((w) => w.name),
16
+ ...foundWrappers,
17
+ ]).map((name) => ({ name })),
18
+ };
19
+ }
20
+ const functionWrappers = foundWrappers.map((name) => ({ name }));
10
21
  return {
11
22
  ...config,
12
- events: engine.getAllEvents(),
23
+ events: foundEvents,
13
24
  functionWrappers,
14
25
  };
15
26
  }
16
- async function persistScanResults(config, engine) {
17
- await (0, config_1.saveConfig)(buildConfigFromScan(config, engine));
27
+ async function persistScanResults(config, engine, options = {}) {
28
+ const next = buildConfigFromScan(config, engine, options);
29
+ await (0, config_1.saveConfig)(next);
30
+ // what is now in eventra.json: callers report these counts
31
+ return (0, config_1.normalizeConfig)(next);
18
32
  }