Skip to content

Commit f7c5062

Browse files
committed
webdetect: check service language level vs Node; advisory runtime APIs
Two follow-ups after the merged web-app work. Service language-version check (gating): a service's own .js is tokenized for its ES syntax level and checked against each firmware's bundled Node.js version (EsLevel::min_node_version). This is the trustworthy, code-derived Node requirement that replaces the untrusted engines.node — a service using optional chaining now correctly fails on Node 12 and passes on Node 16. Runtime-API detection (advisory, polyfill-aware): unambiguous namespaced APIs (Object.assign, Object.entries/fromEntries, Array.from, Promise.allSettled, globalThis, Reflect.*, ...) are detected from the token stream and flagged when newer than the target engine/Node — a "may need polyfills" note that never gates. Prototype methods are excluded (ambiguous receiver). Crucially, testing real packages showed bundles ship core-js/babel-runtime that merely *reference* every API, so the advisory is suppressed when a polyfill library is detected; the component is instead noted as self-polyfilling. JS token analysis (ES features + APIs + polyfill sniffing) is factored into a shared js module used by both web-app and service detection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WNiK747P4W8C2M9RRhRFS3
1 parent d61c1cf commit f7c5062

9 files changed

Lines changed: 777 additions & 312 deletions

File tree

common/verify/src/ipk/mod.rs

Lines changed: 91 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,23 @@ pub enum DetectionResult {
3939
detection: WebAppDetection,
4040
/// The firmware's web engine (for rendering the compat column).
4141
engine: Option<WebEngine>,
42-
/// Whether the firmware's engine supports the app's ES level.
42+
/// Whether the firmware's engine supports the app's ES **syntax** level
43+
/// (gating — syntax can't be polyfilled).
4344
es: CompatVerdict,
45+
/// Whether the firmware's engine natively provides the runtime APIs the
46+
/// app uses (advisory — APIs can be polyfilled).
47+
api: CompatVerdict,
4448
},
4549
Service {
4650
detection: ServiceRuntimeDetection,
47-
/// The firmware's Node.js version — informational only. There is no
48-
/// compat verdict for services: `engines.node` isn't trusted and webOS
49-
/// services carry no other reliable runtime requirement.
51+
/// The firmware's Node.js version.
5052
available_node: Option<Version>,
53+
/// Whether the firmware's Node.js supports the service's ES **syntax**
54+
/// level (gating — derived from the code, not `engines.node`).
55+
node: CompatVerdict,
56+
/// Whether the firmware's Node.js natively provides the runtime APIs the
57+
/// service uses (advisory).
58+
api: CompatVerdict,
5159
},
5260
}
5361

@@ -60,19 +68,27 @@ pub enum CompatVerdict {
6068
}
6169

6270
impl DetectionResult {
63-
/// The compatibility verdict for this component on this firmware, if it has
64-
/// one. Services are informational only (no verdict).
65-
pub fn verdict(&self) -> Option<&CompatVerdict> {
71+
/// The gating compatibility verdict for this component on this firmware
72+
/// (web app: ES syntax vs engine; service: ES syntax vs Node.js).
73+
pub fn verdict(&self) -> &CompatVerdict {
6674
match self {
67-
DetectionResult::WebApp { es, .. } => Some(es),
68-
DetectionResult::Service { .. } => None,
75+
DetectionResult::WebApp { es, .. } => es,
76+
DetectionResult::Service { node, .. } => node,
6977
}
7078
}
7179

72-
/// Whether this component is definitively incompatible (a `Fail` verdict);
73-
/// `Unknown`/no-verdict is not treated as incompatible.
80+
/// The advisory runtime-API verdict — never gates compatibility.
81+
pub fn api_advisory(&self) -> &CompatVerdict {
82+
match self {
83+
DetectionResult::WebApp { api, .. } => api,
84+
DetectionResult::Service { api, .. } => api,
85+
}
86+
}
87+
88+
/// Whether this component is definitively incompatible (a gating `Fail`);
89+
/// `Unknown` and the advisory API verdict never count.
7490
pub fn is_incompatible(&self) -> bool {
75-
matches!(self.verdict(), Some(CompatVerdict::Fail { .. }))
91+
matches!(self.verdict(), CompatVerdict::Fail { .. })
7692
}
7793
}
7894

@@ -134,11 +150,17 @@ impl VerifyForFirmware for Package {
134150

135151
fn web_detection(app: &Component<AppInfo>, engine: Option<&WebEngine>) -> Option<DetectionResult> {
136152
let detection = app.info.web.clone()?;
137-
let es = web_verdict(detection.es_level, engine);
153+
let es = web_verdict(detection.es_level, engine, "app uses");
154+
// Advisory: highest ES level implied by the runtime APIs used.
155+
let api_level = detection.es_apis.iter().map(|a| a.level).max();
156+
let api = web_verdict(api_level, engine, "app calls APIs from").demote_reason(
157+
"may need polyfills",
158+
);
138159
return Some(DetectionResult::WebApp {
139160
detection,
140161
engine: engine.cloned(),
141162
es,
163+
api,
142164
});
143165
}
144166

@@ -147,34 +169,81 @@ fn service_detection(
147169
node: Option<&Version>,
148170
) -> Option<DetectionResult> {
149171
let detection = svc.info.runtime.clone()?;
172+
let node_verdict = service_verdict(detection.es_level, node, "service uses");
173+
let api_level = detection.es_apis.iter().map(|a| a.level).max();
174+
let api = service_verdict(api_level, node, "service calls APIs from")
175+
.demote_reason("may need polyfills");
150176
return Some(DetectionResult::Service {
151177
detection,
152178
available_node: node.cloned(),
179+
node: node_verdict,
180+
api,
153181
});
154182
}
155183

156-
/// Does the firmware's web engine support the app's required ES level?
157-
fn web_verdict(es_level: Option<EsLevel>, engine: Option<&WebEngine>) -> CompatVerdict {
184+
/// Whether the firmware's web engine supports the given ES level.
185+
fn web_verdict(es_level: Option<EsLevel>, engine: Option<&WebEngine>, verb: &str) -> CompatVerdict {
158186
let Some(es_level) = es_level else {
159187
return CompatVerdict::Unknown;
160188
};
161-
let fw_max = match engine {
162-
Some(WebEngine::Chromium(v)) => EsLevel::from_chromium_major(v.major as u32),
163-
// The LG WebKit port (537.x) predates reliable ES2015 support.
164-
Some(WebEngine::WebKit(_)) => EsLevel::Es5,
165-
None => return CompatVerdict::Unknown,
189+
let Some(engine) = engine else {
190+
return CompatVerdict::Unknown;
166191
};
192+
let fw_max = engine_max_es(engine);
167193
if es_level <= fw_max {
168194
CompatVerdict::Ok
169195
} else {
170196
CompatVerdict::Fail {
171197
reason: format!(
172-
"app uses {}, but {} supports up to {}",
198+
"{verb} {}, but {} supports up to {}",
173199
es_level.label(),
174-
engine.map(|e| e.label()).unwrap_or_default(),
200+
engine.label(),
175201
fw_max.label()
176202
),
177203
}
178204
}
179205
}
180206

207+
/// Whether the firmware's Node.js supports the given ES level.
208+
fn service_verdict(es_level: Option<EsLevel>, node: Option<&Version>, verb: &str) -> CompatVerdict {
209+
let Some(es_level) = es_level else {
210+
return CompatVerdict::Unknown;
211+
};
212+
let Some(node) = node else {
213+
return CompatVerdict::Unknown;
214+
};
215+
let (maj, min) = es_level.min_node_version();
216+
if node.major > maj || (node.major == maj && node.minor >= min) {
217+
CompatVerdict::Ok
218+
} else {
219+
CompatVerdict::Fail {
220+
reason: format!(
221+
"{verb} {}, which needs Node.js {maj}.{min}, but firmware ships {node}",
222+
es_level.label(),
223+
),
224+
}
225+
}
226+
}
227+
228+
/// The highest ES level a firmware's web engine supports.
229+
pub fn engine_max_es(engine: &WebEngine) -> EsLevel {
230+
match engine {
231+
WebEngine::Chromium(v) => EsLevel::from_chromium_major(v.major as u32),
232+
// The LG WebKit port (537.x) predates reliable ES2015 support.
233+
WebEngine::WebKit(_) => EsLevel::Es5,
234+
}
235+
}
236+
237+
impl CompatVerdict {
238+
/// Rewrite a `Fail` reason's lead-in so the advisory reads as a polyfill
239+
/// note rather than a hard failure. No-op for `Ok`/`Unknown`.
240+
fn demote_reason(self, note: &str) -> CompatVerdict {
241+
match self {
242+
CompatVerdict::Fail { reason } => CompatVerdict::Fail {
243+
reason: format!("{reason} ({note})"),
244+
},
245+
other => other,
246+
}
247+
}
248+
}
249+

common/webdetect/src/eslevel.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ impl EsLevel {
5353
EsLevel::Es5
5454
}
5555

56+
/// Minimum Node.js major/minor that supports the syntax this level implies
57+
/// (V8 landing points). Used to check a service's own code against the
58+
/// firmware's bundled Node.js — a trustworthy, code-derived requirement
59+
/// (unlike `engines.node`, which isn't read).
60+
pub fn min_node_version(self) -> (u64, u64) {
61+
match self {
62+
EsLevel::Es5 => (0, 10),
63+
EsLevel::Es2015 => (6, 0), // let/const/arrow/class/template/spread
64+
EsLevel::Es2016 => (7, 0), // ** exponentiation
65+
EsLevel::Es2017 => (7, 6), // async/await
66+
EsLevel::Es2018 => (10, 0), // object spread, async iteration
67+
EsLevel::Es2019 => (12, 0),
68+
EsLevel::Es2020 => (14, 0), // optional chaining, nullish coalescing
69+
EsLevel::Es2021Plus => (15, 0),
70+
}
71+
}
72+
5673
pub fn label(self) -> &'static str {
5774
match self {
5875
EsLevel::Es5 => "ES5",
@@ -136,6 +153,14 @@ mod tests {
136153
assert_eq!(EsLevel::from_chromium_major(0), EsLevel::Es5);
137154
}
138155

156+
#[test]
157+
fn min_node_versions_are_ordered() {
158+
assert_eq!(EsLevel::Es5.min_node_version(), (0, 10));
159+
assert_eq!(EsLevel::Es2017.min_node_version(), (7, 6));
160+
assert_eq!(EsLevel::Es2020.min_node_version(), (14, 0));
161+
assert!(EsLevel::Es2020.min_node_version() > EsLevel::Es2015.min_node_version());
162+
}
163+
139164
#[test]
140165
fn feature_levels_order() {
141166
assert!(EsFeature::OptionalChaining.level() > EsFeature::AsyncAwait.level());

0 commit comments

Comments
 (0)