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,517 @@
1
+ use std::{
2
+ collections::{BTreeMap, BTreeSet},
3
+ fs,
4
+ io::Read,
5
+ path::{Path, PathBuf},
6
+ str::FromStr,
7
+ };
8
+
9
+ use anyhow::{Context, Result};
10
+ use clap::Parser;
11
+ use serde_json::Value;
12
+
13
+ const DEFAULT_BCD_VERSION: &str = include_str!("../../data/mdn-bcd.version");
14
+ const DEFAULT_OUTPUT_DIR: &str = "data/mdn";
15
+
16
+ #[derive(Debug, Parser)]
17
+ #[command(name = "generate-mdn-data")]
18
+ #[command(about = "Generate runtime RON data from MDN browser-compat-data")]
19
+ struct Args {
20
+ /// Read MDN BCD data.json from disk instead of downloading it.
21
+ #[arg(long)]
22
+ input: Option<PathBuf>,
23
+
24
+ /// MDN @mdn/browser-compat-data version to download.
25
+ #[arg(long)]
26
+ bcd_version: Option<String>,
27
+
28
+ /// Directory where runtime RON files are written.
29
+ #[arg(long, default_value = DEFAULT_OUTPUT_DIR)]
30
+ output_dir: PathBuf,
31
+
32
+ /// Runtime ids to generate: nodejs, deno, bun, safari, chrome, firefox.
33
+ #[arg(
34
+ long,
35
+ value_delimiter = ',',
36
+ default_value = "nodejs,deno,bun,safari,chrome,firefox"
37
+ )]
38
+ runtimes: Vec<String>,
39
+ }
40
+
41
+ fn main() -> Result<()> {
42
+ let args = Args::parse();
43
+ run(args)
44
+ }
45
+
46
+ fn run(args: Args) -> Result<()> {
47
+ let bcd_version = args
48
+ .bcd_version
49
+ .unwrap_or_else(|| DEFAULT_BCD_VERSION.trim().to_string());
50
+ let input = load_bcd_json(args.input.as_deref(), &bcd_version)?;
51
+ let bcd: Value = serde_json::from_str(&input).context("failed to parse MDN BCD data.json")?;
52
+
53
+ if let Some(actual) = bcd
54
+ .pointer("/__meta/version")
55
+ .and_then(Value::as_str)
56
+ .filter(|actual| *actual != bcd_version)
57
+ {
58
+ anyhow::bail!("expected BCD {bcd_version}, got {actual}");
59
+ }
60
+
61
+ fs::create_dir_all(&args.output_dir)
62
+ .with_context(|| format!("failed to create {}", args.output_dir.display()))?;
63
+
64
+ for runtime_id in args.runtimes {
65
+ let target = RuntimeTarget::from_bcd_id(&runtime_id)?;
66
+ let features = extract_features(&bcd, target.bcd_id)?;
67
+ let output =
68
+ render_runtime_ron(target.runtime_name, target.bcd_id, &bcd_version, &features);
69
+ let path = args.output_dir.join(format!("{}.ron", target.runtime_name));
70
+ fs::write(&path, output).with_context(|| format!("failed to write {}", path.display()))?;
71
+ println!(
72
+ "generated {} features for {} from MDN BCD {} -> {}",
73
+ features.len(),
74
+ target.runtime_name,
75
+ bcd_version,
76
+ path.display()
77
+ );
78
+ }
79
+
80
+ Ok(())
81
+ }
82
+
83
+ fn load_bcd_json(input: Option<&Path>, version: &str) -> Result<String> {
84
+ if let Some(input) = input {
85
+ return fs::read_to_string(input)
86
+ .with_context(|| format!("failed to read {}", input.display()));
87
+ }
88
+
89
+ let url = format!("https://unpkg.com/@mdn/browser-compat-data@{version}/data.json");
90
+ let response = ureq::get(&url)
91
+ .call()
92
+ .with_context(|| format!("failed to download {url}"))?;
93
+ let mut text = String::new();
94
+ response
95
+ .into_reader()
96
+ .read_to_string(&mut text)
97
+ .context("failed to read downloaded MDN BCD data.json")?;
98
+ Ok(text)
99
+ }
100
+
101
+ #[derive(Debug, Clone, Copy)]
102
+ struct RuntimeTarget {
103
+ bcd_id: &'static str,
104
+ runtime_name: &'static str,
105
+ }
106
+
107
+ impl RuntimeTarget {
108
+ fn from_bcd_id(id: &str) -> Result<Self> {
109
+ match id {
110
+ "nodejs" | "node" => Ok(Self {
111
+ bcd_id: "nodejs",
112
+ runtime_name: "node",
113
+ }),
114
+ "deno" => Ok(Self {
115
+ bcd_id: "deno",
116
+ runtime_name: "deno",
117
+ }),
118
+ "bun" => Ok(Self {
119
+ bcd_id: "bun",
120
+ runtime_name: "bun",
121
+ }),
122
+ "safari" => Ok(Self {
123
+ bcd_id: "safari",
124
+ runtime_name: "safari",
125
+ }),
126
+ "chrome" => Ok(Self {
127
+ bcd_id: "chrome",
128
+ runtime_name: "chrome",
129
+ }),
130
+ "firefox" => Ok(Self {
131
+ bcd_id: "firefox",
132
+ runtime_name: "firefox",
133
+ }),
134
+ other => anyhow::bail!("unsupported BCD runtime id `{other}`"),
135
+ }
136
+ }
137
+ }
138
+
139
+ #[derive(Debug, Clone, PartialEq, Eq)]
140
+ struct GeneratedFeature {
141
+ name: String,
142
+ version: Version,
143
+ detect: Vec<GeneratedDetectRule>,
144
+ }
145
+
146
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
147
+ struct Version {
148
+ major: u64,
149
+ minor: u64,
150
+ patch: u64,
151
+ }
152
+
153
+ impl FromStr for Version {
154
+ type Err = anyhow::Error;
155
+
156
+ fn from_str(value: &str) -> Result<Self> {
157
+ let value = value.trim().trim_start_matches('v');
158
+ if !value
159
+ .chars()
160
+ .next()
161
+ .is_some_and(|first| first.is_ascii_digit())
162
+ {
163
+ anyhow::bail!("version is not numeric: {value}");
164
+ }
165
+
166
+ let mut parts = value.split('.');
167
+ let major = parse_version_part(parts.next().unwrap_or("0"), value, "major")?;
168
+ let minor = parse_version_part(parts.next().unwrap_or("0"), value, "minor")?;
169
+ let patch = parse_version_part(parts.next().unwrap_or("0"), value, "patch")?;
170
+ Ok(Self {
171
+ major,
172
+ minor,
173
+ patch,
174
+ })
175
+ }
176
+ }
177
+
178
+ impl std::fmt::Display for Version {
179
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180
+ write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch)
181
+ }
182
+ }
183
+
184
+ fn parse_version_part(part: &str, full: &str, label: &str) -> Result<u64> {
185
+ if part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()) {
186
+ anyhow::bail!("invalid {label} version in `{full}`");
187
+ }
188
+ part.parse()
189
+ .with_context(|| format!("invalid {label} version in `{full}`"))
190
+ }
191
+
192
+ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
193
+ enum GeneratedDetectRule {
194
+ Global(String),
195
+ MemberChain(String),
196
+ Property(String),
197
+ }
198
+
199
+ fn extract_features(bcd: &Value, runtime_id: &str) -> Result<Vec<GeneratedFeature>> {
200
+ let mut features = BTreeMap::<String, GeneratedFeature>::new();
201
+
202
+ if let Some(api) = bcd.get("api") {
203
+ walk_compat_tree(
204
+ api,
205
+ &mut Vec::new(),
206
+ SourceKind::Api,
207
+ runtime_id,
208
+ &mut features,
209
+ );
210
+ }
211
+
212
+ if let Some(builtins) = bcd.pointer("/javascript/builtins") {
213
+ walk_compat_tree(
214
+ builtins,
215
+ &mut Vec::new(),
216
+ SourceKind::JavascriptBuiltin,
217
+ runtime_id,
218
+ &mut features,
219
+ );
220
+ }
221
+
222
+ Ok(features.into_values().collect())
223
+ }
224
+
225
+ #[derive(Debug, Clone, Copy)]
226
+ enum SourceKind {
227
+ Api,
228
+ JavascriptBuiltin,
229
+ }
230
+
231
+ fn walk_compat_tree(
232
+ value: &Value,
233
+ path: &mut Vec<String>,
234
+ source: SourceKind,
235
+ runtime_id: &str,
236
+ features: &mut BTreeMap<String, GeneratedFeature>,
237
+ ) {
238
+ let Some(object) = value.as_object() else {
239
+ return;
240
+ };
241
+
242
+ if let Some(compat) = object.get("__compat")
243
+ && let Some(version) = support_version(compat, runtime_id)
244
+ {
245
+ let name = feature_name(path);
246
+ if !name.is_empty() && is_runtime_surface_path(path) {
247
+ let detect = detect_rules(source, path);
248
+ if !detect.is_empty() {
249
+ upsert_feature(
250
+ features,
251
+ GeneratedFeature {
252
+ name,
253
+ version,
254
+ detect,
255
+ },
256
+ );
257
+ }
258
+ }
259
+ }
260
+
261
+ for (key, child) in object {
262
+ if key == "__compat" {
263
+ continue;
264
+ }
265
+ path.push(key.clone());
266
+ walk_compat_tree(child, path, source, runtime_id, features);
267
+ path.pop();
268
+ }
269
+ }
270
+
271
+ fn upsert_feature(features: &mut BTreeMap<String, GeneratedFeature>, feature: GeneratedFeature) {
272
+ match features.get(&feature.name) {
273
+ Some(existing) if existing.version <= feature.version => {}
274
+ _ => {
275
+ features.insert(feature.name.clone(), feature);
276
+ }
277
+ }
278
+ }
279
+
280
+ fn support_version(compat: &Value, runtime_id: &str) -> Option<Version> {
281
+ let support = compat.get("support")?.get(runtime_id)?;
282
+ let statements: Vec<&Value> = match support {
283
+ Value::Array(values) => values.iter().collect(),
284
+ value => vec![value],
285
+ };
286
+
287
+ statements
288
+ .into_iter()
289
+ .filter(|statement| !has_runtime_flags(statement))
290
+ .filter(|statement| !has_version_removed(statement))
291
+ .filter_map(|statement| statement.get("version_added")?.as_str())
292
+ .filter_map(|version| version.parse::<Version>().ok())
293
+ .min()
294
+ }
295
+
296
+ fn has_runtime_flags(statement: &Value) -> bool {
297
+ statement
298
+ .get("flags")
299
+ .and_then(Value::as_array)
300
+ .is_some_and(|flags| !flags.is_empty())
301
+ }
302
+
303
+ fn has_version_removed(statement: &Value) -> bool {
304
+ statement
305
+ .get("version_removed")
306
+ .is_some_and(|version| !version.is_null())
307
+ }
308
+
309
+ fn feature_name(path: &[String]) -> String {
310
+ path.join(".")
311
+ }
312
+
313
+ fn is_runtime_surface_path(path: &[String]) -> bool {
314
+ if path.is_empty() {
315
+ return false;
316
+ }
317
+
318
+ if path.len() > 1 && path.first() == path.last() {
319
+ return false;
320
+ }
321
+
322
+ path.iter()
323
+ .all(|segment| is_runtime_surface_segment(segment))
324
+ }
325
+
326
+ fn is_runtime_surface_segment(segment: &str) -> bool {
327
+ if segment.is_empty() || segment.starts_with("@@") || segment.contains('-') {
328
+ return false;
329
+ }
330
+
331
+ if segment.contains('_') {
332
+ return segment
333
+ .chars()
334
+ .all(|ch| ch == '_' || ch.is_ascii_digit() || ch.is_ascii_uppercase());
335
+ }
336
+
337
+ segment
338
+ .chars()
339
+ .all(|ch| ch == '$' || ch.is_ascii_alphanumeric())
340
+ }
341
+
342
+ fn detect_rules(source: SourceKind, path: &[String]) -> Vec<GeneratedDetectRule> {
343
+ if path.is_empty() {
344
+ return Vec::new();
345
+ }
346
+
347
+ let name = feature_name(path);
348
+ let mut rules = BTreeSet::new();
349
+
350
+ if path.len() == 1 {
351
+ rules.insert(GeneratedDetectRule::Global(name.clone()));
352
+ }
353
+
354
+ rules.insert(GeneratedDetectRule::MemberChain(name));
355
+
356
+ if matches!(source, SourceKind::JavascriptBuiltin)
357
+ && path.len() >= 2
358
+ && let Some(property) = path
359
+ .last()
360
+ .filter(|property| is_detectable_property(property))
361
+ {
362
+ rules.insert(GeneratedDetectRule::Property(property.clone()));
363
+ }
364
+
365
+ rules.into_iter().collect()
366
+ }
367
+
368
+ fn is_detectable_property(property: &str) -> bool {
369
+ !property.starts_with("@@")
370
+ && !property.starts_with("__")
371
+ && property
372
+ .chars()
373
+ .next()
374
+ .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
375
+ }
376
+
377
+ fn render_runtime_ron(
378
+ runtime_name: &str,
379
+ bcd_id: &str,
380
+ bcd_version: &str,
381
+ features: &[GeneratedFeature],
382
+ ) -> String {
383
+ let mut output = String::new();
384
+ output.push_str(&format!(
385
+ "//! Generated from MDN @mdn/browser-compat-data {bcd_version} runtime `{bcd_id}`.\n"
386
+ ));
387
+ output.push_str(
388
+ "//! Regenerate with: cargo run --bin generate-mdn-data -- --output-dir data/mdn\n",
389
+ );
390
+ output.push_str("(\n");
391
+ output.push_str(" schema: 1,\n");
392
+ output.push_str(&format!(" runtime: {},\n", quote(runtime_name)));
393
+ output.push_str(" features: [\n");
394
+
395
+ for feature in features {
396
+ output.push_str(" (name: ");
397
+ output.push_str(&quote(&feature.name));
398
+ output.push_str(", version: ");
399
+ output.push_str(&quote(&feature.version.to_string()));
400
+ output.push_str(", detect: [");
401
+ for (index, rule) in feature.detect.iter().enumerate() {
402
+ if index > 0 {
403
+ output.push_str(", ");
404
+ }
405
+ output.push_str(&render_detect_rule(rule));
406
+ }
407
+ output.push_str("]),\n");
408
+ }
409
+
410
+ output.push_str(" ],\n");
411
+ output.push_str(")\n");
412
+ output
413
+ }
414
+
415
+ fn render_detect_rule(rule: &GeneratedDetectRule) -> String {
416
+ match rule {
417
+ GeneratedDetectRule::Global(name) => format!("Global({})", quote(name)),
418
+ GeneratedDetectRule::MemberChain(name) => format!("MemberChain({})", quote(name)),
419
+ GeneratedDetectRule::Property(name) => format!("Property({})", quote(name)),
420
+ }
421
+ }
422
+
423
+ fn quote(value: &str) -> String {
424
+ serde_json::to_string(value).expect("string escaping cannot fail")
425
+ }
426
+
427
+ #[cfg(test)]
428
+ mod tests {
429
+ use super::*;
430
+
431
+ #[test]
432
+ fn extracts_runtime_versions_from_bcd_shape() {
433
+ let bcd = serde_json::json!({
434
+ "__meta": { "version": "fixture" },
435
+ "api": {
436
+ "fetch": {
437
+ "__compat": {
438
+ "support": {
439
+ "nodejs": { "version_added": "18.0.0" },
440
+ "deno": { "version_added": "1.0" }
441
+ }
442
+ }
443
+ }
444
+ },
445
+ "javascript": {
446
+ "builtins": {
447
+ "Array": {
448
+ "toSorted": {
449
+ "__compat": {
450
+ "support": {
451
+ "nodejs": { "version_added": "20.0.0" }
452
+ }
453
+ }
454
+ },
455
+ "options_parameter": {
456
+ "__compat": {
457
+ "support": {
458
+ "nodejs": { "version_added": "21.0.0" }
459
+ }
460
+ }
461
+ },
462
+ "flagged": {
463
+ "__compat": {
464
+ "support": {
465
+ "nodejs": {
466
+ "version_added": "21.0.0",
467
+ "flags": [{ "name": "flag" }]
468
+ }
469
+ }
470
+ }
471
+ }
472
+ },
473
+ "Number": {
474
+ "MAX_SAFE_INTEGER": {
475
+ "__compat": {
476
+ "support": {
477
+ "nodejs": { "version_added": "0.12.0" }
478
+ }
479
+ }
480
+ }
481
+ },
482
+ "Temporal": {
483
+ "__compat": {
484
+ "support": {
485
+ "nodejs": { "version_added": "26.0.0" }
486
+ }
487
+ }
488
+ }
489
+ }
490
+ }
491
+ });
492
+
493
+ let features = extract_features(&bcd, "nodejs").unwrap();
494
+ let names: Vec<_> = features
495
+ .iter()
496
+ .map(|feature| feature.name.as_str())
497
+ .collect();
498
+
499
+ assert!(names.contains(&"fetch"));
500
+ assert!(names.contains(&"Array.toSorted"));
501
+ assert!(names.contains(&"Number.MAX_SAFE_INTEGER"));
502
+ assert!(names.contains(&"Temporal"));
503
+ assert!(!names.contains(&"Array.options_parameter"));
504
+ assert!(!names.contains(&"Array.flagged"));
505
+
506
+ let to_sorted = features
507
+ .iter()
508
+ .find(|feature| feature.name == "Array.toSorted")
509
+ .unwrap();
510
+ assert_eq!(to_sorted.version.to_string(), "20.0.0");
511
+ assert!(
512
+ to_sorted
513
+ .detect
514
+ .contains(&GeneratedDetectRule::Property("toSorted".to_string()))
515
+ );
516
+ }
517
+ }
package/src/cli.rs ADDED
@@ -0,0 +1,71 @@
1
+ use std::path::PathBuf;
2
+
3
+ use clap::{Parser, ValueEnum};
4
+
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
6
+ pub enum RuntimeKind {
7
+ All,
8
+ Node,
9
+ Deno,
10
+ Bun,
11
+ Safari,
12
+ Chrome,
13
+ Firefox,
14
+ }
15
+
16
+ const ALL_RUNTIMES: &[RuntimeKind] = &[
17
+ RuntimeKind::Node,
18
+ RuntimeKind::Deno,
19
+ RuntimeKind::Bun,
20
+ RuntimeKind::Safari,
21
+ RuntimeKind::Chrome,
22
+ RuntimeKind::Firefox,
23
+ ];
24
+ const NODE_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Node];
25
+ const DENO_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Deno];
26
+ const BUN_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Bun];
27
+ const SAFARI_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Safari];
28
+ const CHROME_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Chrome];
29
+ const FIREFOX_RUNTIME: &[RuntimeKind] = &[RuntimeKind::Firefox];
30
+
31
+ impl RuntimeKind {
32
+ pub fn targets(self) -> &'static [RuntimeKind] {
33
+ match self {
34
+ Self::All => ALL_RUNTIMES,
35
+ Self::Node => NODE_RUNTIME,
36
+ Self::Deno => DENO_RUNTIME,
37
+ Self::Bun => BUN_RUNTIME,
38
+ Self::Safari => SAFARI_RUNTIME,
39
+ Self::Chrome => CHROME_RUNTIME,
40
+ Self::Firefox => FIREFOX_RUNTIME,
41
+ }
42
+ }
43
+ }
44
+
45
+ #[derive(Debug, Parser)]
46
+ #[command(name = "runtime-checker")]
47
+ #[command(about = "Detect the minimum runtime version required by a codebase")]
48
+ pub struct Cli {
49
+ /// Directory to scan.
50
+ pub dir: PathBuf,
51
+
52
+ /// Use text matching only. This is intentionally less reliable.
53
+ #[arg(long)]
54
+ pub fast: bool,
55
+
56
+ /// Runtime compatibility target.
57
+ #[arg(long, value_enum, default_value_t = RuntimeKind::All)]
58
+ pub runtime: RuntimeKind,
59
+
60
+ /// Print only the summary panel.
61
+ #[arg(long)]
62
+ pub summary: bool,
63
+
64
+ /// Print every detection for one feature instead of grouped summaries.
65
+ #[arg(long, value_name = "FEATURE")]
66
+ pub inspect: Option<String>,
67
+
68
+ /// Update package.json engines.node when it is missing or too low.
69
+ #[arg(long)]
70
+ pub fix: bool,
71
+ }