@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/LICENSE +14 -2
- package/README.md +95 -12
- package/dist/analysis/extractor/extractor.js +1 -1
- package/dist/analysis/extractor/propagationExtractor.js +56 -8
- package/dist/analysis/resolver/resolver.js +7 -4
- package/dist/analysis/scanner/analyzer/propagationAnalyzer.js +5 -0
- package/dist/analysis/scanner/scanner.js +10 -3
- package/dist/analysis/sdk/eventraSdk.js +6 -1
- package/dist/analysis/symbols/symbolUtils.js +12 -0
- package/dist/analysis/symbols/wrapperRegistry.js +14 -5
- package/dist/cli/check.js +25 -9
- package/dist/cli/init.js +71 -12
- package/dist/cli/projectDependencies.js +119 -0
- package/dist/cli/send.js +31 -2
- package/dist/cli/sync.js +13 -10
- package/dist/cli/watch.js +163 -62
- package/dist/compiler/compilerContext.js +103 -5
- package/dist/compiler/documentRegistry.js +15 -1
- package/dist/compiler/importGraph.js +8 -0
- package/dist/config/config.js +52 -2
- package/dist/core/EventraEngine.js +129 -16
- package/dist/core/catalogMeta.js +91 -0
- package/dist/core/fileLimits.js +12 -0
- package/dist/core/projectScanner.js +14 -24
- package/dist/core/scanFilters.js +146 -0
- package/dist/core/scanResults.js +21 -7
- package/dist/core/skipped.js +77 -0
- package/dist/index.js +8 -5
- package/dist/plugin/builtins/configuredSinks.js +253 -0
- package/dist/plugin/builtins/consoleDynamicEventReporter.js +18 -2
- package/dist/plugin/builtins/sinkPresets.js +99 -0
- package/dist/plugin/loadPlugins.js +22 -2
- package/dist/plugin/registry.js +50 -1
- package/package.json +11 -2
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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);
|
package/dist/config/config.js
CHANGED
|
@@ -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;
|
|
@@ -21,6 +23,11 @@ exports.CONFIG_NAME = "eventra.json";
|
|
|
21
23
|
exports.LOCAL_CONFIG_NAME = "eventra.local.json";
|
|
22
24
|
const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
|
|
23
25
|
const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
|
|
26
|
+
function normalizeSinks(sinks) {
|
|
27
|
+
if (!Array.isArray(sinks))
|
|
28
|
+
return [];
|
|
29
|
+
return sinks.filter((s) => typeof s === "object" && s !== null && !Array.isArray(s));
|
|
30
|
+
}
|
|
24
31
|
function normalizeConfig(config) {
|
|
25
32
|
const functionWrappers = (config.functionWrappers ?? [])
|
|
26
33
|
.filter((w) => w?.name)
|
|
@@ -32,12 +39,16 @@ function normalizeConfig(config) {
|
|
|
32
39
|
seen.add(w.name);
|
|
33
40
|
return true;
|
|
34
41
|
});
|
|
42
|
+
const sinks = normalizeSinks(config.sinks);
|
|
35
43
|
return {
|
|
36
44
|
apiKey: config.apiKey ?? "",
|
|
37
45
|
endpoint: config.endpoint ?? "",
|
|
38
46
|
events: [...(config.events ?? [])].sort(),
|
|
39
|
-
|
|
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)),
|
|
40
50
|
plugins: [...(config.plugins ?? [])],
|
|
51
|
+
...(sinks.length > 0 ? { sinks } : {}),
|
|
41
52
|
sync: {
|
|
42
53
|
include: config.sync?.include ?? DEFAULT_INCLUDE,
|
|
43
54
|
exclude: config.sync?.exclude ?? DEFAULT_EXCLUDE,
|
|
@@ -57,9 +68,48 @@ async function loadConfig() {
|
|
|
57
68
|
return null;
|
|
58
69
|
}
|
|
59
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). */
|
|
60
102
|
async function saveConfig(config) {
|
|
61
103
|
const configPath = path_1.default.join(process.cwd(), exports.CONFIG_NAME);
|
|
62
|
-
|
|
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);
|
|
63
113
|
}
|
|
64
114
|
async function readLocalSecrets() {
|
|
65
115
|
const localPath = path_1.default.join(process.cwd(), exports.LOCAL_CONFIG_NAME);
|
|
@@ -15,7 +15,6 @@ const resolvedExportCache_1 = require("../analysis/cache/resolvedExportCache");
|
|
|
15
15
|
const returnPropagationCache_1 = require("../analysis/cache/returnPropagationCache");
|
|
16
16
|
const analyzeFile_1 = require("../analysis/engine/analyzeFile");
|
|
17
17
|
const wrapperRegistry_1 = require("../analysis/symbols/wrapperRegistry");
|
|
18
|
-
const processFile_1 = require("../filesystem/processFile");
|
|
19
18
|
const loadPlugins_1 = require("../plugin/loadPlugins");
|
|
20
19
|
const EMPTY_RESULT = () => ({
|
|
21
20
|
events: new Set(),
|
|
@@ -34,6 +33,10 @@ class EventraEngine {
|
|
|
34
33
|
fileIndices = new Map();
|
|
35
34
|
fileResults = new Map();
|
|
36
35
|
normalizedPathCache = new Map();
|
|
36
|
+
// files whose events count (the scanned sources); files that are in the
|
|
37
|
+
// program only because something imports them are never extracted, as in a
|
|
38
|
+
// fresh scan
|
|
39
|
+
scannedFiles = new Set();
|
|
37
40
|
plugins;
|
|
38
41
|
isPreloading = false;
|
|
39
42
|
lastConfig = {
|
|
@@ -47,14 +50,32 @@ class EventraEngine {
|
|
|
47
50
|
constructor(rootDir, plugins = (0, loadPlugins_1.createBuiltinPluginRegistry)()) {
|
|
48
51
|
this.plugins = plugins;
|
|
49
52
|
this.compiler = new compilerContext_1.CompilerContext(rootDir);
|
|
50
|
-
|
|
53
|
+
// resolved lazily: asking for the checker now would build the program
|
|
54
|
+
this.wrapperRegistry = new wrapperRegistry_1.WrapperRegistry(() => this.compiler.getChecker(), this.resolvedExportCache);
|
|
51
55
|
this.scheduler = new scheduler_1.Scheduler(async (updates) => {
|
|
52
56
|
const changedFiles = new Set();
|
|
57
|
+
// a file the graph has not seen yet changes what other files' imports resolve to
|
|
58
|
+
let fileSetChanged = false;
|
|
59
|
+
// Stage every file, rebuild the program ONCE, then refresh the import
|
|
60
|
+
// graph against that final program (one rebuild per flush, not per file).
|
|
53
61
|
for (const [file, content] of updates) {
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
if (!this.importGraph.hasFile(file))
|
|
63
|
+
fileSetChanged = true;
|
|
64
|
+
this.scannedFiles.add(file);
|
|
65
|
+
this.compiler.stageFile(file, content);
|
|
56
66
|
changedFiles.add(file);
|
|
57
67
|
}
|
|
68
|
+
this.compiler.flushUpdates();
|
|
69
|
+
const edgeChanged = new Set();
|
|
70
|
+
this.compiler.withResolutionCache(() => {
|
|
71
|
+
for (const file of changedFiles) {
|
|
72
|
+
this.updateImportGraph(file);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
if (fileSetChanged) {
|
|
76
|
+
for (const file of this.refreshImportEdges(changedFiles))
|
|
77
|
+
edgeChanged.add(file);
|
|
78
|
+
}
|
|
58
79
|
// Program was rebuilt → every ts.Symbol from the previous program is now
|
|
59
80
|
// dead. WeakMap-keyed caches (incl. WrapperRegistry) must be dropped before
|
|
60
81
|
// we touch any NEW symbols, otherwise cross-file wrapper lookups miss.
|
|
@@ -64,18 +85,20 @@ class EventraEngine {
|
|
|
64
85
|
// symbols for every file, otherwise affected files cannot resolve
|
|
65
86
|
// wrappers defined in unaffected files (this is the watch-corruption bug).
|
|
66
87
|
this.reindexAll(this.lastConfig);
|
|
67
|
-
// Incremental extract: only re-extract changed files + their dependents
|
|
88
|
+
// Incremental extract: only re-extract changed files + their dependents
|
|
89
|
+
// (+ files whose imports now resolve differently, and their dependents).
|
|
68
90
|
// Unaffected files keep their previously-computed Set<string> events,
|
|
69
91
|
// which is safe (just strings, no AST refs).
|
|
70
92
|
const affected = new Set();
|
|
71
|
-
for (const file of changedFiles) {
|
|
93
|
+
for (const file of [...changedFiles, ...edgeChanged]) {
|
|
72
94
|
affected.add(file);
|
|
73
95
|
for (const dependent of this.importGraph.collectDependents(file)) {
|
|
74
96
|
affected.add(dependent);
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
99
|
for (const file of affected) {
|
|
78
|
-
this.
|
|
100
|
+
if (this.scannedFiles.has(file))
|
|
101
|
+
this.extractFile(file, this.lastConfig);
|
|
79
102
|
}
|
|
80
103
|
});
|
|
81
104
|
}
|
|
@@ -107,12 +130,14 @@ class EventraEngine {
|
|
|
107
130
|
this.normalizedPathCache.set(fileName, normalized);
|
|
108
131
|
return normalized;
|
|
109
132
|
}
|
|
133
|
+
// true when the file's resolved imports changed
|
|
110
134
|
updateImportGraph(fileName) {
|
|
111
135
|
const normalized = this.normalize(fileName);
|
|
112
136
|
const source = this.compiler.getSourceFile(normalized);
|
|
113
137
|
if (!source) {
|
|
138
|
+
const known = this.importGraph.hasFile(normalized);
|
|
114
139
|
this.importGraph.removeFile(normalized);
|
|
115
|
-
return;
|
|
140
|
+
return known;
|
|
116
141
|
}
|
|
117
142
|
const imports = [];
|
|
118
143
|
for (const statement of source.statements) {
|
|
@@ -132,22 +157,45 @@ class EventraEngine {
|
|
|
132
157
|
}
|
|
133
158
|
imports.push(this.normalize(resolved));
|
|
134
159
|
}
|
|
135
|
-
this.importGraph.updateFile(normalized, imports);
|
|
160
|
+
return this.importGraph.updateFile(normalized, imports);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Creating or deleting a file can change what OTHER files' imports resolve to,
|
|
164
|
+
* without those files changing: an import written before its target existed
|
|
165
|
+
* starts to resolve, a deleted target stops. Their edges are re-resolved
|
|
166
|
+
* against the current program; the files whose imports changed are returned
|
|
167
|
+
* so they get analysed again (the graph alone cannot know about them: the
|
|
168
|
+
* reverse edge of a file that did not exist was never recorded).
|
|
169
|
+
*/
|
|
170
|
+
refreshImportEdges(skip) {
|
|
171
|
+
const changed = new Set();
|
|
172
|
+
this.compiler.withResolutionCache(() => {
|
|
173
|
+
for (const file of this.importGraph.getFiles()) {
|
|
174
|
+
if (skip.has(file))
|
|
175
|
+
continue;
|
|
176
|
+
if (this.updateImportGraph(file))
|
|
177
|
+
changed.add(file);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
return changed;
|
|
136
181
|
}
|
|
137
182
|
async preloadFile(fileName, content) {
|
|
138
183
|
if (!this.isPreloading) {
|
|
139
184
|
throw new Error("preload phase not active");
|
|
140
185
|
}
|
|
141
186
|
const normalized = this.normalize(fileName);
|
|
142
|
-
|
|
143
|
-
|
|
187
|
+
// processFile only echoed fileName/content back (its dependency list was
|
|
188
|
+
// unused here), so stage directly instead of parsing the file a second time.
|
|
189
|
+
this.compiler.stageFile(normalized, content);
|
|
144
190
|
}
|
|
145
191
|
endPreload() {
|
|
146
192
|
this.isPreloading = false;
|
|
147
193
|
this.compiler.flushUpdates();
|
|
148
|
-
|
|
149
|
-
this.
|
|
150
|
-
|
|
194
|
+
this.compiler.withResolutionCache(() => {
|
|
195
|
+
for (const sourceFile of this.compiler.getAllSourceFiles()) {
|
|
196
|
+
this.updateImportGraph(sourceFile.fileName);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
151
199
|
}
|
|
152
200
|
async updateFile(fileName, content, config) {
|
|
153
201
|
this.lastConfig = config;
|
|
@@ -162,6 +210,7 @@ class EventraEngine {
|
|
|
162
210
|
this.refreshChecker();
|
|
163
211
|
const normalized = fileNames.map((f) => this.normalize(f));
|
|
164
212
|
for (const file of normalized) {
|
|
213
|
+
this.scannedFiles.add(file);
|
|
165
214
|
this.indexFile(file, config);
|
|
166
215
|
}
|
|
167
216
|
for (const file of normalized) {
|
|
@@ -178,8 +227,62 @@ class EventraEngine {
|
|
|
178
227
|
this.reindexAll(config);
|
|
179
228
|
const normalized = fileNames.map((f) => this.normalize(f));
|
|
180
229
|
for (const file of normalized) {
|
|
181
|
-
this.
|
|
230
|
+
if (this.scannedFiles.has(file))
|
|
231
|
+
this.extractFile(file, config);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Files that are only imported (not scanned, e.g. `shared/events.ts` outside
|
|
236
|
+
* `include`) changed on disk, appeared or vanished. Their content is re-read
|
|
237
|
+
* (one program rebuild for the whole batch), the import graph is refreshed and
|
|
238
|
+
* the scanned files that depend on them are analysed again, so a long-running
|
|
239
|
+
* engine agrees with a fresh scan. Returns false when no content changed.
|
|
240
|
+
*/
|
|
241
|
+
async refreshExternalFiles(fileNames, config) {
|
|
242
|
+
this.lastConfig = config;
|
|
243
|
+
const normalized = fileNames.map((f) => this.normalize(f));
|
|
244
|
+
// dependents are read before the edges change
|
|
245
|
+
const affected = new Set();
|
|
246
|
+
for (const file of normalized) {
|
|
247
|
+
for (const dependent of this.importGraph.collectDependents(file)) {
|
|
248
|
+
affected.add(dependent);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const changed = this.compiler.refreshFromDisk(normalized);
|
|
252
|
+
if (changed.length === 0) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
this.compiler.withResolutionCache(() => {
|
|
256
|
+
for (const file of changed) {
|
|
257
|
+
this.updateImportGraph(file);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// importers that had no edge before (a missing target appeared) or lost it
|
|
261
|
+
// (the target vanished), and everything depending on them
|
|
262
|
+
for (const file of this.refreshImportEdges(new Set(changed))) {
|
|
263
|
+
affected.add(file);
|
|
264
|
+
for (const dependent of this.importGraph.collectDependents(file)) {
|
|
265
|
+
affected.add(dependent);
|
|
266
|
+
}
|
|
182
267
|
}
|
|
268
|
+
for (const file of changed) {
|
|
269
|
+
for (const dependent of this.importGraph.collectDependents(file)) {
|
|
270
|
+
affected.add(dependent);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const file of changed) {
|
|
274
|
+
affected.delete(file);
|
|
275
|
+
}
|
|
276
|
+
await this.reanalyzeFiles([...affected], config);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
/** True when the file is in the program (scanned or only imported). */
|
|
280
|
+
hasProgramFile(fileName) {
|
|
281
|
+
return this.compiler.hasFile(this.normalize(fileName));
|
|
282
|
+
}
|
|
283
|
+
/** Project files in the program, for watching those that live outside the project root. */
|
|
284
|
+
getProjectFileNames() {
|
|
285
|
+
return this.compiler.getProjectFileNames();
|
|
183
286
|
}
|
|
184
287
|
indexFile(fileName, _config) {
|
|
185
288
|
const normalized = this.normalize(fileName);
|
|
@@ -204,6 +307,7 @@ class EventraEngine {
|
|
|
204
307
|
}
|
|
205
308
|
async scanFile(fileName, config) {
|
|
206
309
|
this.lastConfig = config;
|
|
310
|
+
this.scannedFiles.add(this.normalize(fileName));
|
|
207
311
|
this.refreshChecker();
|
|
208
312
|
this.indexFile(fileName, config);
|
|
209
313
|
return this.extractFile(fileName, config);
|
|
@@ -215,8 +319,16 @@ class EventraEngine {
|
|
|
215
319
|
this.compiler.removeFile(normalized);
|
|
216
320
|
this.fileIndices.delete(normalized);
|
|
217
321
|
this.fileResults.delete(normalized);
|
|
322
|
+
this.scannedFiles.delete(normalized);
|
|
218
323
|
this.normalizedPathCache.delete(fileName);
|
|
219
324
|
this.normalizedPathCache.delete(normalized);
|
|
325
|
+
// importers of the removed file now resolve differently (and another file
|
|
326
|
+
// may now win a resolution the removed one was shadowing)
|
|
327
|
+
for (const file of this.refreshImportEdges(new Set([normalized]))) {
|
|
328
|
+
for (const dependent of this.importGraph.collectDependents(file)) {
|
|
329
|
+
affected.add(dependent);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
220
332
|
const nextConfig = config ?? this.lastConfig;
|
|
221
333
|
const toReanalyze = [...affected].filter((f) => f !== normalized);
|
|
222
334
|
// reanalyzeFiles already resets caches + reindexes all sources,
|
|
@@ -259,7 +371,8 @@ class EventraEngine {
|
|
|
259
371
|
for (const result of this.fileResults.values()) {
|
|
260
372
|
occurrences.push(...result.dynamicOccurrences);
|
|
261
373
|
}
|
|
262
|
-
|
|
374
|
+
const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
375
|
+
return occurrences.sort((a, b) => byText(a.fileName, b.fileName) || a.line - b.line || a.character - b.character);
|
|
263
376
|
}
|
|
264
377
|
}
|
|
265
378
|
exports.EventraEngine = EventraEngine;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isPullRequestBuild = isPullRequestBuild;
|
|
4
|
+
exports.detectBranch = detectBranch;
|
|
5
|
+
exports.countUnresolved = countUnresolved;
|
|
6
|
+
exports.collectCatalogMeta = collectCatalogMeta;
|
|
7
|
+
const child_process_1 = require("child_process");
|
|
8
|
+
const projectScanner_1 = require("./projectScanner");
|
|
9
|
+
const MAX_BRANCH_LENGTH = 200;
|
|
10
|
+
// CI systems usually check out a detached HEAD, so git cannot name the branch
|
|
11
|
+
// there; these variables can. First one set wins.
|
|
12
|
+
const BRANCH_ENV_VARS = [
|
|
13
|
+
"GITHUB_REF_NAME",
|
|
14
|
+
"CI_COMMIT_REF_NAME",
|
|
15
|
+
"BRANCH_NAME",
|
|
16
|
+
"BITBUCKET_BRANCH",
|
|
17
|
+
"CIRCLE_BRANCH",
|
|
18
|
+
"BUILDKITE_BRANCH",
|
|
19
|
+
"VERCEL_GIT_COMMIT_REF",
|
|
20
|
+
];
|
|
21
|
+
// A pull / merge request build checks out the PROPOSED code: a PR that deletes
|
|
22
|
+
// an event must not mark it removed on the project catalog.
|
|
23
|
+
const PULL_REQUEST_MARKERS = [
|
|
24
|
+
(env) => env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target",
|
|
25
|
+
(env) => (env.GITHUB_REF ?? "").startsWith("refs/pull/"),
|
|
26
|
+
(env) => Boolean(env.CI_MERGE_REQUEST_IID) || env.CI_PIPELINE_SOURCE === "merge_request_event",
|
|
27
|
+
(env) => Boolean(env.BITBUCKET_PR_ID),
|
|
28
|
+
(env) => Boolean(env.CIRCLE_PULL_REQUEST || env.CIRCLE_PR_NUMBER),
|
|
29
|
+
(env) => Boolean(env.BUILDKITE_PULL_REQUEST) && env.BUILDKITE_PULL_REQUEST !== "false",
|
|
30
|
+
(env) => Boolean(env.VERCEL_GIT_PULL_REQUEST_ID),
|
|
31
|
+
(env) => Boolean(env.TRAVIS_PULL_REQUEST) && env.TRAVIS_PULL_REQUEST !== "false",
|
|
32
|
+
(env) => Boolean(env.SYSTEM_PULLREQUEST_PULLREQUESTID) || env.BUILD_REASON === "PullRequest",
|
|
33
|
+
(env) => Boolean(env.CHANGE_ID),
|
|
34
|
+
];
|
|
35
|
+
/** True when the environment says this run is a pull / merge request build. */
|
|
36
|
+
function isPullRequestBuild(env = process.env) {
|
|
37
|
+
return PULL_REQUEST_MARKERS.some((marker) => marker(env));
|
|
38
|
+
}
|
|
39
|
+
/** Best-effort current branch. Never throws; returns undefined when unknown. */
|
|
40
|
+
function detectBranch(env = process.env, cwd = process.cwd()) {
|
|
41
|
+
for (const name of BRANCH_ENV_VARS) {
|
|
42
|
+
const value = env[name]?.trim();
|
|
43
|
+
if (value)
|
|
44
|
+
return value.slice(0, MAX_BRANCH_LENGTH);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const out = (0, child_process_1.execFileSync)("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
timeout: 3000,
|
|
51
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
52
|
+
}).trim();
|
|
53
|
+
// "HEAD" is what git prints for a detached checkout: not a branch name
|
|
54
|
+
if (out && out !== "HEAD")
|
|
55
|
+
return out.slice(0, MAX_BRANCH_LENGTH);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// not a git checkout, git missing or too slow
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function sameSet(a, b) {
|
|
63
|
+
if (a.length !== b.length)
|
|
64
|
+
return false;
|
|
65
|
+
const set = new Set(a);
|
|
66
|
+
return b.every((value) => set.has(value));
|
|
67
|
+
}
|
|
68
|
+
// An Angular template module embeds a copy of its component class, so the same
|
|
69
|
+
// call is reported twice: once from the class file and once from that copy
|
|
70
|
+
// (inGeneratedCode). Only the real one is counted.
|
|
71
|
+
function countUnresolved(occurrences) {
|
|
72
|
+
return occurrences.filter((o) => o.resolvedValues.length === 0 && !o.inGeneratedCode).length;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Scans the project again to find out whether the event list about to be sent
|
|
76
|
+
* is complete. Never throws: any failure means complete = false.
|
|
77
|
+
*/
|
|
78
|
+
async function collectCatalogMeta(config) {
|
|
79
|
+
const branch = detectBranch();
|
|
80
|
+
const withBranch = (meta) => (branch ? { ...meta, branch } : meta);
|
|
81
|
+
try {
|
|
82
|
+
const { engine, skipped } = await (0, projectScanner_1.scanProject)(config);
|
|
83
|
+
const scanned = engine.getAllEvents();
|
|
84
|
+
const dynamicCount = countUnresolved(engine.getAllDynamicOccurrences());
|
|
85
|
+
const complete = skipped.length === 0 && !isPullRequestBuild() && sameSet(config.events ?? [], scanned);
|
|
86
|
+
return withBranch({ complete, dynamicCount });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return withBranch({ complete: false });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OVERSIZED_REASON = exports.MAX_FILE_CHARS = void 0;
|
|
4
|
+
exports.isOversized = isOversized;
|
|
5
|
+
// Files above this size are generated or vendored output (bundles, minified
|
|
6
|
+
// code, big fixtures), not something to analyse. One limit for every command,
|
|
7
|
+
// so `sync` and `watch` can never disagree about a file.
|
|
8
|
+
exports.MAX_FILE_CHARS = 2_000_000;
|
|
9
|
+
exports.OVERSIZED_REASON = "file is larger than 2 MB";
|
|
10
|
+
function isOversized(content) {
|
|
11
|
+
return content.length > exports.MAX_FILE_CHARS;
|
|
12
|
+
}
|