runtime-checker 1.0.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/src/scanner.rs ADDED
@@ -0,0 +1,404 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ fs, io,
4
+ path::{Path, PathBuf},
5
+ };
6
+
7
+ use aho_corasick::{AhoCorasick, AhoCorasickBuilder, MatchKind};
8
+ use anyhow::{Context, Result};
9
+ use fff_grep::{
10
+ LineTerminator, Match, Matcher, NoError, Searcher, SearcherBuilder, Sink, SinkMatch,
11
+ };
12
+ use ignore::WalkBuilder;
13
+
14
+ use crate::{
15
+ data::{Feature, RuntimeDb},
16
+ version::RuntimeVersion,
17
+ };
18
+
19
+ #[derive(Debug, Clone)]
20
+ pub struct SourceFile {
21
+ pub path: PathBuf,
22
+ pub text: String,
23
+ }
24
+
25
+ #[derive(Debug, Clone, Copy, Default)]
26
+ pub struct ScanStats {
27
+ pub line_count: usize,
28
+ }
29
+
30
+ #[derive(Debug, Clone)]
31
+ pub struct SourceScan {
32
+ pub files: Vec<SourceFile>,
33
+ pub stats: ScanStats,
34
+ }
35
+
36
+ #[derive(Debug, Clone)]
37
+ pub struct DetectedFeature {
38
+ pub feature: String,
39
+ pub version: RuntimeVersion,
40
+ pub path: PathBuf,
41
+ pub line: u64,
42
+ pub column: u64,
43
+ pub count: usize,
44
+ }
45
+
46
+ pub trait Scanner {
47
+ type Output;
48
+
49
+ fn scan(&self, root: &Path, runtime: &RuntimeDb) -> Result<Self::Output>;
50
+ }
51
+
52
+ pub struct SourceDiscovery;
53
+
54
+ impl Scanner for SourceDiscovery {
55
+ type Output = SourceScan;
56
+
57
+ fn scan(&self, root: &Path, _runtime: &RuntimeDb) -> Result<Self::Output> {
58
+ let mut files = Vec::new();
59
+ let mut stats = ScanStats::default();
60
+ let mut builder = WalkBuilder::new(root);
61
+ builder.filter_entry(|entry| !is_ignored_dir(entry.path()));
62
+
63
+ for entry in builder.build() {
64
+ let entry = entry?;
65
+ let path = entry.path();
66
+ if !entry
67
+ .file_type()
68
+ .is_some_and(|file_type| file_type.is_file())
69
+ {
70
+ continue;
71
+ }
72
+ if !is_source_file(path) {
73
+ continue;
74
+ }
75
+
76
+ let text = fs::read_to_string(path)
77
+ .with_context(|| format!("failed to read {}", path.display()))?;
78
+ stats.line_count += count_lines(&text);
79
+ files.push(SourceFile {
80
+ path: path.to_path_buf(),
81
+ text,
82
+ });
83
+ }
84
+
85
+ Ok(SourceScan { files, stats })
86
+ }
87
+ }
88
+
89
+ #[derive(Clone, Copy)]
90
+ struct RuntimePattern<'a> {
91
+ runtime_index: usize,
92
+ runtime: &'a RuntimeDb,
93
+ feature: &'a Feature,
94
+ }
95
+
96
+ pub struct FffMultiRuntimeScanner;
97
+
98
+ impl FffMultiRuntimeScanner {
99
+ pub fn scan_files(
100
+ &self,
101
+ runtimes: &[&RuntimeDb],
102
+ files: &[SourceFile],
103
+ ) -> Result<Vec<Vec<DetectedFeature>>> {
104
+ let entries = combined_fast_patterns(runtimes);
105
+ if entries.is_empty() {
106
+ return Ok(vec![Vec::new(); runtimes.len()]);
107
+ }
108
+
109
+ let pattern_refs: Vec<&str> = entries.iter().map(|(pattern, _)| *pattern).collect();
110
+ let matcher = AhoCorasickBuilder::new()
111
+ .match_kind(MatchKind::LeftmostLongest)
112
+ .build(pattern_refs)
113
+ .context("failed to build matcher")?;
114
+ let searcher = SearcherBuilder::new().line_number(true).build();
115
+
116
+ let mut detections_by_runtime = vec![Vec::new(); runtimes.len()];
117
+ let mut seen_by_runtime = vec![HashSet::new(); runtimes.len()];
118
+ for (file_index, file) in files.iter().enumerate() {
119
+ let mut sink = FffMultiRuntimeSink {
120
+ entries: &entries,
121
+ matcher: &matcher,
122
+ file_index,
123
+ path: &file.path,
124
+ detections_by_runtime: &mut detections_by_runtime,
125
+ seen_by_runtime: &mut seen_by_runtime,
126
+ };
127
+ searcher
128
+ .search_slice(
129
+ FffAhoMatcher { matcher: &matcher },
130
+ file.text.as_bytes(),
131
+ &mut sink,
132
+ )
133
+ .context("failed to search source with FFF")?;
134
+ }
135
+
136
+ Ok(detections_by_runtime)
137
+ }
138
+ }
139
+
140
+ fn combined_fast_patterns<'a>(
141
+ runtimes: &[&'a RuntimeDb],
142
+ ) -> Vec<(&'a str, Vec<RuntimePattern<'a>>)> {
143
+ let mut by_pattern = HashMap::<&'a str, Vec<RuntimePattern<'a>>>::new();
144
+ for (runtime_index, runtime) in runtimes.iter().copied().enumerate() {
145
+ for pattern in runtime.fast_patterns() {
146
+ let Some(feature) = runtime.feature_for_pattern(pattern) else {
147
+ continue;
148
+ };
149
+ by_pattern
150
+ .entry(pattern.as_str())
151
+ .or_default()
152
+ .push(RuntimePattern {
153
+ runtime_index,
154
+ runtime,
155
+ feature,
156
+ });
157
+ }
158
+ }
159
+
160
+ let mut entries: Vec<_> = by_pattern.into_iter().collect();
161
+ entries.sort_by(|left, right| {
162
+ right
163
+ .0
164
+ .len()
165
+ .cmp(&left.0.len())
166
+ .then_with(|| left.0.cmp(right.0))
167
+ });
168
+ entries
169
+ }
170
+
171
+ struct FffAhoMatcher<'a> {
172
+ matcher: &'a AhoCorasick,
173
+ }
174
+
175
+ impl Matcher for FffAhoMatcher<'_> {
176
+ type Error = NoError;
177
+
178
+ fn find_at(&self, haystack: &[u8], at: usize) -> std::result::Result<Option<Match>, NoError> {
179
+ Ok(self
180
+ .matcher
181
+ .find(&haystack[at..])
182
+ .map(|matched| Match::new(at + matched.start(), at + matched.end())))
183
+ }
184
+
185
+ fn line_terminator(&self) -> Option<LineTerminator> {
186
+ Some(LineTerminator::byte(b'\n'))
187
+ }
188
+ }
189
+
190
+ struct FffMultiRuntimeSink<'a> {
191
+ entries: &'a [(&'a str, Vec<RuntimePattern<'a>>)],
192
+ matcher: &'a AhoCorasick,
193
+ file_index: usize,
194
+ path: &'a Path,
195
+ detections_by_runtime: &'a mut [Vec<DetectedFeature>],
196
+ seen_by_runtime: &'a mut [DetectionSeen],
197
+ }
198
+
199
+ impl Sink for FffMultiRuntimeSink<'_> {
200
+ type Error = io::Error;
201
+
202
+ fn matched(&mut self, _searcher: &Searcher, matched: &SinkMatch<'_>) -> io::Result<bool> {
203
+ let Ok(line) = std::str::from_utf8(matched.bytes()) else {
204
+ return Ok(true);
205
+ };
206
+ let line = line.trim_end_matches(['\r', '\n']);
207
+ let line_number = matched.line_number().unwrap_or(1);
208
+
209
+ for found in self.matcher.find_iter(line) {
210
+ for runtime_pattern in &self.entries[found.pattern()].1 {
211
+ if !is_fast_match(
212
+ runtime_pattern.runtime,
213
+ line,
214
+ found.start(),
215
+ found.end(),
216
+ self.entries[found.pattern()].0,
217
+ ) {
218
+ continue;
219
+ }
220
+ push_detection(
221
+ &mut self.detections_by_runtime[runtime_pattern.runtime_index],
222
+ &mut self.seen_by_runtime[runtime_pattern.runtime_index],
223
+ runtime_pattern.feature,
224
+ self.file_index,
225
+ self.path,
226
+ line_number,
227
+ (found.start() + 1) as u64,
228
+ );
229
+ }
230
+ }
231
+
232
+ Ok(true)
233
+ }
234
+ }
235
+
236
+ impl SourceScan {
237
+ pub fn files(&self) -> &[SourceFile] {
238
+ &self.files
239
+ }
240
+
241
+ pub fn stats(&self) -> ScanStats {
242
+ self.stats
243
+ }
244
+ }
245
+
246
+ pub type DetectionSeen = HashSet<(usize, usize, u64, u64)>;
247
+
248
+ pub fn push_detection(
249
+ detections: &mut Vec<DetectedFeature>,
250
+ seen: &mut DetectionSeen,
251
+ feature: &Feature,
252
+ file_index: usize,
253
+ path: &Path,
254
+ line: u64,
255
+ column: u64,
256
+ ) {
257
+ let key = (feature.id, file_index, line, column);
258
+ if seen.insert(key) {
259
+ detections.push(DetectedFeature {
260
+ feature: feature.name.clone(),
261
+ version: feature.version,
262
+ path: path.to_path_buf(),
263
+ line,
264
+ column,
265
+ count: 1,
266
+ });
267
+ }
268
+ }
269
+
270
+ pub fn is_source_file(path: &Path) -> bool {
271
+ matches!(
272
+ path.extension().and_then(|extension| extension.to_str()),
273
+ Some("js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts")
274
+ )
275
+ }
276
+
277
+ fn is_ignored_dir(path: &Path) -> bool {
278
+ let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
279
+ return false;
280
+ };
281
+ matches!(
282
+ name,
283
+ ".git" | "node_modules" | "dist" | "build" | "coverage" | "target"
284
+ )
285
+ }
286
+
287
+ fn count_lines(text: &str) -> usize {
288
+ if text.is_empty() {
289
+ 0
290
+ } else {
291
+ text.lines().count()
292
+ }
293
+ }
294
+
295
+ fn is_fast_match(runtime: &RuntimeDb, line: &str, start: usize, end: usize, pattern: &str) -> bool {
296
+ (runtime.is_property_pattern(pattern) && is_property_access(line, start, end))
297
+ || (runtime.is_global_or_member_pattern(pattern)
298
+ && is_safe_global_or_member_pattern(pattern)
299
+ && has_identifier_boundaries(line, start, end))
300
+ }
301
+
302
+ fn is_safe_global_or_member_pattern(pattern: &str) -> bool {
303
+ pattern.contains('.')
304
+ || pattern
305
+ .chars()
306
+ .next()
307
+ .is_some_and(|ch| ch.is_ascii_uppercase())
308
+ || matches!(
309
+ pattern,
310
+ "alert"
311
+ | "atob"
312
+ | "btoa"
313
+ | "caches"
314
+ | "cancelAnimationFrame"
315
+ | "cancelIdleCallback"
316
+ | "clearImmediate"
317
+ | "clearInterval"
318
+ | "clearTimeout"
319
+ | "console"
320
+ | "crypto"
321
+ | "document"
322
+ | "fetch"
323
+ | "global"
324
+ | "globalThis"
325
+ | "indexedDB"
326
+ | "localStorage"
327
+ | "location"
328
+ | "navigator"
329
+ | "performance"
330
+ | "process"
331
+ | "queueMicrotask"
332
+ | "reportError"
333
+ | "requestAnimationFrame"
334
+ | "requestIdleCallback"
335
+ | "self"
336
+ | "sessionStorage"
337
+ | "setImmediate"
338
+ | "setInterval"
339
+ | "setTimeout"
340
+ | "structuredClone"
341
+ | "window"
342
+ )
343
+ }
344
+
345
+ fn is_property_access(line: &str, start: usize, end: usize) -> bool {
346
+ previous_char(line, start) == Some('.')
347
+ && !next_char(line, end).is_some_and(is_js_identifier_part)
348
+ }
349
+
350
+ fn has_identifier_boundaries(line: &str, start: usize, end: usize) -> bool {
351
+ !previous_char(line, start).is_some_and(|ch| is_js_identifier_part(ch) || ch == '.')
352
+ && !next_char(line, end).is_some_and(is_js_identifier_part)
353
+ }
354
+
355
+ fn previous_char(text: &str, index: usize) -> Option<char> {
356
+ text.get(..index)?.chars().next_back()
357
+ }
358
+
359
+ fn next_char(text: &str, index: usize) -> Option<char> {
360
+ text.get(index..)?.chars().next()
361
+ }
362
+
363
+ fn is_js_identifier_part(ch: char) -> bool {
364
+ ch.is_ascii_alphanumeric() || ch == '_' || ch == '$'
365
+ }
366
+
367
+ #[cfg(test)]
368
+ mod tests {
369
+ use std::fs;
370
+
371
+ use tempfile::tempdir;
372
+
373
+ use crate::{data::node_runtime, scanner::Scanner};
374
+
375
+ use super::SourceDiscovery;
376
+
377
+ #[test]
378
+ fn source_discovery_filters_before_scanners_run() {
379
+ let dir = tempdir().unwrap();
380
+ fs::write(dir.path().join("app.ts"), "Temporal.Now.instant();\n").unwrap();
381
+ fs::write(dir.path().join("README.md"), "Temporal.Now.instant();\n").unwrap();
382
+
383
+ for ignored in [
384
+ "node_modules",
385
+ ".git",
386
+ "dist",
387
+ "build",
388
+ "coverage",
389
+ "target",
390
+ ] {
391
+ let ignored_dir = dir.path().join(ignored);
392
+ fs::create_dir(&ignored_dir).unwrap();
393
+ fs::write(ignored_dir.join("ignored.ts"), "Temporal.Now.instant();\n").unwrap();
394
+ }
395
+
396
+ let scan = SourceDiscovery
397
+ .scan(dir.path(), node_runtime().unwrap())
398
+ .unwrap();
399
+
400
+ assert_eq!(scan.stats().line_count, 1);
401
+ assert_eq!(scan.files().len(), 1);
402
+ assert_eq!(scan.files()[0].path.file_name().unwrap(), "app.ts");
403
+ }
404
+ }
package/src/version.rs ADDED
@@ -0,0 +1,101 @@
1
+ use std::{cmp::Ordering, fmt, str::FromStr};
2
+
3
+ use serde::Deserialize;
4
+
5
+ #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
6
+ pub struct RuntimeVersion {
7
+ pub major: u64,
8
+ pub minor: u64,
9
+ pub patch: u64,
10
+ }
11
+
12
+ impl RuntimeVersion {
13
+ pub const ZERO: Self = Self {
14
+ major: 0,
15
+ minor: 0,
16
+ patch: 0,
17
+ };
18
+
19
+ pub fn is_zero(self) -> bool {
20
+ self == Self::ZERO
21
+ }
22
+ }
23
+
24
+ impl fmt::Display for RuntimeVersion {
25
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26
+ write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
27
+ }
28
+ }
29
+
30
+ impl FromStr for RuntimeVersion {
31
+ type Err = anyhow::Error;
32
+
33
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
34
+ let value = value.trim().trim_start_matches('v');
35
+ let mut parts = value.split('.');
36
+ let major = parts
37
+ .next()
38
+ .unwrap_or("0")
39
+ .parse()
40
+ .map_err(|error| anyhow::anyhow!("invalid major version `{value}`: {error}"))?;
41
+ let minor = parts
42
+ .next()
43
+ .unwrap_or("0")
44
+ .parse()
45
+ .map_err(|error| anyhow::anyhow!("invalid minor version `{value}`: {error}"))?;
46
+ let patch = parts
47
+ .next()
48
+ .unwrap_or("0")
49
+ .parse()
50
+ .map_err(|error| anyhow::anyhow!("invalid patch version `{value}`: {error}"))?;
51
+
52
+ Ok(Self {
53
+ major,
54
+ minor,
55
+ patch,
56
+ })
57
+ }
58
+ }
59
+
60
+ impl<'de> Deserialize<'de> for RuntimeVersion {
61
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
62
+ where
63
+ D: serde::Deserializer<'de>,
64
+ {
65
+ let value = String::deserialize(deserializer)?;
66
+ value.parse().map_err(serde::de::Error::custom)
67
+ }
68
+ }
69
+
70
+ impl PartialOrd for RuntimeVersion {
71
+ fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
72
+ Some(self.cmp(other))
73
+ }
74
+ }
75
+
76
+ impl Ord for RuntimeVersion {
77
+ fn cmp(&self, other: &Self) -> Ordering {
78
+ (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
79
+ }
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::RuntimeVersion;
85
+
86
+ #[test]
87
+ fn parses_short_versions() {
88
+ assert_eq!(
89
+ "24".parse::<RuntimeVersion>().unwrap(),
90
+ RuntimeVersion {
91
+ major: 24,
92
+ minor: 0,
93
+ patch: 0
94
+ }
95
+ );
96
+ assert_eq!(
97
+ "v20.5.1".parse::<RuntimeVersion>().unwrap().to_string(),
98
+ "20.5.1"
99
+ );
100
+ }
101
+ }