@eventra_dev/eventra-cli 2.1.0 → 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.
package/dist/cli/watch.js CHANGED
@@ -14,141 +14,242 @@ const eventraSdk_1 = require("../analysis/sdk/eventraSdk");
14
14
  const EventraEngine_1 = require("../core/EventraEngine");
15
15
  const scanResults_1 = require("../core/scanResults");
16
16
  const loadPlugins_1 = require("../plugin/loadPlugins");
17
- const SDK_TYPES_FILE = "__eventra_sdk_types__.d.ts";
18
- const processFile_1 = require("../filesystem/processFile");
17
+ const scanFilters_1 = require("../core/scanFilters");
18
+ const skipped_1 = require("../core/skipped");
19
+ const fileLimits_1 = require("../core/fileLimits");
19
20
  const getVirtualFile_1 = require("../filesystem/getVirtualFile");
21
+ const SDK_TYPES_FILE = "__eventra_sdk_types__.d.ts";
22
+ // Files outside `include` can still change what a scanned file means when
23
+ // something imports them (a shared constants module, a wrapper in a helper).
24
+ // Only code-like files are worth rebuilding the program for.
25
+ const CODE_FILE = /\.(?:[cm]?[jt]s|[jt]sx|json)$/;
26
+ const isNotFound = (err) => err?.code === "ENOENT";
20
27
  async function watch() {
21
- const config = await (0, config_1.loadConfig)();
22
- if (!config) {
23
- console.log(chalk_1.default.red("eventra.json not found"));
24
- return;
25
- }
28
+ const config = await (0, config_1.requireConfig)();
29
+ if (!config)
30
+ return undefined;
26
31
  console.log(chalk_1.default.blue("Watching...\n"));
27
32
  const plugins = await (0, loadPlugins_1.createPluginRegistry)(config);
28
33
  const include = [...config.sync.include, ...plugins.getIncludePatterns()];
29
34
  const engine = new EventraEngine_1.EventraEngine(process.cwd(), plugins);
30
- const fileDeps = new Map();
31
- const cache = new Map();
32
35
  const watchedDeps = new Set();
33
36
  const trackedFiles = new Set();
34
37
  const sourceFiles = new Set();
38
+ // files whose last analysis failed; while there are any, saved results keep
39
+ // the previously known events (see buildConfigFromScan)
40
+ const skippedNow = new Map();
35
41
  const normalize = (file) => {
36
42
  return path_1.default.resolve(file).replace(/\\/g, "/");
37
43
  };
38
- // chokidar's `ignored` receives absolute, normalized paths — reject any path
39
- // with an excluded directory name as one of its segments (matches how
40
- // `fast-glob`'s own `ignore` option is applied during the initial scan).
41
- const excludedDirs = new Set(config.sync.exclude);
42
- const isExcludedPath = (file) => {
43
- const segments = normalize(file).split("/");
44
- return segments.some((segment) => excludedDirs.has(segment));
44
+ // The same rules as the initial scan (and as `sync`): plain exclude names match
45
+ // at any depth, and a changed file is only handled when it matches `include`.
46
+ // The config files are written by this process, so they must never trigger a run.
47
+ const filter = (0, scanFilters_1.createFileFilter)(include, config.sync.exclude, process.cwd(), [
48
+ config_1.CONFIG_NAME,
49
+ config_1.LOCAL_CONFIG_NAME,
50
+ ]);
51
+ const isExcludedPath = (file) => filter.isExcluded(normalize(file));
52
+ const isSource = (abs) => sourceFiles.has(abs) || trackedFiles.has(abs) || filter.isIncluded(abs);
53
+ // an imported (or newly importable) file outside `include`
54
+ const isDependencyCandidate = (abs) => CODE_FILE.test(abs) && !isSource(abs);
55
+ const shouldHandle = (abs) => !filter.isExcluded(abs) && (isSource(abs) || isDependencyCandidate(abs));
56
+ // what eventra.json holds now: "keep the known events" while a file is broken
57
+ // must mean the events this process saved last, not the ones at startup
58
+ let saved = config;
59
+ const persist = async () => {
60
+ saved = await (0, scanResults_1.persistScanResults)(saved, engine, { preserveKnown: skippedNow.size > 0 });
45
61
  };
62
+ const signature = () => JSON.stringify([engine.getAllEvents(), engine.getAllFunctionWrappers()]);
46
63
  const files = await (0, fast_glob_1.default)(include, {
47
- ignore: config.sync.exclude,
64
+ ignore: (0, scanFilters_1.expandExcludePatterns)(config.sync.exclude),
48
65
  absolute: true,
49
66
  });
67
+ const ignoredInitial = [];
50
68
  engine.beginPreload();
51
69
  await engine.preloadFile(SDK_TYPES_FILE, eventraSdk_1.EVENTRA_SDK_SHIM);
52
70
  for (const file of files) {
53
71
  try {
54
72
  const abs = normalize(file);
55
- sourceFiles.add(abs);
56
73
  const raw = await promises_1.default.readFile(abs, "utf-8");
74
+ // the same limit as `sync`: too big to analyse, but not a failure
75
+ if ((0, fileLimits_1.isOversized)(raw)) {
76
+ ignoredInitial.push({ file: abs, reason: fileLimits_1.OVERSIZED_REASON });
77
+ continue;
78
+ }
79
+ sourceFiles.add(abs);
57
80
  const virtualFiles = await plugins.preprocessFile({ fileName: abs, content: raw });
58
81
  for (const virtualFile of virtualFiles) {
59
82
  const scanPath = normalize(virtualFile.fileName);
60
- const parsed = await (0, processFile_1.processFile)(scanPath, virtualFile.content);
61
- cache.set(scanPath, parsed);
62
83
  trackedFiles.add(scanPath);
63
84
  await engine.preloadFile((0, getVirtualFile_1.getVirtualFile)(scanPath), virtualFile.content);
64
- fileDeps.set(scanPath, new Set(parsed.dependencies
65
- .filter((dep) => dep.startsWith(".") || dep.startsWith("/"))
66
- .map((dep) => normalize(path_1.default.resolve(path_1.default.dirname(scanPath), dep)))));
67
85
  }
68
86
  }
69
- catch {
70
- // ignore invalid files
87
+ catch (err) {
88
+ const skip = (0, skipped_1.skippedFrom)(file, err);
89
+ skippedNow.set(normalize(file), skip);
71
90
  }
72
91
  }
73
92
  engine.endPreload();
93
+ (0, skipped_1.reportSkipped)([...skippedNow.values()]);
94
+ (0, skipped_1.reportIgnored)(ignoredInitial);
74
95
  await engine.runFullAnalysis([SDK_TYPES_FILE, ...trackedFiles].map((f) => (0, getVirtualFile_1.getVirtualFile)(f)), config);
75
- await (0, scanResults_1.persistScanResults)(config, engine);
96
+ await persist();
76
97
  const initialEvents = engine.getAllEvents();
77
98
  const initialWrappers = engine.getAllFunctionWrappers();
78
- console.log(chalk_1.default.gray(`Initial: ${initialEvents.length} events, ${initialWrappers.length} wrappers\n`));
99
+ console.log(chalk_1.default.gray(`Initial: ${(0, skipped_1.plural)(initialEvents.length, "event")}, ${(0, skipped_1.plural)(initialWrappers.length, "wrapper")}\n`));
79
100
  // Watch the project root (not just the files found by the initial scan) so
80
- // that brand-new files matching `include` are noticed as they're created —
101
+ // that brand-new files matching `include` are noticed as they're created -
81
102
  // a fixed file list only ever detects edits to files chokidar already knows
82
- // about. Dependency files outside the root (added below via `watcher.add`)
83
- // are unaffected by this and keep working the same way.
103
+ // about. Project files that live outside the root are added below.
84
104
  const watcher = chokidar_1.default.watch(process.cwd(), {
85
105
  ignoreInitial: true,
86
106
  ignored: isExcludedPath,
87
107
  });
108
+ const root = normalize(process.cwd());
109
+ const isOutsideRoot = (abs) => abs !== root && !abs.startsWith(`${root}/`);
110
+ // Imported files outside the project root are not covered by the root
111
+ // watcher: follow exactly the ones the program really resolved.
112
+ const syncOutsideDeps = () => {
113
+ const outside = new Set(engine
114
+ .getProjectFileNames()
115
+ .map(normalize)
116
+ .filter((file) => isOutsideRoot(file) && !filter.isExcluded(file) && CODE_FILE.test(file)));
117
+ for (const dep of outside) {
118
+ if (watchedDeps.has(dep))
119
+ continue;
120
+ watcher.add(dep);
121
+ watchedDeps.add(dep);
122
+ }
123
+ for (const dep of [...watchedDeps]) {
124
+ if (outside.has(dep))
125
+ continue;
126
+ watcher.unwatch(dep);
127
+ watchedDeps.delete(dep);
128
+ }
129
+ };
130
+ syncOutsideDeps();
88
131
  const queue = new Map();
89
132
  let timer = null;
133
+ // a source file is gone (deleted, or no longer analysable such as one that grew past the size limit)
134
+ const dropSource = async (abs) => {
135
+ sourceFiles.delete(abs);
136
+ skippedNow.delete(abs);
137
+ for (const virtualPath of plugins.getVirtualPathsForSource(abs)) {
138
+ trackedFiles.delete(virtualPath);
139
+ await engine.removeFile((0, getVirtualFile_1.getVirtualFile)(virtualPath), config);
140
+ }
141
+ };
90
142
  const run = async () => {
91
143
  const batch = [...queue.keys()];
92
144
  queue.clear();
145
+ if (batch.length === 0)
146
+ return;
147
+ const before = signature();
148
+ let touchedSource = false;
149
+ const skippedThisRun = [];
150
+ const ignoredThisRun = [];
151
+ // imported files outside `include` that changed, appeared or vanished: one rebuild for all of them
152
+ const external = batch.filter((file) => !isSource(file));
153
+ if (external.length > 0) {
154
+ try {
155
+ await engine.refreshExternalFiles(external, config);
156
+ }
157
+ catch (err) {
158
+ console.error(chalk_1.default.red(`watch error: ${(0, skipped_1.errorMessage)(err)}`));
159
+ }
160
+ }
93
161
  for (const file of batch) {
162
+ if (!isSource(file))
163
+ continue;
164
+ touchedSource = true;
94
165
  try {
95
166
  const raw = await promises_1.default.readFile(file, "utf-8");
167
+ if ((0, fileLimits_1.isOversized)(raw)) {
168
+ // too big to analyse, like `sync`: not a failure, and its events are not kept
169
+ await dropSource(file);
170
+ ignoredThisRun.push({ file, reason: fileLimits_1.OVERSIZED_REASON });
171
+ continue;
172
+ }
96
173
  const virtualFiles = await plugins.preprocessFile({ fileName: file, content: raw });
174
+ sourceFiles.add(file);
175
+ skippedNow.delete(file);
97
176
  for (const virtualFile of virtualFiles) {
98
177
  const scanPath = normalize(virtualFile.fileName);
99
- const parsed = await (0, processFile_1.processFile)(scanPath, virtualFile.content);
100
- cache.set(scanPath, parsed);
101
178
  trackedFiles.add(scanPath);
102
179
  await engine.updateFile((0, getVirtualFile_1.getVirtualFile)(scanPath), virtualFile.content, config);
103
- const prevDeps = fileDeps.get(scanPath) ?? new Set();
104
- const nextDeps = new Set(parsed.dependencies
105
- .filter((dep) => dep.startsWith(".") || dep.startsWith("/"))
106
- .map((dep) => normalize(path_1.default.resolve(path_1.default.dirname(scanPath), dep))));
107
- for (const dep of nextDeps) {
108
- if (prevDeps.has(dep))
109
- continue;
110
- watcher.add(dep);
111
- watchedDeps.add(dep);
112
- }
113
- for (const dep of prevDeps) {
114
- if (nextDeps.has(dep))
115
- continue;
116
- watcher.unwatch(dep);
117
- watchedDeps.delete(dep);
118
- }
119
- fileDeps.set(scanPath, nextDeps);
120
180
  }
121
181
  }
122
- catch {
123
- console.log(chalk_1.default.gray(`skip: ${file}`));
182
+ catch (err) {
183
+ if (isNotFound(err)) {
184
+ // gone before it could be read (created and deleted within one debounce, or removed mid-run)
185
+ await dropSource(file);
186
+ continue;
187
+ }
188
+ const skip = (0, skipped_1.skippedFrom)(file, err);
189
+ skippedNow.set(file, skip);
190
+ skippedThisRun.push(skip);
124
191
  }
125
192
  }
126
- await (0, scanResults_1.persistScanResults)(config, engine);
127
- console.log(chalk_1.default.green(`Updated: ${engine.getAllEvents().length} events, ${engine.getAllFunctionWrappers().length} wrappers`));
193
+ (0, skipped_1.reportSkipped)(skippedThisRun);
194
+ (0, skipped_1.reportIgnored)(ignoredThisRun);
195
+ syncOutsideDeps();
196
+ // a batch of imported-only files that changed nothing about the result is not worth a write or a log line
197
+ if (!touchedSource && signature() === before)
198
+ return;
199
+ await persist();
200
+ console.log(chalk_1.default.green(`Updated: ${(0, skipped_1.plural)(engine.getAllEvents().length, "event")}, ${(0, skipped_1.plural)(engine.getAllFunctionWrappers().length, "wrapper")}`));
201
+ };
202
+ // One run at a time: a timer firing while the previous run is still awaiting
203
+ // would otherwise interleave two runs over the same engine.
204
+ let chain = Promise.resolve();
205
+ const enqueue = (task) => {
206
+ chain = chain.then(task).catch((err) => {
207
+ console.error(chalk_1.default.red(`watch error: ${(0, skipped_1.errorMessage)(err)}`));
208
+ });
128
209
  };
129
210
  const schedule = (file) => {
130
211
  const abs = normalize(file);
212
+ if (!shouldHandle(abs))
213
+ return;
131
214
  queue.set(abs, true);
132
215
  if (timer)
133
216
  clearTimeout(timer);
134
- timer = setTimeout(run, 200);
217
+ timer = setTimeout(() => enqueue(run), 200);
135
218
  };
136
219
  watcher
137
220
  .on("change", schedule)
138
221
  .on("add", schedule)
139
- .on("unlink", async (file) => {
222
+ .on("unlink", (file) => {
140
223
  const abs = normalize(file);
141
- sourceFiles.delete(abs);
142
- const virtualPaths = plugins.getVirtualPathsForSource(abs);
143
- for (const virtualPath of virtualPaths) {
144
- trackedFiles.delete(virtualPath);
145
- await engine.removeFile((0, getVirtualFile_1.getVirtualFile)(virtualPath), config);
224
+ if (filter.isExcluded(abs))
225
+ return;
226
+ // a file that failed to analyse is in no set but is still remembered as skipped
227
+ if (!sourceFiles.has(abs) && !trackedFiles.has(abs) && !skippedNow.has(abs)) {
228
+ // an imported-only file vanished: dependents resolve differently now
229
+ if (CODE_FILE.test(abs) && engine.hasProgramFile(abs)) {
230
+ queue.set(abs, true);
231
+ if (timer)
232
+ clearTimeout(timer);
233
+ timer = setTimeout(() => enqueue(run), 200);
234
+ }
235
+ return;
146
236
  }
147
- await (0, scanResults_1.persistScanResults)(config, engine);
148
- console.log(chalk_1.default.red(`Removed → ${engine.getAllEvents().length} events, ${engine.getAllFunctionWrappers().length} wrappers`));
237
+ enqueue(async () => {
238
+ await dropSource(abs);
239
+ await persist();
240
+ console.log(chalk_1.default.red(`Removed -> ${(0, skipped_1.plural)(engine.getAllEvents().length, "event")}, ${(0, skipped_1.plural)(engine.getAllFunctionWrappers().length, "wrapper")}`));
241
+ });
149
242
  });
150
243
  process.on("SIGINT", async () => {
151
244
  await watcher.close();
152
245
  process.exit(0);
153
246
  });
247
+ return {
248
+ close: async () => {
249
+ if (timer)
250
+ clearTimeout(timer);
251
+ await watcher.close();
252
+ await chain;
253
+ },
254
+ };
154
255
  }
@@ -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;
@@ -42,7 +44,9 @@ function normalizeConfig(config) {
42
44
  apiKey: config.apiKey ?? "",
43
45
  endpoint: config.endpoint ?? "",
44
46
  events: [...(config.events ?? [])].sort(),
45
- 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)),
46
50
  plugins: [...(config.plugins ?? [])],
47
51
  ...(sinks.length > 0 ? { sinks } : {}),
48
52
  sync: {
@@ -64,9 +68,48 @@ async function loadConfig() {
64
68
  return null;
65
69
  }
66
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). */
67
102
  async function saveConfig(config) {
68
103
  const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
69
- 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);
70
113
  }
71
114
  async function readLocalSecrets() {
72
115
  const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);