@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.
@@ -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
  }
@@ -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
@@ -46,22 +46,25 @@ program
46
46
  .version(package_json_1.default.version);
47
47
  program
48
48
  .command("init")
49
- .action(async () => {
49
+ .option("-y, --yes", "do not prompt: accept defaults and keep an existing eventra.json")
50
+ .action(async (opts) => {
50
51
  const { init } = await Promise.resolve().then(() => __importStar(require("./cli/init")));
51
- await init();
52
+ await init({ yes: opts.yes });
52
53
  });
53
54
  program
54
55
  .command("sync")
55
- .action(async () => {
56
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
57
+ .action(async (opts) => {
56
58
  const { sync } = await Promise.resolve().then(() => __importStar(require("./cli/sync")));
57
- await sync();
59
+ await sync({ strict: opts.strict });
58
60
  });
59
61
  program
60
62
  .command("check")
61
63
  .option("--fix", "auto fix issues")
64
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
62
65
  .action(async (opts) => {
63
66
  const { check } = await Promise.resolve().then(() => __importStar(require("./cli/check")));
64
- await check({ fix: opts.fix });
67
+ await check({ fix: opts.fix, strict: opts.strict });
65
68
  });
66
69
  program
67
70
  .command("watch")
@@ -0,0 +1,253 @@
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.resolveSinkRules = resolveSinkRules;
7
+ exports.createConfiguredSinkDetector = createConfiguredSinkDetector;
8
+ exports.registerConfiguredSinks = registerConfiguredSinks;
9
+ const typescript_1 = __importDefault(require("typescript"));
10
+ const utils_1 = require("../../analysis/shared/utils");
11
+ const sinkPresets_1 = require("./sinkPresets");
12
+ /** Expands presets and drops malformed rules. `warn` receives a message per dropped rule. */
13
+ function resolveSinkRules(rules, warn) {
14
+ const resolved = [];
15
+ for (const rule of rules) {
16
+ if (rule.preset !== undefined) {
17
+ const preset = typeof rule.preset === "string" && Object.hasOwn(sinkPresets_1.SINK_PRESETS, rule.preset) ? sinkPresets_1.SINK_PRESETS[rule.preset] : undefined;
18
+ if (!preset) {
19
+ warn(`Eventra: unknown sinks preset "${rule.preset}" (available: ${Object.keys(sinkPresets_1.SINK_PRESETS).join(", ")}) - skipped.`);
20
+ continue;
21
+ }
22
+ resolved.push(...preset);
23
+ continue;
24
+ }
25
+ // eventra.json is hand-edited JSON: every field can hold any type, and a wrong one must be skipped, not crash.
26
+ const callee = typeof rule.callee === "string" ? rule.callee.trim() : undefined;
27
+ if (!callee || !/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(callee)) {
28
+ warn(`Eventra: sinks rule needs a "preset" or a dotted "callee" like "analytics.track" - skipped.`);
29
+ continue;
30
+ }
31
+ const nameArg = rule.nameArg ?? 0;
32
+ if (!Number.isInteger(nameArg) || nameArg < 0) {
33
+ warn(`Eventra: sinks rule "${callee}" has an invalid "nameArg" - skipped.`);
34
+ continue;
35
+ }
36
+ if (rule.nameProperty !== undefined && typeof rule.nameProperty !== "string") {
37
+ warn(`Eventra: sinks rule "${callee}" has an invalid "nameProperty" - skipped.`);
38
+ continue;
39
+ }
40
+ if (rule.modules !== undefined &&
41
+ (!Array.isArray(rule.modules) || !rule.modules.every((m) => typeof m === "string" && m !== ""))) {
42
+ warn(`Eventra: sinks rule "${callee}" has an invalid "modules" - skipped.`);
43
+ continue;
44
+ }
45
+ let when;
46
+ if (rule.when !== undefined) {
47
+ const candidate = rule.when;
48
+ if (!candidate ||
49
+ !Number.isInteger(candidate.arg) ||
50
+ candidate.arg < 0 ||
51
+ typeof candidate.equals !== "string" ||
52
+ candidate.equals === "") {
53
+ warn(`Eventra: sinks rule "${callee}" has an invalid "when" - skipped.`);
54
+ continue;
55
+ }
56
+ when = { arg: candidate.arg, equals: candidate.equals };
57
+ }
58
+ resolved.push({
59
+ callee,
60
+ nameArg,
61
+ ...(rule.nameProperty ? { nameProperty: rule.nameProperty } : {}),
62
+ ...(rule.modules?.length ? { modules: rule.modules } : {}),
63
+ ...(when ? { when } : {}),
64
+ });
65
+ }
66
+ return resolved;
67
+ }
68
+ /** `a.b.c` / `this.a.b` / `a?.b` -> "a.b.c"; null for anything that isn't a plain property path. */
69
+ function calleePath(expr) {
70
+ const e = (0, utils_1.unwrapExpression)(expr);
71
+ if (typescript_1.default.isIdentifier(e))
72
+ return e.text;
73
+ if (e.kind === typescript_1.default.SyntaxKind.ThisKeyword)
74
+ return "this";
75
+ if (typescript_1.default.isPropertyAccessExpression(e)) {
76
+ const head = calleePath(e.expression);
77
+ return head === null ? null : `${head}.${e.name.text}`;
78
+ }
79
+ return null;
80
+ }
81
+ function moduleMatches(specifier, modules) {
82
+ return modules.some((m) => specifier === m || specifier.startsWith(`${m}/`));
83
+ }
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
+ /**
100
+ * The symbol an identifier refers to, keeping an import as the import binding itself. TS resolves an
101
+ * import all the way to its target in some positions once something makes the module resolvable
102
+ * (a wildcard `declare module "*"` does that for every unresolved package), and the trail to the
103
+ * package's name is gone by then.
104
+ */
105
+ function symbolAt(id, checker) {
106
+ const symbol = checker.getSymbolAtLocation(id);
107
+ // Stryker disable next-line ConditionalExpression: fast path only; the scope lookup below returns the same alias
108
+ if (symbol && symbol.flags & typescript_1.default.SymbolFlags.Alias)
109
+ return symbol;
110
+ return checker.getSymbolsInScope(id, typescript_1.default.SymbolFlags.Alias).find((alias) => alias.name === id.text) ?? symbol;
111
+ }
112
+ /** The symbol `name` exported by the module a specifier points at, when TS can resolve that module. */
113
+ function resolveExport(specifier, name, checker) {
114
+ const moduleSymbol = checker.getSymbolAtLocation(specifier);
115
+ return moduleSymbol ? checker.tryGetMemberInModuleExports(name, moduleSymbol) : undefined;
116
+ }
117
+ /**
118
+ * `usePostHog()`, `new PostHog(k)`, `await init()`, `this.client`, `this.getClient()`,
119
+ * `AnalyticsBrowser.load()` or a bare alias `usePostHog`: true when the name at the heart of the
120
+ * expression, or the import its chain hangs off, originates from `modules`.
121
+ */
122
+ function expressionFromModules(expression, checker, modules, hops) {
123
+ let expr = (0, utils_1.unwrapExpression)(expression);
124
+ if (typescript_1.default.isAwaitExpression(expr))
125
+ expr = (0, utils_1.unwrapExpression)(expr.expression);
126
+ if (typescript_1.default.isCallExpression(expr) || typescript_1.default.isNewExpression(expr))
127
+ expr = (0, utils_1.unwrapExpression)(expr.expression);
128
+ const fromName = (name) => typescript_1.default.isIdentifier(name) && symbolFromModules(symbolAt(name, checker), checker, modules, hops);
129
+ if (!typescript_1.default.isPropertyAccessExpression(expr))
130
+ return fromName(expr);
131
+ // `this.client` is vetted through the property's own declaration; `AnalyticsBrowser.load()` and
132
+ // `amplitude.getInstance()` hang off an import, so the chain's root decides.
133
+ let root = expr;
134
+ while (typescript_1.default.isPropertyAccessExpression(root))
135
+ root = (0, utils_1.unwrapExpression)(root.expression);
136
+ return fromName(expr.name) || fromName(root);
137
+ }
138
+ function declarationFromModules(decl, checker, modules, hops) {
139
+ if (typescript_1.default.isImportSpecifier(decl) || typescript_1.default.isImportClause(decl) || typescript_1.default.isNamespaceImport(decl)) {
140
+ // Every import binding sits under an ImportDeclaration, whose specifier is a string literal.
141
+ const importDecl = typescript_1.default.findAncestor(decl, typescript_1.default.isImportDeclaration);
142
+ if (moduleMatches(importDecl.moduleSpecifier.text, modules))
143
+ return true;
144
+ if (typescript_1.default.isNamespaceImport(decl))
145
+ return false;
146
+ const exported = typescript_1.default.isImportSpecifier(decl) ? (decl.propertyName ?? decl.name).text : "default";
147
+ return symbolFromModules(resolveExport(importDecl.moduleSpecifier, exported, checker), checker, modules, hops + 1);
148
+ }
149
+ if (typescript_1.default.isExportSpecifier(decl)) {
150
+ const specifier = decl.parent.parent.moduleSpecifier;
151
+ if (!specifier) {
152
+ // export { local as alias }
153
+ return symbolFromModules(checker.getExportSpecifierLocalTargetSymbol(decl), checker, modules, hops + 1);
154
+ }
155
+ // export { name as alias } from "module"
156
+ if (moduleMatches(specifier.text, modules))
157
+ return true;
158
+ const exported = (decl.propertyName ?? decl.name).text;
159
+ return symbolFromModules(resolveExport(specifier, exported, checker), checker, modules, hops + 1);
160
+ }
161
+ // export default client
162
+ if (typescript_1.default.isExportAssignment(decl)) {
163
+ return expressionFromModules(decl.expression, checker, modules, hops + 1);
164
+ }
165
+ // const { posthog } = usePostHog(): the binding's own declaration is the enclosing variable.
166
+ const owner = typescript_1.default.isBindingElement(decl) ? decl.parent.parent : decl;
167
+ // Variables, properties, parameters, functions (return type) and type aliases all keep their
168
+ // annotation in `type`; variables, properties and parameters keep a default/initial value in
169
+ // `initializer`. Anything else (classes, interfaces, ...) simply has neither.
170
+ const { type, initializer } = owner;
171
+ // 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))) {
174
+ return true;
175
+ }
176
+ // const ph = usePostHog() / new PostHog(k) / client = new PostHog(k) / const useTrack = usePostHog
177
+ return initializer !== undefined && expressionFromModules(initializer, checker, modules, hops + 1);
178
+ }
179
+ /**
180
+ * True when `symbol` originates from one of `modules`, following import/re-export chains
181
+ * (`import { useAnalytics } from "@/hooks"` -> `export { usePostHog as useAnalytics } from "posthog-js/react"`).
182
+ * Works without installed dependencies, by reading the specifiers along the chain.
183
+ */
184
+ function symbolFromModules(symbol, checker, modules, hops = 0) {
185
+ if (!symbol || hops > MAX_ALIAS_HOPS)
186
+ return false;
187
+ // A real ts.Symbol's `declarations` is only undefined for synthetic symbols we never see here.
188
+ // Stryker disable next-line ArrayDeclaration: unreachable fallback, see above
189
+ /* v8 ignore next */
190
+ const declarations = symbol.declarations ?? [];
191
+ return declarations.some((decl) => declarationFromModules(decl, checker, modules, hops));
192
+ }
193
+ /** `rule.when`: the guard argument must be the exact string literal (`gtag("event", ...)` but not `gtag("config", ...)`). */
194
+ function whenSatisfied(call, rule) {
195
+ if (!rule.when)
196
+ return true;
197
+ const guard = call.arguments[rule.when.arg];
198
+ const literal = guard && (0, utils_1.unwrapExpression)(guard);
199
+ return literal !== undefined && typescript_1.default.isStringLiteralLike(literal) && literal.text === rule.when.equals;
200
+ }
201
+ function trackedArgumentFor(call, rule) {
202
+ const arg = call.arguments[rule.nameArg];
203
+ if (!arg)
204
+ return null;
205
+ const literal = (0, utils_1.unwrapExpression)(arg);
206
+ if (typescript_1.default.isObjectLiteralExpression(literal)) {
207
+ // Only `{ event: name }` is a usable shape; the scanner reads this property off the literal.
208
+ // (`SpreadAssignment` has no name, hence the kind check.)
209
+ const found = literal.properties.some((p) => (typescript_1.default.isPropertyAssignment(p) || typescript_1.default.isShorthandPropertyAssignment(p)) && p.name.getText() === rule.nameProperty);
210
+ return found ? { index: rule.nameArg, propertyPath: [rule.nameProperty] } : null;
211
+ }
212
+ // Stryker disable next-line ArrayDeclaration: propertyPath is only read for object-literal arguments
213
+ return { index: rule.nameArg, propertyPath: [] };
214
+ }
215
+ function createConfiguredSinkDetector(rules) {
216
+ return {
217
+ name: "configured-sinks",
218
+ detect({ call, checker }) {
219
+ const callee = (0, utils_1.unwrapExpression)(call.expression);
220
+ // `gtag(...)` / `x.capture(...)`: the name actually being called.
221
+ const last = typescript_1.default.isIdentifier(callee) ? callee.text : typescript_1.default.isPropertyAccessExpression(callee) ? callee.name.text : null;
222
+ const path = calleePath(callee);
223
+ for (const rule of rules) {
224
+ // A dotted rule needs the whole plain path (`foo().analytics.track` has none); a bare name
225
+ // matches whatever receiver it hangs off, including a call (`usePostHog().capture`).
226
+ const matched = rule.callee.includes(".")
227
+ ? path !== null && (path === rule.callee || path.endsWith(`.${rule.callee}`))
228
+ : last === rule.callee;
229
+ if (!matched || !whenSatisfied(call, rule))
230
+ continue;
231
+ // A bare name is too generic to match alone: a receiver-vetted rule checks where the receiver
232
+ // (`x` in `x.capture(...)`) or, for a bare call, the called name itself comes from.
233
+ if (rule.modules &&
234
+ !expressionFromModules(typescript_1.default.isPropertyAccessExpression(callee) ? callee.expression : callee, checker, rule.modules, 0)) {
235
+ continue;
236
+ }
237
+ const tracked = trackedArgumentFor(call, rule);
238
+ if (tracked)
239
+ return { call, trackedArguments: [tracked] };
240
+ }
241
+ return null;
242
+ },
243
+ };
244
+ }
245
+ /** Registers the `sinks` rules from eventra.json (declarative data only - never code). */
246
+ function registerConfiguredSinks(registry, rules, warn = (m) => console.warn(m)) {
247
+ if (!rules?.length)
248
+ return;
249
+ const resolved = resolveSinkRules(rules, warn);
250
+ if (resolved.length === 0)
251
+ return;
252
+ registry.registerSinkDetector(createConfiguredSinkDetector(resolved));
253
+ }
@@ -5,16 +5,32 @@ 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 {
32
+ console.log(chalk_1.default.yellow(`~ ${file}:${occ.line} ${occ.calleeText}(...)${values}`));
33
+ }
18
34
  }
19
35
  },
20
36
  };