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.
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ const exe = process.platform === "win32" ? "runtime-checker.exe" : "runtime-checker";
9
+ const native = join(root, "npm", "bin", "native", exe);
10
+
11
+ if (!existsSync(native)) {
12
+ console.error("runtime-checker native binary is missing. Try reinstalling the package.");
13
+ process.exit(1);
14
+ }
15
+
16
+ const child = spawnSync(native, process.argv.slice(2), { stdio: "inherit" });
17
+
18
+ if (child.error) {
19
+ console.error(child.error.message);
20
+ process.exit(1);
21
+ }
22
+
23
+ process.exit(child.status ?? 0);
@@ -0,0 +1,29 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const exe = process.platform === "win32" ? "runtime-checker.exe" : "runtime-checker";
8
+ const native = join(root, "npm", "bin", "native", exe);
9
+
10
+ if (existsSync(native)) {
11
+ process.exit(0);
12
+ }
13
+
14
+ const cargo = spawnSync("cargo", ["build", "--release"], {
15
+ cwd: root,
16
+ stdio: "inherit",
17
+ });
18
+
19
+ if (cargo.status !== 0) {
20
+ console.error("Failed to build runtime-checker. Install Rust from https://rustup.rs/ and retry.");
21
+ process.exit(cargo.status ?? 1);
22
+ }
23
+
24
+ const prepare = spawnSync(process.execPath, ["./npm/prepare-bin.js"], {
25
+ cwd: root,
26
+ stdio: "inherit",
27
+ });
28
+
29
+ process.exit(prepare.status ?? 0);
@@ -0,0 +1,11 @@
1
+ import { copyFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
6
+ const exe = process.platform === "win32" ? "runtime-checker.exe" : "runtime-checker";
7
+ const source = join(root, "target", "release", exe);
8
+ const target = join(root, "npm", "bin", "native", exe);
9
+
10
+ mkdirSync(dirname(target), { recursive: true });
11
+ copyFileSync(source, target);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "runtime-checker",
3
+ "version": "1.0.0",
4
+ "description": "Detect minimum runtime versions required by JavaScript and TypeScript codebases.",
5
+ "license": "BSD-3-Clause",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/usebarekey/runtime-checker.git"
10
+ },
11
+ "homepage": "https://github.com/usebarekey/runtime-checker",
12
+ "bugs": {
13
+ "url": "https://github.com/usebarekey/runtime-checker/issues"
14
+ },
15
+ "bin": {
16
+ "runtime-checker": "./npm/bin/runtime-checker.js"
17
+ },
18
+ "files": [
19
+ "Cargo.lock",
20
+ "Cargo.toml",
21
+ "LICENSE",
22
+ "README.md",
23
+ "data",
24
+ "npm",
25
+ "src"
26
+ ],
27
+ "scripts": {
28
+ "build": "cargo build --release",
29
+ "check": "cargo clippy --all-targets -- -D warnings",
30
+ "test": "cargo test",
31
+ "postinstall": "node ./npm/postinstall.js",
32
+ "prepack": "cargo build --release && node ./npm/prepare-bin.js",
33
+ "publish:npm": "npm publish --access public --provenance",
34
+ "publish:jsr": "npx --yes jsr publish"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }
@@ -0,0 +1,405 @@
1
+ use std::{
2
+ collections::{HashMap, HashSet},
3
+ path::Path,
4
+ };
5
+
6
+ use anyhow::{Context, Result};
7
+ use oxc_allocator::Allocator;
8
+ use oxc_ast::ast::{
9
+ Argument, BindingIdentifier, BindingPattern, BlockStatement, CallExpression, Expression,
10
+ Function, IdentifierReference, ImportDeclaration, ImportDeclarationSpecifier, ModuleExportName,
11
+ PropertyKey, StaticMemberExpression, VariableDeclarator,
12
+ };
13
+ use oxc_ast_visit::{Visit, walk};
14
+ use oxc_parser::Parser;
15
+ use oxc_semantic::{Semantic, SemanticBuilder};
16
+ use oxc_span::{SourceType, Span};
17
+ use oxc_syntax::scope::ScopeFlags;
18
+
19
+ use crate::{
20
+ data::{Feature, RuntimeDb},
21
+ scanner::{DetectedFeature, DetectionSeen, SourceFile, push_detection},
22
+ };
23
+
24
+ pub fn analyze_files_for_runtimes(
25
+ root: &Path,
26
+ files: &[SourceFile],
27
+ runtimes: &[&RuntimeDb],
28
+ ) -> Result<Vec<Vec<DetectedFeature>>> {
29
+ let mut detections_by_runtime = vec![Vec::new(); runtimes.len()];
30
+ for file in files {
31
+ analyze_file_for_runtimes(file, runtimes, &mut detections_by_runtime)
32
+ .with_context(|| format!("failed to analyze {}", file.path.display()))?;
33
+ }
34
+ let _ = root;
35
+ Ok(detections_by_runtime)
36
+ }
37
+
38
+ fn analyze_file_for_runtimes(
39
+ file: &SourceFile,
40
+ runtimes: &[&RuntimeDb],
41
+ detections_by_runtime: &mut [Vec<DetectedFeature>],
42
+ ) -> Result<()> {
43
+ let allocator = Allocator::default();
44
+ let source_type =
45
+ SourceType::from_path(&file.path).unwrap_or_else(|_| SourceType::unambiguous());
46
+ let parsed = Parser::new(&allocator, &file.text, source_type).parse();
47
+ if !parsed.errors.is_empty() {
48
+ return Ok(());
49
+ }
50
+
51
+ let semantic = SemanticBuilder::new_compiler().build(&parsed.program);
52
+ if !semantic.errors.is_empty() {
53
+ return Ok(());
54
+ }
55
+
56
+ let line_index = LineIndex::new(&file.text);
57
+ for (runtime, detections) in runtimes.iter().zip(detections_by_runtime.iter_mut()) {
58
+ let mut visitor = AstVisitor {
59
+ runtime,
60
+ semantic: &semantic.semantic,
61
+ line_index: &line_index,
62
+ path: file.path.as_path(),
63
+ namespace_imports: HashMap::new(),
64
+ named_imports: HashMap::new(),
65
+ local_scopes: vec![HashSet::new()],
66
+ detections: Vec::new(),
67
+ seen: HashSet::new(),
68
+ };
69
+ visitor.visit_program(&parsed.program);
70
+ detections.append(&mut visitor.detections);
71
+ }
72
+ Ok(())
73
+ }
74
+
75
+ struct AstVisitor<'a, 'db> {
76
+ runtime: &'db RuntimeDb,
77
+ semantic: &'a Semantic<'a>,
78
+ line_index: &'a LineIndex,
79
+ path: &'a Path,
80
+ namespace_imports: HashMap<String, String>,
81
+ named_imports: HashMap<String, String>,
82
+ local_scopes: Vec<HashSet<String>>,
83
+ detections: Vec<DetectedFeature>,
84
+ seen: DetectionSeen,
85
+ }
86
+
87
+ impl<'a> Visit<'a> for AstVisitor<'a, '_> {
88
+ fn visit_function(&mut self, function: &Function<'a>, flags: ScopeFlags) {
89
+ self.local_scopes.push(HashSet::new());
90
+ walk::walk_function(self, function, flags);
91
+ self.local_scopes.pop();
92
+ }
93
+
94
+ fn visit_block_statement(&mut self, block: &BlockStatement<'a>) {
95
+ self.local_scopes.push(HashSet::new());
96
+ walk::walk_block_statement(self, block);
97
+ self.local_scopes.pop();
98
+ }
99
+
100
+ fn visit_binding_identifier(&mut self, ident: &BindingIdentifier<'a>) {
101
+ if let Some(scope) = self.local_scopes.last_mut() {
102
+ scope.insert(ident.name.as_str().to_owned());
103
+ }
104
+ walk::walk_binding_identifier(self, ident);
105
+ }
106
+
107
+ fn visit_import_declaration(&mut self, declaration: &ImportDeclaration<'a>) {
108
+ self.record_import(declaration);
109
+ walk::walk_import_declaration(self, declaration);
110
+ }
111
+
112
+ fn visit_variable_declarator(&mut self, declarator: &VariableDeclarator<'a>) {
113
+ self.record_require(declarator);
114
+ walk::walk_variable_declarator(self, declarator);
115
+ }
116
+
117
+ fn visit_identifier_reference(&mut self, ident: &IdentifierReference<'a>) {
118
+ let name = ident.name.as_str();
119
+ if !self.is_shadowed(name)
120
+ && self.semantic.is_reference_to_global_variable(ident)
121
+ && let Some(feature) = self.runtime.match_global(name)
122
+ {
123
+ self.emit(feature, ident.span);
124
+ }
125
+
126
+ if let Some(feature_name) = self.named_imports.get(name)
127
+ && let Some(feature) = self.runtime.match_member_chain(feature_name)
128
+ {
129
+ self.emit(feature, ident.span);
130
+ }
131
+
132
+ walk::walk_identifier_reference(self, ident);
133
+ }
134
+
135
+ fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
136
+ let property = member.property.name.as_str();
137
+ if let Some(feature) = self.runtime.match_property(property) {
138
+ self.emit(feature, member.property.span);
139
+ }
140
+
141
+ if let Some(chain) = member_chain(member) {
142
+ let chain = self.canonicalize_chain(chain);
143
+ if let Some(feature) = self.runtime.match_member_chain(&chain) {
144
+ self.emit(feature, member.span);
145
+ }
146
+ }
147
+
148
+ walk::walk_static_member_expression(self, member);
149
+ }
150
+ }
151
+
152
+ impl AstVisitor<'_, '_> {
153
+ fn record_import(&mut self, declaration: &ImportDeclaration<'_>) {
154
+ let Some(module) = normalize_module_name(declaration.source.value.as_str()) else {
155
+ return;
156
+ };
157
+ let Some(specifiers) = &declaration.specifiers else {
158
+ return;
159
+ };
160
+
161
+ for specifier in specifiers {
162
+ match specifier {
163
+ ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
164
+ self.namespace_imports
165
+ .insert(specifier.local.name.as_str().to_owned(), module.clone());
166
+ }
167
+ ImportDeclarationSpecifier::ImportSpecifier(specifier) => {
168
+ let Some(imported) = module_export_name(&specifier.imported) else {
169
+ continue;
170
+ };
171
+ let local = specifier.local.name.as_str().to_owned();
172
+ self.named_imports
173
+ .insert(local, format!("{module}.{imported}"));
174
+ }
175
+ ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => {
176
+ self.namespace_imports
177
+ .insert(specifier.local.name.as_str().to_owned(), module.clone());
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ fn record_require(&mut self, declarator: &VariableDeclarator<'_>) {
184
+ let Some(init) = declarator.init.as_ref() else {
185
+ return;
186
+ };
187
+ let Some(module) = require_module(init) else {
188
+ return;
189
+ };
190
+
191
+ match &declarator.id {
192
+ BindingPattern::BindingIdentifier(binding) => {
193
+ self.namespace_imports
194
+ .insert(binding.name.as_str().to_owned(), module);
195
+ }
196
+ BindingPattern::ObjectPattern(pattern) => {
197
+ for property in &pattern.properties {
198
+ let Some(imported) = property_key_name(&property.key) else {
199
+ continue;
200
+ };
201
+ if let BindingPattern::BindingIdentifier(local) = &property.value {
202
+ self.named_imports.insert(
203
+ local.name.as_str().to_owned(),
204
+ format!("{module}.{imported}"),
205
+ );
206
+ }
207
+ }
208
+ }
209
+ _ => {}
210
+ }
211
+ }
212
+
213
+ fn canonicalize_chain(&self, parts: Vec<&str>) -> String {
214
+ let Some((root, tail)) = parts.split_first() else {
215
+ return String::new();
216
+ };
217
+ let root = self
218
+ .namespace_imports
219
+ .get(*root)
220
+ .map(String::as_str)
221
+ .unwrap_or(*root);
222
+ let capacity = root.len() + tail.iter().map(|part| part.len() + 1).sum::<usize>();
223
+ let mut chain = String::with_capacity(capacity);
224
+ chain.push_str(root);
225
+ for part in tail {
226
+ chain.push('.');
227
+ chain.push_str(part);
228
+ }
229
+ chain
230
+ }
231
+
232
+ fn is_shadowed(&self, name: &str) -> bool {
233
+ self.local_scopes
234
+ .iter()
235
+ .rev()
236
+ .any(|scope| scope.contains(name))
237
+ }
238
+
239
+ fn emit(&mut self, feature: &Feature, span: Span) {
240
+ let (line, column) = self.line_index.line_column(span.start as usize);
241
+ push_detection(
242
+ &mut self.detections,
243
+ &mut self.seen,
244
+ feature,
245
+ 0,
246
+ self.path,
247
+ line,
248
+ column,
249
+ );
250
+ }
251
+ }
252
+
253
+ fn member_chain<'a>(member: &'a StaticMemberExpression<'a>) -> Option<Vec<&'a str>> {
254
+ let mut parts = expression_chain(&member.object)?;
255
+ parts.push(member.property.name.as_str());
256
+ Some(parts)
257
+ }
258
+
259
+ fn expression_chain<'a>(expression: &'a Expression<'a>) -> Option<Vec<&'a str>> {
260
+ match expression {
261
+ Expression::Identifier(ident) => Some(vec![ident.name.as_str()]),
262
+ Expression::StaticMemberExpression(member) => member_chain(member),
263
+ Expression::ParenthesizedExpression(expression) => expression_chain(&expression.expression),
264
+ Expression::TSAsExpression(expression) => expression_chain(&expression.expression),
265
+ Expression::TSSatisfiesExpression(expression) => expression_chain(&expression.expression),
266
+ Expression::TSNonNullExpression(expression) => expression_chain(&expression.expression),
267
+ Expression::TSTypeAssertion(expression) => expression_chain(&expression.expression),
268
+ _ => None,
269
+ }
270
+ }
271
+
272
+ fn require_module(expression: &Expression<'_>) -> Option<String> {
273
+ let Expression::CallExpression(call) = expression else {
274
+ return None;
275
+ };
276
+ if !is_require_call(call) {
277
+ return None;
278
+ }
279
+ let first = call.arguments.first()?;
280
+ match first {
281
+ Argument::StringLiteral(literal) => normalize_module_name(literal.value.as_str()),
282
+ _ => None,
283
+ }
284
+ }
285
+
286
+ fn is_require_call(call: &CallExpression<'_>) -> bool {
287
+ matches!(&call.callee, Expression::Identifier(ident) if ident.name.as_str() == "require")
288
+ }
289
+
290
+ fn normalize_module_name(module: &str) -> Option<String> {
291
+ let module = module.strip_prefix("node:").unwrap_or(module);
292
+ match module {
293
+ "fs/promises" => Some("fsPromises".to_owned()),
294
+ "timers/promises" => Some("timersPromises".to_owned()),
295
+ value
296
+ if value
297
+ .chars()
298
+ .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '/') =>
299
+ {
300
+ Some(value.replace('/', "."))
301
+ }
302
+ _ => None,
303
+ }
304
+ }
305
+
306
+ fn module_export_name(name: &ModuleExportName<'_>) -> Option<String> {
307
+ match name {
308
+ ModuleExportName::IdentifierName(name) => Some(name.name.as_str().to_owned()),
309
+ ModuleExportName::IdentifierReference(name) => Some(name.name.as_str().to_owned()),
310
+ ModuleExportName::StringLiteral(name) => Some(name.value.as_str().to_owned()),
311
+ }
312
+ }
313
+
314
+ fn property_key_name(key: &PropertyKey<'_>) -> Option<String> {
315
+ match key {
316
+ PropertyKey::StaticIdentifier(name) => Some(name.name.as_str().to_owned()),
317
+ PropertyKey::StringLiteral(name) => Some(name.value.as_str().to_owned()),
318
+ _ => None,
319
+ }
320
+ }
321
+
322
+ struct LineIndex {
323
+ starts: Vec<usize>,
324
+ }
325
+
326
+ impl LineIndex {
327
+ fn new(text: &str) -> Self {
328
+ let mut starts = vec![0];
329
+ for (index, byte) in text.bytes().enumerate() {
330
+ if byte == b'\n' {
331
+ starts.push(index + 1);
332
+ }
333
+ }
334
+ Self { starts }
335
+ }
336
+
337
+ fn line_column(&self, offset: usize) -> (u64, u64) {
338
+ let line_index = self
339
+ .starts
340
+ .partition_point(|start| *start <= offset)
341
+ .saturating_sub(1);
342
+ let line_start = self.starts[line_index];
343
+ ((line_index + 1) as u64, (offset - line_start + 1) as u64)
344
+ }
345
+ }
346
+
347
+ #[cfg(test)]
348
+ mod tests {
349
+ use std::{fs, path::Path};
350
+
351
+ use tempfile::tempdir;
352
+
353
+ use crate::{analyzer::analyze_files_for_runtimes, data::node_runtime, scanner::SourceFile};
354
+
355
+ fn analyze(path: &Path, text: &str) -> Vec<String> {
356
+ let runtime = node_runtime().unwrap();
357
+ let files = vec![SourceFile {
358
+ path: path.to_path_buf(),
359
+ text: text.to_owned(),
360
+ }];
361
+ analyze_files_for_runtimes(path.parent().unwrap(), &files, &[runtime])
362
+ .unwrap()
363
+ .pop()
364
+ .unwrap()
365
+ .into_iter()
366
+ .map(|detection| detection.feature)
367
+ .collect()
368
+ }
369
+
370
+ #[test]
371
+ fn detects_global_temporal_but_not_local_binding() {
372
+ let dir = tempdir().unwrap();
373
+ let path = dir.path().join("date.ts");
374
+ fs::write(
375
+ &path,
376
+ "Temporal.Now.instant();\nfunction shadow() { const Temporal = local;\nTemporal.Now.instant();\n}",
377
+ )
378
+ .unwrap();
379
+
380
+ let detections = analyze(&path, &fs::read_to_string(&path).unwrap());
381
+ assert!(detections.iter().any(|feature| feature == "Temporal"));
382
+ assert_eq!(
383
+ detections
384
+ .iter()
385
+ .filter(|feature| feature.as_str() == "Temporal")
386
+ .count(),
387
+ 1
388
+ );
389
+ }
390
+
391
+ #[test]
392
+ fn detects_imported_fs_members() {
393
+ let dir = tempdir().unwrap();
394
+ let path = dir.path().join("fs.ts");
395
+ fs::write(
396
+ &path,
397
+ "import * as fs from 'node:fs';\nimport { cp } from 'node:fs/promises';\nfs.cp('a', 'b', () => {});\ncp('a', 'b');",
398
+ )
399
+ .unwrap();
400
+
401
+ let detections = analyze(&path, &fs::read_to_string(&path).unwrap());
402
+ assert!(detections.iter().any(|feature| feature == "fs.cp"));
403
+ assert!(detections.iter().any(|feature| feature == "fsPromises.cp"));
404
+ }
405
+ }