@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.
@@ -0,0 +1,77 @@
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.cleanSkipReason = cleanSkipReason;
7
+ exports.errorMessage = errorMessage;
8
+ exports.skippedFrom = skippedFrom;
9
+ exports.formatSkipped = formatSkipped;
10
+ exports.reportSkipped = reportSkipped;
11
+ exports.formatIgnored = formatIgnored;
12
+ exports.reportIgnored = reportIgnored;
13
+ exports.plural = plural;
14
+ const chalk_1 = __importDefault(require("chalk"));
15
+ const path_1 = __importDefault(require("path"));
16
+ /**
17
+ * A skipped file's error is wrapped once by the CLI, once by the plugin
18
+ * adapter and once by the plugin itself, each repeating the path. Keep only the
19
+ * innermost message.
20
+ */
21
+ function cleanSkipReason(file, message) {
22
+ let text = message.split(file).join("").trim();
23
+ text = text.replace(/^Plugin "[^"]*" failed to transform\s*:\s*/, "");
24
+ text = text.replace(/^Failed to parse [A-Za-z][A-Za-z ]*?\s*:\s*/, "");
25
+ return text.replace(/\s+/g, " ").trim() || "unknown error";
26
+ }
27
+ function errorMessage(err) {
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
30
+ function skippedFrom(file, err) {
31
+ return { file, reason: cleanSkipReason(file, errorMessage(err)) };
32
+ }
33
+ function formatSkipped(skipped, cwd) {
34
+ if (skipped.length === 0)
35
+ return [];
36
+ const lines = [
37
+ `Skipped ${skipped.length} file${skipped.length === 1 ? "" : "s"} that could not be analysed:`,
38
+ ];
39
+ for (const { file, reason } of skipped) {
40
+ lines.push(` ${path_1.default.relative(cwd, file).replace(/\\/g, "/")}: ${reason}`);
41
+ }
42
+ return lines;
43
+ }
44
+ /** Prints one summary block (stderr) for everything skipped in a scan. */
45
+ function reportSkipped(skipped, cwd = process.cwd()) {
46
+ const lines = formatSkipped(skipped, cwd);
47
+ if (lines.length === 0)
48
+ return;
49
+ console.error(chalk_1.default.yellow(lines.join("\n")));
50
+ console.error(chalk_1.default.gray("Events from these files are not in this scan; previously known events were kept. Use --strict to fail on skipped files."));
51
+ }
52
+ /**
53
+ * Files that were deliberately not analysed (for example generated bundles
54
+ * over the size limit). Unlike skipped files they are not failures: their
55
+ * absence neither keeps known events nor makes a catalog snapshot incomplete.
56
+ */
57
+ function formatIgnored(ignored, cwd) {
58
+ if (ignored.length === 0)
59
+ return [];
60
+ const lines = [
61
+ `Ignored ${ignored.length} file${ignored.length === 1 ? "" : "s"} (not analysed):`,
62
+ ];
63
+ for (const { file, reason } of ignored) {
64
+ lines.push(` ${path_1.default.relative(cwd, file).replace(/\\/g, "/")}: ${reason}`);
65
+ }
66
+ return lines;
67
+ }
68
+ /** Prints one note (stderr) for everything ignored in a scan. */
69
+ function reportIgnored(ignored, cwd = process.cwd()) {
70
+ const lines = formatIgnored(ignored, cwd);
71
+ if (lines.length === 0)
72
+ return;
73
+ console.error(chalk_1.default.gray(lines.join("\n")));
74
+ }
75
+ function plural(count, singular, pluralForm = `${singular}s`) {
76
+ return `${count} ${count === 1 ? singular : pluralForm}`;
77
+ }
package/dist/index.js CHANGED
@@ -39,42 +39,53 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  const commander_1 = require("commander");
41
41
  const package_json_1 = __importDefault(require("../package.json"));
42
- const program = new commander_1.Command();
43
- program
44
- .name("eventra")
45
- .description("Eventra CLI")
46
- .version(package_json_1.default.version);
47
- program
48
- .command("init")
49
- .action(async () => {
50
- const { init } = await Promise.resolve().then(() => __importStar(require("./cli/init")));
51
- await init();
42
+ const respawn_1 = require("./respawn");
43
+ // a large repository can need more memory than Node's default: start again with enough
44
+ (0, respawn_1.respawnWithMoreHeap)().then((respawned) => {
45
+ if (!respawned)
46
+ run();
52
47
  });
53
- program
54
- .command("sync")
55
- .action(async () => {
56
- const { sync } = await Promise.resolve().then(() => __importStar(require("./cli/sync")));
57
- await sync();
58
- });
59
- program
60
- .command("check")
61
- .option("--fix", "auto fix issues")
62
- .action(async (opts) => {
63
- const { check } = await Promise.resolve().then(() => __importStar(require("./cli/check")));
64
- await check({ fix: opts.fix });
65
- });
66
- program
67
- .command("watch")
68
- .description("Watch files and detect events in real-time")
69
- .action(async () => {
70
- const { watch } = await Promise.resolve().then(() => __importStar(require("./cli/watch")));
71
- await watch();
72
- });
73
- program
74
- .command("send")
75
- .option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
76
- .action(async (opts) => {
77
- const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
78
- await send({ trustEndpoint: opts.trustEndpoint });
79
- });
80
- program.parse();
48
+ function run() {
49
+ const program = new commander_1.Command();
50
+ program
51
+ .name("eventra")
52
+ .description("Eventra CLI")
53
+ .version(package_json_1.default.version);
54
+ program
55
+ .command("init")
56
+ .option("-y, --yes", "do not prompt: accept defaults and keep an existing eventra.json")
57
+ .action(async (opts) => {
58
+ const { init } = await Promise.resolve().then(() => __importStar(require("./cli/init")));
59
+ await init({ yes: opts.yes });
60
+ });
61
+ program
62
+ .command("sync")
63
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
64
+ .action(async (opts) => {
65
+ const { sync } = await Promise.resolve().then(() => __importStar(require("./cli/sync")));
66
+ await sync({ strict: opts.strict });
67
+ });
68
+ program
69
+ .command("check")
70
+ .option("--fix", "auto fix issues")
71
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
72
+ .action(async (opts) => {
73
+ const { check } = await Promise.resolve().then(() => __importStar(require("./cli/check")));
74
+ await check({ fix: opts.fix, strict: opts.strict });
75
+ });
76
+ program
77
+ .command("watch")
78
+ .description("Watch files and detect events in real-time")
79
+ .action(async () => {
80
+ const { watch } = await Promise.resolve().then(() => __importStar(require("./cli/watch")));
81
+ await watch();
82
+ });
83
+ program
84
+ .command("send")
85
+ .option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
86
+ .action(async (opts) => {
87
+ const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
88
+ await send({ trustEndpoint: opts.trustEndpoint });
89
+ });
90
+ program.parse();
91
+ }
@@ -82,20 +82,6 @@ function moduleMatches(specifier, modules) {
82
82
  return modules.some((m) => specifier === m || specifier.startsWith(`${m}/`));
83
83
  }
84
84
  const MAX_ALIAS_HOPS = 6;
85
- /** Identifiers naming the types in a type annotation: `PostHog`, `PostHog | null`, `ph.PostHog`. */
86
- function typeReferenceRoots(type) {
87
- if (typescript_1.default.isParenthesizedTypeNode(type))
88
- return typeReferenceRoots(type.type);
89
- if (typescript_1.default.isUnionTypeNode(type) || typescript_1.default.isIntersectionTypeNode(type))
90
- return type.types.flatMap(typeReferenceRoots);
91
- if (typescript_1.default.isTypeReferenceNode(type)) {
92
- let name = type.typeName;
93
- while (typescript_1.default.isQualifiedName(name))
94
- name = name.left;
95
- return [name];
96
- }
97
- return [];
98
- }
99
85
  /**
100
86
  * The symbol an identifier refers to, keeping an import as the import binding itself. TS resolves an
101
87
  * import all the way to its target in some positions once something makes the module resolvable
@@ -109,6 +95,55 @@ function symbolAt(id, checker) {
109
95
  return symbol;
110
96
  return checker.getSymbolsInScope(id, typescript_1.default.SymbolFlags.Alias).find((alias) => alias.name === id.text) ?? symbol;
111
97
  }
98
+ /** `import("m")`: the specifier of the call decides, no installed package needed. */
99
+ function dynamicImportFrom(expression, modules) {
100
+ const specifier = typescript_1.default.isCallExpression(expression) && expression.expression.kind === typescript_1.default.SyntaxKind.ImportKeyword
101
+ ? expression.arguments[0]
102
+ : undefined;
103
+ return specifier !== undefined && typescript_1.default.isStringLiteralLike(specifier) && moduleMatches(specifier.text, modules);
104
+ }
105
+ const PROMISE_TYPES = new Set(["Promise", "PromiseLike"]);
106
+ /**
107
+ * True when a type annotation names something from `modules`: `PostHog`, `PostHog | null`, `ph.PostHog`,
108
+ * `typeof import("posthog-js")`, `Promise<typeof import("posthog-js")>`, `typeof posthog`.
109
+ */
110
+ function typeFromModules(type, checker, modules, hops) {
111
+ if (typescript_1.default.isParenthesizedTypeNode(type))
112
+ return typeFromModules(type.type, checker, modules, hops);
113
+ if (typescript_1.default.isUnionTypeNode(type) || typescript_1.default.isIntersectionTypeNode(type)) {
114
+ return type.types.some((member) => typeFromModules(member, checker, modules, hops));
115
+ }
116
+ if (typescript_1.default.isImportTypeNode(type)) {
117
+ return typescript_1.default.isLiteralTypeNode(type.argument) && typescript_1.default.isStringLiteral(type.argument.literal) && moduleMatches(type.argument.literal.text, modules);
118
+ }
119
+ const name = typescript_1.default.isTypeReferenceNode(type) ? type.typeName : typescript_1.default.isTypeQueryNode(type) ? type.exprName : undefined;
120
+ if (name === undefined)
121
+ return false;
122
+ let root = name;
123
+ while (typescript_1.default.isQualifiedName(root))
124
+ root = root.left;
125
+ return (symbolFromModules(symbolAt(root, checker), checker, modules, hops + 1) ||
126
+ // a promise of the module (`Promise<typeof import("posthog-js")>`), not any generic that mentions it
127
+ (typescript_1.default.isTypeReferenceNode(type) &&
128
+ typescript_1.default.isIdentifier(type.typeName) &&
129
+ PROMISE_TYPES.has(type.typeName.text) &&
130
+ (type.typeArguments ?? []).some((argument) => typeFromModules(argument, checker, modules, hops))));
131
+ }
132
+ /** `return expr` statements of a function body, not looking into functions nested in it. */
133
+ function returnedExpressions(body) {
134
+ if (!typescript_1.default.isBlock(body))
135
+ return [body];
136
+ const found = [];
137
+ const visit = (node) => {
138
+ if (typescript_1.default.isFunctionLike(node))
139
+ return;
140
+ if (typescript_1.default.isReturnStatement(node) && node.expression)
141
+ found.push(node.expression);
142
+ typescript_1.default.forEachChild(node, visit);
143
+ };
144
+ typescript_1.default.forEachChild(body, visit);
145
+ return found;
146
+ }
112
147
  /** The symbol `name` exported by the module a specifier points at, when TS can resolve that module. */
113
148
  function resolveExport(specifier, name, checker) {
114
149
  const moduleSymbol = checker.getSymbolAtLocation(specifier);
@@ -123,6 +158,8 @@ function expressionFromModules(expression, checker, modules, hops) {
123
158
  let expr = (0, utils_1.unwrapExpression)(expression);
124
159
  if (typescript_1.default.isAwaitExpression(expr))
125
160
  expr = (0, utils_1.unwrapExpression)(expr.expression);
161
+ if (dynamicImportFrom(expr, modules))
162
+ return true;
126
163
  if (typescript_1.default.isCallExpression(expr) || typescript_1.default.isNewExpression(expr))
127
164
  expr = (0, utils_1.unwrapExpression)(expr.expression);
128
165
  const fromName = (name) => typescript_1.default.isIdentifier(name) && symbolFromModules(symbolAt(name, checker), checker, modules, hops);
@@ -133,7 +170,10 @@ function expressionFromModules(expression, checker, modules, hops) {
133
170
  let root = expr;
134
171
  while (typescript_1.default.isPropertyAccessExpression(root))
135
172
  root = (0, utils_1.unwrapExpression)(root.expression);
136
- return fromName(expr.name) || fromName(root);
173
+ // `(await import("posthog-js")).default`
174
+ if (typescript_1.default.isAwaitExpression(root))
175
+ root = (0, utils_1.unwrapExpression)(root.expression);
176
+ return fromName(expr.name) || fromName(root) || dynamicImportFrom(root, modules);
137
177
  }
138
178
  function declarationFromModules(decl, checker, modules, hops) {
139
179
  if (typescript_1.default.isImportSpecifier(decl) || typescript_1.default.isImportClause(decl) || typescript_1.default.isNamespaceImport(decl)) {
@@ -162,15 +202,42 @@ function declarationFromModules(decl, checker, modules, hops) {
162
202
  if (typescript_1.default.isExportAssignment(decl)) {
163
203
  return expressionFromModules(decl.expression, checker, modules, hops + 1);
164
204
  }
205
+ // function useAnalytics() { return usePostHog() } / const load = () => import("posthog-js"): a function
206
+ // gives back what it returns (or what its return annotation names)
207
+ const fn = typescript_1.default.isFunctionDeclaration(decl) || typescript_1.default.isMethodDeclaration(decl)
208
+ ? decl
209
+ : (typescript_1.default.isVariableDeclaration(decl) || typescript_1.default.isPropertyDeclaration(decl)) &&
210
+ decl.initializer &&
211
+ (typescript_1.default.isArrowFunction(decl.initializer) || typescript_1.default.isFunctionExpression(decl.initializer))
212
+ ? decl.initializer
213
+ : undefined;
214
+ if (fn) {
215
+ if (fn.type && typeFromModules(fn.type, checker, modules, hops))
216
+ return true;
217
+ if (fn.body && returnedExpressions(fn.body).some((returned) => expressionFromModules(returned, checker, modules, hops + 1))) {
218
+ return true;
219
+ }
220
+ }
165
221
  // const { posthog } = usePostHog(): the binding's own declaration is the enclosing variable.
166
222
  const owner = typescript_1.default.isBindingElement(decl) ? decl.parent.parent : decl;
223
+ // promise.then(({ default: posthog }) => ...) / promise.then((mod) => ...): the parameter holds what the promise resolves to
224
+ if (typescript_1.default.isParameter(owner)) {
225
+ const callback = owner.parent;
226
+ const call = callback.parent;
227
+ if ((typescript_1.default.isArrowFunction(callback) || typescript_1.default.isFunctionExpression(callback)) &&
228
+ typescript_1.default.isCallExpression(call) &&
229
+ call.arguments[0] === callback &&
230
+ typescript_1.default.isPropertyAccessExpression(call.expression) &&
231
+ call.expression.name.text === "then") {
232
+ return expressionFromModules(call.expression.expression, checker, modules, hops + 1);
233
+ }
234
+ }
167
235
  // Variables, properties, parameters, functions (return type) and type aliases all keep their
168
236
  // annotation in `type`; variables, properties and parameters keep a default/initial value in
169
237
  // `initializer`. Anything else (classes, interfaces, ...) simply has neither.
170
238
  const { type, initializer } = owner;
171
239
  // let client: PostHog | null / private client: PostHog / (client: PostHog) => ... / type Client = PostHog
172
- if (type &&
173
- typeReferenceRoots(type).some((id) => symbolFromModules(symbolAt(id, checker), checker, modules, hops + 1))) {
240
+ if (type && typeFromModules(type, checker, modules, hops)) {
174
241
  return true;
175
242
  }
176
243
  // const ph = usePostHog() / new PostHog(k) / client = new PostHog(k) / const useTrack = usePostHog
@@ -5,16 +5,35 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.registerConsoleDynamicEventReporterPlugin = registerConsoleDynamicEventReporterPlugin;
7
7
  const chalk_1 = __importDefault(require("chalk"));
8
+ const path_1 = __importDefault(require("path"));
9
+ // paths are printed relative to where the command runs, with forward slashes
10
+ function displayPath(fileName) {
11
+ const relative = path_1.default.relative(process.cwd(), fileName);
12
+ const outside = relative === ".." || relative.startsWith(`..${path_1.default.sep}`) || relative.startsWith("../");
13
+ const shown = relative && !outside && !path_1.default.isAbsolute(relative) ? relative : fileName;
14
+ return shown.replace(/\\/g, "/");
15
+ }
8
16
  const consoleDynamicEventReporter = {
9
17
  name: "console",
10
- report({ occurrences }) {
18
+ report({ occurrences: all }) {
19
+ // the copy of a component class inside an Angular template module repeats a call the class file already reports
20
+ const occurrences = all.filter((occ) => !occ.inGeneratedCode);
11
21
  if (occurrences.length === 0) {
12
22
  return;
13
23
  }
14
24
  console.log(chalk_1.default.blue("\nDynamic event names:"));
15
25
  for (const occ of occurrences) {
16
26
  const values = occ.resolvedValues.length > 0 ? ` (resolved: ${occ.resolvedValues.join(", ")})` : "";
17
- console.log(chalk_1.default.yellow(`~ ${occ.fileName}:${occ.line} ${occ.calleeText}(...)${values}`));
27
+ const file = displayPath(occ.fileName);
28
+ if (occ.inTemplate) {
29
+ console.log(chalk_1.default.yellow(`~ template of ${file}: event="${occ.eventExpression ?? ""}"${values}`));
30
+ }
31
+ else if (occ.inTemplateCode) {
32
+ console.log(chalk_1.default.yellow(`~ template of ${file}: ${occ.calleeText}(...)${values}`));
33
+ }
34
+ else {
35
+ console.log(chalk_1.default.yellow(`~ ${file}:${occ.line} ${occ.calleeText}(...)${values}`));
36
+ }
18
37
  }
19
38
  },
20
39
  };
@@ -32,10 +32,15 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
39
  exports.isTrustedPluginSpecifier = isTrustedPluginSpecifier;
37
40
  exports.createBuiltinPluginRegistry = createBuiltinPluginRegistry;
38
41
  exports.createPluginRegistry = createPluginRegistry;
42
+ const module_1 = require("module");
43
+ const path_1 = __importDefault(require("path"));
39
44
  const external_1 = require("./adapters/external");
40
45
  const configuredSinks_1 = require("./builtins/configuredSinks");
41
46
  const eventra_sdk_1 = require("./builtins/eventra-sdk");
@@ -69,6 +74,19 @@ function pluginLoadHint(spec) {
69
74
  }
70
75
  return "";
71
76
  }
77
+ /**
78
+ * Plugins are installed in the user's project, not next to the CLI (a global
79
+ * install, npx or a pnpm layout would not find them from here), so resolve from
80
+ * the project first and fall back to normal resolution relative to the CLI.
81
+ */
82
+ function resolveFromProject(spec) {
83
+ try {
84
+ return (0, module_1.createRequire)(path_1.default.join(process.cwd(), "package.json")).resolve(spec);
85
+ }
86
+ catch {
87
+ return spec;
88
+ }
89
+ }
72
90
  async function loadExternalPlugin(registry, spec) {
73
91
  const trimmed = spec.trim();
74
92
  // Defensive-only: the sole caller (createPluginRegistry) already does
@@ -80,7 +98,7 @@ async function loadExternalPlugin(registry, spec) {
80
98
  return;
81
99
  let mod;
82
100
  try {
83
- mod = await Promise.resolve(`${trimmed}`).then(s => __importStar(require(s)));
101
+ mod = await Promise.resolve(`${resolveFromProject(trimmed)}`).then(s => __importStar(require(s)));
84
102
  }
85
103
  catch (err) {
86
104
  // Node's dynamic import() only ever rejects with an Error (module
@@ -114,7 +132,7 @@ async function createPluginRegistry(config) {
114
132
  if (!trimmed)
115
133
  continue;
116
134
  if (!isTrustedPluginSpecifier(trimmed)) {
117
- console.warn(`Eventra: skipping plugin "${trimmed}" — only official @eventra_dev/cli-plugin-* packages can be loaded.${pluginLoadHint(trimmed)}`);
135
+ console.warn(`Eventra: skipping plugin "${trimmed}" - only official @eventra_dev/cli-plugin-* packages can be loaded.${pluginLoadHint(trimmed)}`);
118
136
  continue;
119
137
  }
120
138
  await loadExternalPlugin(registry, trimmed);
@@ -5,6 +5,30 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.PluginRegistry = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
+ // the synthetic callee each framework plugin uses for a template `event=...`
9
+ const TEMPLATE_CALLEE = /^__eventra_[a-z]+_template_event__$/;
10
+ // A plugin starts the code it generates from a template with this comment line, so a call found
11
+ // below it is not on a line the user wrote.
12
+ const TEMPLATE_REGION_START = /^\s*\/\/ --- [a-z]+ template \(auto-generated\) ---$/;
13
+ // optional: a module that carries more after the generated code (an Angular component class) ends it
14
+ const TEMPLATE_REGION_END = /^\s*\/\/ --- end template ---$/;
15
+ // 1-based lines of the generated template region in a virtual module, or undefined when it has none
16
+ function templateRegionOf(content) {
17
+ const lines = content.split("\n");
18
+ const start = lines.findIndex((line) => TEMPLATE_REGION_START.test(line));
19
+ if (start === -1)
20
+ return undefined;
21
+ const end = lines.findIndex((line, index) => index > start && TEMPLATE_REGION_END.test(line));
22
+ return { from: start + 1, to: end === -1 ? Infinity : end + 1 };
23
+ }
24
+ // `__eventra_x_template_event__(expr)` (callText may be cut with an ellipsis)
25
+ function templateExpression(occurrence) {
26
+ const open = occurrence.callText.indexOf("(");
27
+ let expression = open === -1 ? occurrence.callText : occurrence.callText.slice(open + 1);
28
+ expression = expression.endsWith(")") ? expression.slice(0, -1) : expression;
29
+ // Angular qualifies a bare name with `this.`; the template never writes it
30
+ return occurrence.calleeText.includes("_angular_") ? expression.replace(/^this\./, "") : expression;
31
+ }
8
32
  class PluginRegistry {
9
33
  preprocessors = [];
10
34
  sinkDetectors = [];
@@ -12,6 +36,7 @@ class PluginRegistry {
12
36
  dynamicEventReporters = [];
13
37
  includePatterns = [];
14
38
  sourceToVirtuals = new Map();
39
+ templateRegions = new Map();
15
40
  registerFilePreprocessor(preprocessor) {
16
41
  if (this.preprocessors.some((p) => p.name === preprocessor.name)) {
17
42
  throw new Error(`File preprocessor already registered: ${preprocessor.name}`);
@@ -75,10 +100,23 @@ class PluginRegistry {
75
100
  }
76
101
  const files = await preprocessor.process(input);
77
102
  if (files.length > 0) {
103
+ // virtual files this source produced before and no longer does: their regions are not valid for anything else
104
+ for (const stale of this.sourceToVirtuals.get(sourceKey) ?? [])
105
+ this.templateRegions.delete(stale);
106
+ for (const file of files) {
107
+ const key = this.normalizePath(file.fileName);
108
+ const region = templateRegionOf(file.content);
109
+ if (region === undefined)
110
+ this.templateRegions.delete(key);
111
+ else
112
+ this.templateRegions.set(key, region);
113
+ }
78
114
  this.sourceToVirtuals.set(sourceKey, files.map((file) => this.normalizePath(file.fileName)));
79
115
  return files;
80
116
  }
81
117
  }
118
+ for (const stale of this.sourceToVirtuals.get(sourceKey) ?? [])
119
+ this.templateRegions.delete(stale);
82
120
  this.sourceToVirtuals.delete(sourceKey);
83
121
  return [{ fileName: input.fileName, content: input.content }];
84
122
  }
@@ -101,10 +139,54 @@ class PluginRegistry {
101
139
  return null;
102
140
  }
103
141
  async runDynamicEventReporters(occurrences) {
142
+ const attributed = this.attributeToSources(occurrences);
104
143
  for (const reporter of this.dynamicEventReporters) {
105
- await reporter.report({ occurrences });
144
+ await reporter.report({ occurrences: attributed });
106
145
  }
107
146
  }
147
+ /**
148
+ * Occurrences are found in virtual modules (`App.vue.ts`). Point them back
149
+ * at the file the user wrote, and flag the ones that come from generated
150
+ * code so reporters do not print a line number that exists nowhere.
151
+ */
152
+ attributeToSources(occurrences) {
153
+ if (this.sourceToVirtuals.size === 0) {
154
+ return occurrences;
155
+ }
156
+ const virtualToSource = new Map();
157
+ for (const [source, virtuals] of this.sourceToVirtuals) {
158
+ for (const virtual of virtuals) {
159
+ if (virtual !== source) {
160
+ virtualToSource.set(virtual, source);
161
+ }
162
+ }
163
+ }
164
+ return occurrences.map((occurrence) => {
165
+ const source = virtualToSource.get(this.normalizePath(occurrence.fileName));
166
+ if (!source) {
167
+ return occurrence;
168
+ }
169
+ const template = TEMPLATE_CALLEE.test(occurrence.calleeText);
170
+ if (template) {
171
+ return {
172
+ ...occurrence,
173
+ fileName: source,
174
+ inTemplate: true,
175
+ eventExpression: templateExpression(occurrence),
176
+ };
177
+ }
178
+ // ordinary code the plugin copied out of the template: its line is in the generated text
179
+ const region = this.templateRegions.get(this.normalizePath(occurrence.fileName));
180
+ if (region !== undefined && occurrence.line >= region.from && occurrence.line <= region.to) {
181
+ return { ...occurrence, fileName: source, inTemplateCode: true };
182
+ }
183
+ // an Angular template module embeds the component class: lines there
184
+ // are generated, the real call is also reported from the class file
185
+ return source.toLowerCase().endsWith(".html")
186
+ ? { ...occurrence, fileName: source, inGeneratedCode: true }
187
+ : { ...occurrence, fileName: source };
188
+ });
189
+ }
108
190
  normalizePath(fileName) {
109
191
  return path_1.default.resolve(fileName).replace(/\\/g, "/");
110
192
  }
@@ -0,0 +1,89 @@
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.defaultRespawnDeps = exports.SCANNING_COMMANDS = exports.RESPAWN_ENV = void 0;
7
+ exports.wantedHeapMb = wantedHeapMb;
8
+ exports.usableMemory = usableMemory;
9
+ exports.respawnWithMoreHeap = respawnWithMoreHeap;
10
+ const child_process_1 = require("child_process");
11
+ const os_1 = __importDefault(require("os"));
12
+ const v8_1 = __importDefault(require("v8"));
13
+ /** Set on the child, so a process started with more memory never starts another one. */
14
+ exports.RESPAWN_ENV = "EVENTRA_HEAP_RESPAWNED";
15
+ /** The commands that read the whole project; the rest (`init`, `--version`, `--help`) stay in one process. */
16
+ exports.SCANNING_COMMANDS = new Set(["sync", "check", "watch", "send"]);
17
+ const MIB = 1024 * 1024;
18
+ // a project of tens of thousands of files needs a few GB while its program is built; more than
19
+ // this is never useful, and half the machine's memory is what can be taken without hurting it
20
+ const MAX_HEAP_MB = 8192;
21
+ const SHARE_OF_MEMORY = 0.5;
22
+ /** Signals a parent passes on, so stopping it stops the child too instead of leaving it running. */
23
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
24
+ /**
25
+ * The old-space limit (MB) worth starting with, or `undefined` to carry on as is: Node's default
26
+ * limit is a fraction of the memory and can be well below what a large monorepo needs.
27
+ */
28
+ function wantedHeapMb(availableMemoryBytes, currentLimitBytes) {
29
+ const wanted = Math.min(MAX_HEAP_MB, Math.floor((availableMemoryBytes * SHARE_OF_MEMORY) / MIB));
30
+ // not worth a second process for a small difference
31
+ return wanted > (currentLimitBytes / MIB) * 1.25 ? wanted : undefined;
32
+ }
33
+ /**
34
+ * Memory this process can use: the smaller of the machine's and the container's limit (cgroups) when it has
35
+ * one. `constrained` is 0 when nothing limits the process, and undefined on a Node that does not report it.
36
+ */
37
+ function usableMemory(total, constrained) {
38
+ return constrained !== undefined && constrained > 0 ? Math.min(total, constrained) : total;
39
+ }
40
+ function runChild(command, args, env) {
41
+ return new Promise((resolve) => {
42
+ const child = (0, child_process_1.spawn)(command, [...args], { stdio: "inherit", env });
43
+ const forwarders = FORWARDED_SIGNALS.map((signal) => {
44
+ const forward = () => child.kill(signal);
45
+ process.on(signal, forward);
46
+ return [signal, forward];
47
+ });
48
+ const done = (code) => {
49
+ for (const [signal, forward] of forwarders)
50
+ process.off(signal, forward);
51
+ resolve(code);
52
+ };
53
+ // a child that could not start at all (`error`) counts as a failed run, like one killed by a signal
54
+ child.on("error", () => done(1));
55
+ child.on("exit", (code) => done(code ?? 1));
56
+ });
57
+ }
58
+ const defaultRespawnDeps = () => ({
59
+ env: process.env,
60
+ execArgv: process.execArgv,
61
+ argv: process.argv,
62
+ execPath: process.execPath,
63
+ availableMemoryBytes: usableMemory(os_1.default.totalmem(), os_1.default.constrainedMemory?.()),
64
+ heapLimitBytes: v8_1.default.getHeapStatistics().heap_size_limit,
65
+ run: runChild,
66
+ exit: (code) => process.exit(code),
67
+ });
68
+ exports.defaultRespawnDeps = defaultRespawnDeps;
69
+ /**
70
+ * Runs a scanning command again in a child with a larger heap when this process would start with too
71
+ * little, and exits with the child's status. Resolves `true` when it did, so the caller stops there. An
72
+ * explicit `--max-old-space-size` (in NODE_OPTIONS or the command line) is the user's choice and is left alone.
73
+ */
74
+ async function respawnWithMoreHeap(deps = (0, exports.defaultRespawnDeps)()) {
75
+ if (deps.env[exports.RESPAWN_ENV])
76
+ return false;
77
+ // a second process costs startup time, so only a command that scans a project is worth it
78
+ if (!exports.SCANNING_COMMANDS.has(deps.argv[2] ?? ""))
79
+ return false;
80
+ const explicit = [deps.env.NODE_OPTIONS ?? "", ...deps.execArgv].some((part) => part.includes("max-old-space-size"));
81
+ if (explicit)
82
+ return false;
83
+ const heapMb = wantedHeapMb(deps.availableMemoryBytes, deps.heapLimitBytes);
84
+ if (heapMb === undefined)
85
+ return false;
86
+ const code = await deps.run(deps.execPath, [`--max-old-space-size=${heapMb}`, ...deps.execArgv, ...deps.argv.slice(1)], { ...deps.env, [exports.RESPAWN_ENV]: "1" });
87
+ deps.exit(code);
88
+ return true;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventra_dev/eventra-cli",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -37,7 +37,8 @@
37
37
  "test:e2e": "pnpm run clean && pnpm run build && tsx tests/run.ts",
38
38
  "clean": "rm -rf dist",
39
39
  "test:mutation": "stryker run",
40
- "check:presets": "tsx scripts/check-preset-packages.ts"
40
+ "check:presets": "tsx scripts/check-preset-packages.ts",
41
+ "typecheck": "tsc --noEmit -p tsconfig.json"
41
42
  },
42
43
  "dependencies": {
43
44
  "chalk": "^4.1.2",