@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.
package/dist/cli/init.js CHANGED
@@ -5,23 +5,78 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.init = init;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
9
  const inquirer_1 = __importDefault(require("inquirer"));
10
+ const path_1 = __importDefault(require("path"));
9
11
  const config_1 = require("../config/config");
10
- async function init() {
11
- console.log(chalk_1.default.blue("Initializing Eventra..."));
12
- const { apiKey, endpoint, } = await inquirer_1.default.prompt([
13
- {
14
- type: "input",
15
- name: "apiKey",
16
- message: "API Key (optional):",
17
- },
12
+ const sinkPresets_1 = require("../plugin/builtins/sinkPresets");
13
+ const projectDependencies_1 = require("./projectDependencies");
14
+ /**
15
+ * Looks for analytics libraries in package.json (and workspace members) and, if there are any,
16
+ * asks which of them Eventra should read events from. Everything detected is pre-selected.
17
+ */
18
+ async function chooseSinks(yes) {
19
+ const detected = (0, sinkPresets_1.detectSinkPresets)(await (0, projectDependencies_1.collectDependencyNames)(process.cwd()));
20
+ if (detected.length === 0)
21
+ return [];
22
+ console.log(chalk_1.default.gray(`Found analytics in package.json: ${detected.map((d) => d.packages.join(", ")).join("; ")}`));
23
+ if (yes)
24
+ return detected.map((d) => ({ preset: d.preset }));
25
+ const { presets } = await inquirer_1.default.prompt([
18
26
  {
19
- type: "input",
20
- name: "endpoint",
21
- message: "Custom endpoint (optional):",
22
- default: "https://api.eventra.dev/api/v1/cli/events",
27
+ type: "checkbox",
28
+ name: "presets",
29
+ message: "Also read events from these analytics calls (sinks):",
30
+ choices: detected.map((d) => ({
31
+ name: `${d.preset} (${d.packages.join(", ")})`,
32
+ value: d.preset,
33
+ checked: true,
34
+ })),
23
35
  },
24
36
  ]);
37
+ return presets.map((preset) => ({ preset }));
38
+ }
39
+ const DEFAULT_ENDPOINT = "https://api.eventra.dev/api/v1/cli/events";
40
+ /**
41
+ * `yes` is the non-interactive mode (CI, scripts): defaults for every answer,
42
+ * and an existing eventra.json is never overwritten.
43
+ */
44
+ async function init({ yes = false } = {}) {
45
+ if (await fs_extra_1.default.pathExists(path_1.default.join(process.cwd(), config_1.CONFIG_NAME))) {
46
+ if (yes) {
47
+ console.log(chalk_1.default.yellow(`${config_1.CONFIG_NAME} already exists, keeping it`));
48
+ return;
49
+ }
50
+ const { overwrite } = await inquirer_1.default.prompt([
51
+ {
52
+ type: "confirm",
53
+ name: "overwrite",
54
+ message: `${config_1.CONFIG_NAME} already exists. Overwrite it? Its events, wrappers, plugins and sinks will be lost.`,
55
+ default: false,
56
+ },
57
+ ]);
58
+ if (!overwrite) {
59
+ console.log(chalk_1.default.gray(`Kept the existing ${config_1.CONFIG_NAME}`));
60
+ return;
61
+ }
62
+ }
63
+ console.log(chalk_1.default.blue("Initializing Eventra..."));
64
+ const { apiKey, endpoint, } = yes
65
+ ? { apiKey: "", endpoint: DEFAULT_ENDPOINT }
66
+ : await inquirer_1.default.prompt([
67
+ {
68
+ type: "input",
69
+ name: "apiKey",
70
+ message: "API Key (optional):",
71
+ },
72
+ {
73
+ type: "input",
74
+ name: "endpoint",
75
+ message: "Custom endpoint (optional):",
76
+ default: DEFAULT_ENDPOINT,
77
+ },
78
+ ]);
79
+ const sinks = await chooseSinks(yes);
25
80
  console.log("");
26
81
  console.log(chalk_1.default.gray("Eventra detects @eventra_dev/eventra-sdk usage:"));
27
82
  console.log(chalk_1.default.gray('• tracker.track("event.name")'));
@@ -33,6 +88,7 @@ async function init() {
33
88
  endpoint,
34
89
  events: [],
35
90
  functionWrappers: [],
91
+ ...(sinks.length > 0 ? { sinks } : {}),
36
92
  sync: {
37
93
  include: [
38
94
  "**/*.{ts,tsx,js,jsx}",
@@ -47,6 +103,9 @@ async function init() {
47
103
  };
48
104
  await (0, config_1.saveConfig)(config);
49
105
  console.log(chalk_1.default.green("eventra.json created"));
106
+ if (sinks.length > 0) {
107
+ console.log(chalk_1.default.green(`sinks: ${sinks.map((s) => s.preset).join(", ")}`));
108
+ }
50
109
  // Never write the key into eventra.json — that file is meant to be
51
110
  // committed (CI diffs it via `eventra check`). Keep secrets out of git.
52
111
  if (apiKey) {
@@ -0,0 +1,119 @@
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.collectDependencyNames = collectDependencyNames;
7
+ const fast_glob_1 = __importDefault(require("fast-glob"));
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
11
+ /** Upper bound on workspace members read, so a huge monorepo cannot make `init` crawl. */
12
+ const MAX_WORKSPACE_PACKAGES = 300;
13
+ /**
14
+ * Parsed package.json, or null when it is missing or not JSON. Whatever else the file holds (an
15
+ * array, a number) is returned as is: every reader below only looks up named properties on it, which
16
+ * is harmless on any JSON value, so there is no point in a second validation here.
17
+ */
18
+ async function readPackage(file) {
19
+ try {
20
+ return (await fs_extra_1.default.readJSON(file));
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ function dependencyNames(pkg) {
27
+ return DEPENDENCY_FIELDS.flatMap((field) => {
28
+ const deps = pkg[field];
29
+ return typeof deps === "object" && deps !== null && !Array.isArray(deps) ? Object.keys(deps) : [];
30
+ });
31
+ }
32
+ function stringList(value) {
33
+ // Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
34
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
35
+ }
36
+ /** `packages:` entries of a pnpm-workspace.yaml (only the plain list form, which is all pnpm writes). */
37
+ function pnpmWorkspacePatterns(yaml) {
38
+ // Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
39
+ const patterns = [];
40
+ let inPackages = false;
41
+ for (const line of yaml.split(/\r?\n/)) {
42
+ if (/^packages:\s*(#.*)?$/.test(line)) {
43
+ inPackages = true;
44
+ continue;
45
+ }
46
+ if (!inPackages)
47
+ continue;
48
+ // YAML lets the dashes sit at the key's own indent (`packages:` / `- 'apps/*'`), so no indent is required.
49
+ const item = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*(#.*)?$/.exec(line);
50
+ if (item) {
51
+ patterns.push(item[1]);
52
+ }
53
+ else if (/^\S/.test(line)) {
54
+ // The next top-level key ends the list.
55
+ inPackages = false;
56
+ }
57
+ }
58
+ return patterns;
59
+ }
60
+ /** A workspace pattern may only point inside the project: no absolute paths, no `..`. */
61
+ function staysInsideProject(pattern) {
62
+ // Stryker disable next-line StringLiteral: a negated pattern outside the project excludes nothing, so what replaces the "!" cannot show
63
+ const target = pattern.replace(/^!/, "");
64
+ return !path_1.default.isAbsolute(target) && !target.split(/[\\/]/).includes("..");
65
+ }
66
+ /** pnpm-workspace.yaml patterns; an unreadable file (a directory, no permission) counts as having none. */
67
+ async function readPnpmPatterns(file) {
68
+ try {
69
+ return pnpmWorkspacePatterns(await fs_extra_1.default.readFile(file, "utf8"));
70
+ }
71
+ catch {
72
+ // Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
73
+ return [];
74
+ }
75
+ }
76
+ async function workspacePatterns(rootDir, rootPackage) {
77
+ const workspaces = rootPackage.workspaces;
78
+ const fromPackageJson = Array.isArray(workspaces)
79
+ ? stringList(workspaces)
80
+ : stringList(workspaces?.packages);
81
+ const fromPnpm = await readPnpmPatterns(path_1.default.join(rootDir, "pnpm-workspace.yaml"));
82
+ return [...fromPackageJson, ...fromPnpm].filter(staysInsideProject);
83
+ }
84
+ /**
85
+ * `apps/*` -> every member's package.json. A negated pattern (`!apps/skipped`) becomes
86
+ * `!apps/skipped/package.json`, which fast-glob treats as an exclusion of exactly those files.
87
+ */
88
+ const memberGlob = (pattern) => `${pattern}/package.json`;
89
+ /** Member package.json paths for the patterns; a pattern fast-glob rejects (a NUL byte, say) yields none. */
90
+ async function findMembers(rootDir, patterns) {
91
+ try {
92
+ return await (0, fast_glob_1.default)(patterns.map(memberGlob), { cwd: rootDir, ignore: ["**/node_modules/**"] });
93
+ }
94
+ catch {
95
+ // Stryker disable next-line ArrayDeclaration: a stray path that is not a package.json is read as "no package.json"
96
+ return [];
97
+ }
98
+ }
99
+ /**
100
+ * Every dependency name the project declares: the root package.json plus, in a monorepo
101
+ * (`workspaces` or pnpm-workspace.yaml), each workspace member's package.json.
102
+ */
103
+ async function collectDependencyNames(rootDir) {
104
+ const names = new Set();
105
+ const rootPackage = await readPackage(path_1.default.join(rootDir, "package.json"));
106
+ if (rootPackage === null)
107
+ return names;
108
+ for (const name of dependencyNames(rootPackage))
109
+ names.add(name);
110
+ const patterns = await workspacePatterns(rootDir, rootPackage);
111
+ const members = (await findMembers(rootDir, patterns)).slice(0, MAX_WORKSPACE_PACKAGES);
112
+ for (const member of members) {
113
+ const memberPackage = await readPackage(path_1.default.join(rootDir, member));
114
+ if (memberPackage !== null)
115
+ for (const name of dependencyNames(memberPackage))
116
+ names.add(name);
117
+ }
118
+ return names;
119
+ }
package/dist/cli/send.js CHANGED
@@ -7,6 +7,7 @@ exports.send = send;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
8
  const inquirer_1 = __importDefault(require("inquirer"));
9
9
  const config_1 = require("../config/config");
10
+ const catalogMeta_1 = require("../core/catalogMeta");
10
11
  const package_json_1 = __importDefault(require("../../package.json"));
11
12
  const CLI_VERSION = package_json_1.default.version;
12
13
  // Always trusted, no local approval needed — matches `eventra init`'s prompt default.
@@ -64,6 +65,10 @@ async function attemptDelivery(endpoint, apiKey, payload) {
64
65
  clearTimeout(timeout);
65
66
  }
66
67
  }
68
+ // the API answers a rejected unknown property with {"error":{"code":"VALIDATION_ERROR",...}}
69
+ function isValidationError(body) {
70
+ return typeof body === "string" && body.includes("VALIDATION_ERROR");
71
+ }
67
72
  async function send(opts = {}) {
68
73
  const config = await (0, config_1.loadConfig)();
69
74
  if (!config) {
@@ -134,24 +139,48 @@ async function send(opts = {}) {
134
139
  }
135
140
  console.log("");
136
141
  console.log(chalk_1.default.blue(`Sending ${config.events.length} events...`));
137
- const payload = JSON.stringify({
142
+ // Tells the server whether this list is a full snapshot of the code (so names
143
+ // missing from it can be marked as removed) and how many event names could
144
+ // not be resolved. Older servers ignore the extra fields.
145
+ const meta = await (0, catalogMeta_1.collectCatalogMeta)(config);
146
+ if (!meta.complete) {
147
+ console.log(chalk_1.default.gray("Catalog snapshot marked incomplete (files were skipped, the scan failed, or eventra.json is out of date: run 'eventra sync')"));
148
+ }
149
+ const legacyBody = {
138
150
  events: config.events,
139
151
  cli: {
140
152
  name: "@eventra_dev/eventra-cli",
141
153
  version: CLI_VERSION,
142
154
  runtime: "node",
143
155
  },
156
+ };
157
+ const payload = JSON.stringify({
158
+ ...legacyBody,
159
+ complete: meta.complete,
160
+ cliVersion: CLI_VERSION,
161
+ ...(meta.dynamicCount !== undefined ? { dynamicCount: meta.dynamicCount } : {}),
162
+ ...(meta.branch ? { branch: meta.branch } : {}),
144
163
  });
164
+ // A server that predates the catalog fields rejects unknown properties with a
165
+ // generic validation 400; the request is then repeated once without them.
166
+ const legacyPayload = JSON.stringify(legacyBody);
167
+ let body = payload;
145
168
  // attempt delivery with retry on 429/5xx/network errors
146
169
  let data;
147
170
  let lastFailure = null;
148
171
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
149
172
  try {
150
- const outcome = await attemptDelivery(endpoint, apiKey, payload);
173
+ const outcome = await attemptDelivery(endpoint, apiKey, body);
151
174
  if (outcome.ok) {
152
175
  data = outcome.data;
153
176
  break;
154
177
  }
178
+ if (outcome.status === 400 && body !== legacyPayload && isValidationError(outcome.body)) {
179
+ body = legacyPayload;
180
+ console.log(chalk_1.default.gray("Server does not know the catalog fields yet, sending the plain event list"));
181
+ attempt--;
182
+ continue;
183
+ }
155
184
  if (!outcome.retryable) {
156
185
  // permanent failure — surface immediately
157
186
  console.log(chalk_1.default.red(`Request failed (${outcome.status})`));
package/dist/cli/sync.js CHANGED
@@ -8,17 +8,20 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const config_1 = require("../config/config");
9
9
  const projectScanner_1 = require("../core/projectScanner");
10
10
  const scanResults_1 = require("../core/scanResults");
11
- async function sync() {
12
- const config = await (0, config_1.loadConfig)();
13
- if (!config) {
14
- console.log(chalk_1.default.red("eventra.json not found"));
11
+ const skipped_1 = require("../core/skipped");
12
+ async function sync({ strict = false } = {}) {
13
+ const config = await (0, config_1.requireConfig)();
14
+ if (!config)
15
15
  return;
16
- }
17
16
  console.log(chalk_1.default.blue("Scanning..."));
18
- const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
19
- await (0, scanResults_1.persistScanResults)(config, engine);
20
- const events = engine.getAllEvents();
21
- const wrappers = engine.getAllFunctionWrappers();
22
- console.log(chalk_1.default.green(`Found ${events.length} events, ${wrappers.length} function wrappers`));
17
+ const { engine, plugins, skipped, ignored } = await (0, projectScanner_1.scanProject)(config);
18
+ // report what was saved: with skipped files that is the union of the known and
19
+ // the found events, not just what this scan found
20
+ const saved = await (0, scanResults_1.persistScanResults)(config, engine, { preserveKnown: skipped.length > 0 });
21
+ console.log(chalk_1.default.green(`Found ${(0, skipped_1.plural)(saved.events?.length ?? 0, "event")}, ${(0, skipped_1.plural)(saved.functionWrappers?.length ?? 0, "function wrapper")}`));
23
22
  await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
23
+ (0, skipped_1.reportSkipped)(skipped);
24
+ (0, skipped_1.reportIgnored)(ignored);
25
+ if (strict && skipped.length > 0)
26
+ process.exitCode = 1;
24
27
  }
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
  }