diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 429f83c..8449c8d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -42,20 +42,20 @@ just start # Run agent (tsx src/agent/index.ts) # ── Testing ── just test # Run TypeScript tests -just test-rust # Run Rust tests (analysis-guest) -just test-all # Run all tests (TS + Rust) +just test-analysis-guest # Run Rust tests (analysis-guest) +just test-all # Run all tests (TS + Rust) # ── Formatting ── -just fmt # Format TypeScript/JavaScript -just fmt-rust # Format Rust (analysis-guest) -just fmt-runtime # Format Rust (sandbox/runtime) -just fmt-all # Format all code +just fmt # Format TypeScript/JavaScript +just fmt-analysis-guest # Format Rust (analysis-guest) +just fmt-runtime # Format Rust (sandbox/runtime) +just fmt-all # Format all code # ── Linting ── -just lint # TypeScript: fmt-check + typecheck -just lint-rust # Rust: clippy + fmt-check (analysis-guest) -just lint-runtime # Rust: clippy + fmt-check (runtime) -just lint-all # All lints +just lint # TypeScript: fmt-check + typecheck +just lint-analysis-guest # Rust: clippy + fmt-check (analysis-guest) +just lint-runtime # Rust: clippy + fmt-check (runtime) +just lint-all # All lints # ── Quality Gate ── just check # Full quality gate: lint-all + test-all diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 277ac44..6c665f9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -46,7 +46,7 @@ Individual commands: | `just fmt` | Format TS/JS with Prettier | Before committing | | `just lint` | `fmt-check` + `tsc --noEmit` | Quick validation | | `just test` | Run Vitest suite (30 test files, ~1700 tests) | After TS changes | -| `just test-rust` | Run Rust tests in code-validator | After Rust changes | +| `just test-analysis-guest` | Run Rust tests in code-validator | After Rust changes | | `just test-all` | Both TS + Rust tests | Full validation | | `just start` | Run agent with `tsx` (no build needed) | Dev/testing | | `just binary-release` | Build standalone binary to `dist/bin/hyperagent` | Release builds | @@ -93,5 +93,5 @@ Before considering a change complete: 1. `just fmt` — all code formatted 2. `just lint` — TypeScript compiles with zero errors 3. `just test` — all ~1700 tests pass -4. If Rust code was changed: `just test-rust` passes +4. If Rust code was changed: `just test-analysis-guest` passes 5. No new `expect`/`unwrap`/`assert` in production TS code diff --git a/.github/instructions/tests.instructions.md b/.github/instructions/tests.instructions.md index 3214782..467d834 100644 --- a/.github/instructions/tests.instructions.md +++ b/.github/instructions/tests.instructions.md @@ -10,7 +10,7 @@ Vitest test suite for Hyperagent. ```bash just test # Run all TypeScript tests -just test-rust # Run Rust tests (analysis-guest) +just test-analysis-guest # Run Rust tests (analysis-guest) just test-all # Run both ``` diff --git a/.github/workflows/pr-validate.yml b/.github/workflows/pr-validate.yml index f033749..5c9abce 100644 --- a/.github/workflows/pr-validate.yml +++ b/.github/workflows/pr-validate.yml @@ -36,14 +36,11 @@ jobs: - name: Setup run: just setup - - name: Format check - run: npm run fmt:check + - name: Lint (TS + Rust) + run: just lint-all - - name: Typecheck - run: npm run typecheck - - - name: Test - run: just test + - name: Test (TS + Rust) + run: just test-all # Build and test on all hypervisor configurations (1ES runners have Rust + just) # NOTE: Windows WHP support is temporarily disabled pending upstream diff --git a/CLAUDE.md b/CLAUDE.md index 429f83c..8449c8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,20 +42,20 @@ just start # Run agent (tsx src/agent/index.ts) # ── Testing ── just test # Run TypeScript tests -just test-rust # Run Rust tests (analysis-guest) -just test-all # Run all tests (TS + Rust) +just test-analysis-guest # Run Rust tests (analysis-guest) +just test-all # Run all tests (TS + Rust) # ── Formatting ── -just fmt # Format TypeScript/JavaScript -just fmt-rust # Format Rust (analysis-guest) -just fmt-runtime # Format Rust (sandbox/runtime) -just fmt-all # Format all code +just fmt # Format TypeScript/JavaScript +just fmt-analysis-guest # Format Rust (analysis-guest) +just fmt-runtime # Format Rust (sandbox/runtime) +just fmt-all # Format all code # ── Linting ── -just lint # TypeScript: fmt-check + typecheck -just lint-rust # Rust: clippy + fmt-check (analysis-guest) -just lint-runtime # Rust: clippy + fmt-check (runtime) -just lint-all # All lints +just lint # TypeScript: fmt-check + typecheck +just lint-analysis-guest # Rust: clippy + fmt-check (analysis-guest) +just lint-runtime # Rust: clippy + fmt-check (runtime) +just lint-all # All lints # ── Quality Gate ── just check # Full quality gate: lint-all + test-all diff --git a/Justfile b/Justfile index fe5c19a..6827da9 100644 --- a/Justfile +++ b/Justfile @@ -171,17 +171,17 @@ lint: fmt-check typecheck @echo "✅ Lint passed — looking sharp" # Lint Rust code in analysis-guest -lint-rust: +lint-analysis-guest: cd "{{analysis-guest-dir}}" && cargo fmt --check && cargo clippy --workspace -- -D warnings - @echo "✅ Rust lint passed" + @echo "✅ Analysis-guest lint passed" # Format Rust code in analysis-guest -fmt-rust: +fmt-analysis-guest: cd "{{analysis-guest-dir}}" && cargo fmt # Test Rust code in analysis-guest # Note: --test-threads=1 required because QuickJS context isn't thread-safe -test-rust: +test-analysis-guest: cd "{{analysis-guest-dir}}" && cargo test --workspace -- --test-threads=1 # ── HyperAgent Runtime (native modules) ────────────────────────────── @@ -210,15 +210,15 @@ fmt-runtime: cd "{{runtime-dir}}" && cargo +1.89 fmt --all # Full lint: TypeScript + Rust (analysis-guest + runtime) -lint-all: lint lint-rust lint-runtime +lint-all: lint lint-analysis-guest lint-runtime @echo "✅ All lints passed" # Full format: TypeScript + Rust -fmt-all: fmt fmt-rust fmt-runtime +fmt-all: fmt fmt-analysis-guest fmt-runtime @echo "✅ All code formatted" # Full test: TypeScript + Rust -test-all: test test-rust +test-all: test test-analysis-guest @echo "✅ All tests passed" # ── OOXML Validation ───────────────────────────────────────────────── diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 6e1549b..6f42b18 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -160,7 +160,7 @@ npm test -- tests/plugin-manager.test.ts npm test -- --coverage # Rust tests only -just test-rust # Tests analysis-guest +just test-analysis-guest # Tests analysis-guest # All tests (TS + Rust) just test-all @@ -187,10 +187,10 @@ just fmt # Format TS/JS just lint # Check format + typecheck # Rust only -just fmt-rust # Format analysis-guest Rust -just lint-rust # Clippy + format check for analysis-guest -just fmt-runtime # Format runtime Rust -just lint-runtime # Clippy + format check for runtime +just fmt-analysis-guest # Format analysis-guest Rust +just lint-analysis-guest # Clippy + format check for analysis-guest +just fmt-runtime # Format runtime Rust +just lint-runtime # Clippy + format check for runtime # Everything (TS + Rust) just fmt-all # Format all code diff --git a/src/code-validator/guest/README.md b/src/code-validator/guest/README.md index 5d0bfa3..87b5695 100644 --- a/src/code-validator/guest/README.md +++ b/src/code-validator/guest/README.md @@ -163,7 +163,7 @@ Analyze a library tarball for security issues. just test # Rust tests only -just test-rust +just test-analysis-guest # Node.js tests only just test-node diff --git a/src/code-validator/guest/host/src/runtime.rs b/src/code-validator/guest/host/src/runtime.rs index 71bd6bb..5dfa5d1 100644 --- a/src/code-validator/guest/host/src/runtime.rs +++ b/src/code-validator/guest/host/src/runtime.rs @@ -94,13 +94,12 @@ pub(crate) fn get_analysis_runtime() -> Option { /// /// After calling this, `get_analysis_runtime()` will return `None`. pub(crate) fn shutdown_runtime() { - if let Some(mutex) = ANALYSIS_RUNTIME.get() { - if let Ok(mut guard) = mutex.lock() { - if let Some(rt) = guard.take() { - eprintln!("[hyperlight-analysis] Shutting down runtime..."); - rt.shutdown_timeout(SHUTDOWN_TIMEOUT); - eprintln!("[hyperlight-analysis] Runtime shutdown complete"); - } - } + if let Some(mutex) = ANALYSIS_RUNTIME.get() + && let Ok(mut guard) = mutex.lock() + && let Some(rt) = guard.take() + { + eprintln!("[hyperlight-analysis] Shutting down runtime..."); + rt.shutdown_timeout(SHUTDOWN_TIMEOUT); + eprintln!("[hyperlight-analysis] Runtime shutdown complete"); } } diff --git a/src/code-validator/guest/host/src/sandbox.rs b/src/code-validator/guest/host/src/sandbox.rs index f2c39b6..102a457 100644 --- a/src/code-validator/guest/host/src/sandbox.rs +++ b/src/code-validator/guest/host/src/sandbox.rs @@ -36,8 +36,8 @@ use hyperlight_host::sandbox::uninitialized::GuestBinary; use hyperlight_host::{MultiUseSandbox, UninitializedSandbox}; use napi::bindgen_prelude::*; -use crate::runtime::get_analysis_runtime; use crate::ANALYSIS_RUNTIME; +use crate::runtime::get_analysis_runtime; /// Heap size for the guest (16 MB). const GUEST_HEAP_SIZE: u64 = 16 * 1024 * 1024; @@ -76,12 +76,8 @@ const GUEST_OUTPUT_SIZE: usize = 4 * 1024 * 1024; pub async fn call_guest_function(function_name: &str, input: String) -> Result { let function_name = function_name.to_string(); - let handle = get_analysis_runtime().ok_or_else(|| { - Error::new( - Status::GenericFailure, - "Analysis runtime not available", - ) - })?; + let handle = get_analysis_runtime() + .ok_or_else(|| Error::new(Status::GenericFailure, "Analysis runtime not available"))?; // Run the blocking Hyperlight operations on the runtime's thread pool handle @@ -101,12 +97,8 @@ pub async fn call_guest_function_2( ) -> Result { let function_name = function_name.to_string(); - let handle = get_analysis_runtime().ok_or_else(|| { - Error::new( - Status::GenericFailure, - "Analysis runtime not available", - ) - })?; + let handle = get_analysis_runtime() + .ok_or_else(|| Error::new(Status::GenericFailure, "Analysis runtime not available"))?; // Run the blocking Hyperlight operations on the runtime's thread pool handle diff --git a/src/code-validator/guest/runtime/src/js_parser.rs b/src/code-validator/guest/runtime/src/js_parser.rs index fdfdbef..e2108be 100644 --- a/src/code-validator/guest/runtime/src/js_parser.rs +++ b/src/code-validator/guest/runtime/src/js_parser.rs @@ -476,10 +476,7 @@ pub fn ternary_assignment(input: &str) -> IResult<&str, TernaryAssignment<'_>> { let (input, _) = ws(input)?; // Parse false branch (rest of line, stopping at ; or newline) - let false_branch = input - .split(|c| c == ';' || c == '\n') - .next() - .unwrap_or(input); + let false_branch = input.split([';', '\n']).next().unwrap_or(input); let true_is_null = is_null_or_undefined(true_branch); let false_is_null = is_null_or_undefined(false_branch); @@ -505,20 +502,19 @@ pub fn extract_all_imports(source: &str) -> Vec { for line in source.lines() { let trimmed = line.trim(); - if trimmed.starts_with("import") { - if let Ok((_, stmt)) = import_statement(trimmed) { - if !imports.iter().any(|s: &String| s == stmt.specifier) { - imports.push(String::from(stmt.specifier)); - } - } + if trimmed.starts_with("import") + && let Ok((_, stmt)) = import_statement(trimmed) + && !imports.iter().any(|s: &String| s == stmt.specifier) + { + imports.push(String::from(stmt.specifier)); } // Also check for `from "..."` pattern on continuation lines if let Some(from_pos) = trimmed.find("from ") { let rest = &trimmed[from_pos + 5..]; - if let Ok((_, specifier)) = string_literal(rest.trim()) { - if !imports.iter().any(|s: &String| s == specifier) { - imports.push(String::from(specifier)); - } + if let Ok((_, specifier)) = string_literal(rest.trim()) + && !imports.iter().any(|s: &String| s == specifier) + { + imports.push(String::from(specifier)); } } } @@ -542,15 +538,15 @@ pub fn extract_all_named_imports(source: &str) -> Vec { for line in source.lines() { let trimmed = line.trim(); - if trimmed.starts_with("import") { - if let Ok((_, stmt)) = import_statement(trimmed) { - // Only track named imports (not namespace or default) - if !stmt.names.is_empty() { - imports.push(NamedImport { - module: String::from(stmt.specifier), - names: stmt.names.iter().map(|s| String::from(*s)).collect(), - }); - } + if trimmed.starts_with("import") + && let Ok((_, stmt)) = import_statement(trimmed) + { + // Only track named imports (not namespace or default) + if !stmt.names.is_empty() { + imports.push(NamedImport { + module: String::from(stmt.specifier), + names: stmt.names.iter().map(|s| String::from(*s)).collect(), + }); } } } @@ -695,10 +691,10 @@ pub fn extract_all_destructuring(source: &str) -> Vec { } } // Check for array destructuring: [ ... ] = - else if after_keyword.starts_with('[') { - if let Some(info) = parse_array_destructuring(after_keyword, line_num as u32 + 1) { - results.push(info); - } + else if after_keyword.starts_with('[') + && let Some(info) = parse_array_destructuring(after_keyword, line_num as u32 + 1) + { + results.push(info); } } @@ -772,7 +768,7 @@ fn parse_array_destructuring(input: &str, line: u32) -> Option Vec { if let Some(colon_pos) = trimmed.find(':') { let new_name = trimmed[colon_pos + 1..].trim(); // Skip if it's a nested destructure - if !new_name.starts_with('{') && !new_name.starts_with('[') { - if !new_name.is_empty() - && new_name - .chars() - .next() - .map_or(false, |c| c.is_alphabetic() || c == '_') - { - names.push(String::from(new_name)); - } + if !new_name.starts_with('{') + && !new_name.starts_with('[') + && !new_name.is_empty() + && new_name + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_') + { + names.push(String::from(new_name)); } } else { // Simple name if trimmed .chars() .next() - .map_or(false, |c| c.is_alphabetic() || c == '_') + .is_some_and(|c| c.is_alphabetic() || c == '_') { // Handle default values: `name = default` let name = if let Some(eq_pos) = trimmed.find('=') { @@ -1178,24 +1174,23 @@ pub fn extract_all_method_calls(source: &str) -> Vec { && bytes[paren_check] == b'(' && obj_start < i && method_start < method_end - { - if let (Ok(object), Ok(method)) = ( + && let (Ok(object), Ok(method)) = ( core::str::from_utf8(&bytes[obj_start..i]), core::str::from_utf8(&bytes[method_start..method_end]), - ) { - // Skip built-in methods and invalid identifiers - if !is_builtin_method(method) - && !object.is_empty() - && (object.chars().next().unwrap().is_alphabetic() - || object.starts_with('_') - || object.starts_with('$')) - { - calls.push(MethodCallInfo { - object: String::from(object), - method: String::from(method), - line: line_num as u32 + 1, - }); - } + ) + { + // Skip built-in methods and invalid identifiers + if !is_builtin_method(method) + && !object.is_empty() + && (object.chars().next().is_some_and(|c| c.is_alphabetic()) + || object.starts_with('_') + || object.starts_with('$')) + { + calls.push(MethodCallInfo { + object: String::from(object), + method: String::from(method), + line: line_num as u32 + 1, + }); } } } @@ -1264,23 +1259,22 @@ pub fn extract_all_function_calls(source: &str) -> Vec { // Check it's not a method call (preceded by .) let is_method = func_start > 0 && bytes[func_start - 1] == b'.'; - if !is_method && func_start < func_end { - if let Ok(func_name) = core::str::from_utf8(&bytes[func_start..func_end]) { - if !is_builtin_function(func_name) - && !func_name.is_empty() - && (func_name.chars().next().unwrap().is_alphabetic() - || func_name.starts_with('_') - || func_name.starts_with('$')) - { - // Count arguments - if let Ok((_, arg_count)) = count_args(&line[i..]) { - calls.push(FunctionCallInfo { - func_name: String::from(func_name), - arg_count, - line: line_num as u32 + 1, - }); - } - } + if !is_method + && func_start < func_end + && let Ok(func_name) = core::str::from_utf8(&bytes[func_start..func_end]) + && !is_builtin_function(func_name) + && !func_name.is_empty() + && (func_name.chars().next().is_some_and(|c| c.is_alphabetic()) + || func_name.starts_with('_') + || func_name.starts_with('$')) + { + // Count arguments + if let Ok((_, arg_count)) = count_args(&line[i..]) { + calls.push(FunctionCallInfo { + func_name: String::from(func_name), + arg_count, + line: line_num as u32 + 1, + }); } } } @@ -1307,34 +1301,35 @@ pub fn extract_all_assignments(source: &str) -> Vec { } // Try ternary assignment first (for nullable tracking) - if trimmed.contains('?') && trimmed.contains(':') { - if let Ok((_, ternary)) = ternary_assignment(trimmed) { - let func = ternary.true_expr.or(ternary.false_expr); - assignments.push(AssignmentInfo { - var_name: String::from(ternary.var_name), - func_name: func.map(String::from), - method_chain: Vec::new(), - initial_object: None, - is_nullable: ternary.is_nullable, - line: line_num as u32 + 1, - }); - continue; - } + if trimmed.contains('?') + && trimmed.contains(':') + && let Ok((_, ternary)) = ternary_assignment(trimmed) + { + let func = ternary.true_expr.or(ternary.false_expr); + assignments.push(AssignmentInfo { + var_name: String::from(ternary.var_name), + func_name: func.map(String::from), + method_chain: Vec::new(), + initial_object: None, + is_nullable: ternary.is_nullable, + line: line_num as u32 + 1, + }); + continue; } // Try chained method call: const x = obj.method1().method2() - if let Ok((_, (var_name, expr))) = var_decl_chained_call(trimmed) { - if !expr.chain.is_empty() { - assignments.push(AssignmentInfo { - var_name: String::from(var_name), - func_name: None, - method_chain: expr.chain.iter().map(|m| String::from(m.method)).collect(), - initial_object: Some(String::from(expr.object)), - is_nullable: false, - line: line_num as u32 + 1, - }); - continue; - } + if let Ok((_, (var_name, expr))) = var_decl_chained_call(trimmed) + && !expr.chain.is_empty() + { + assignments.push(AssignmentInfo { + var_name: String::from(var_name), + func_name: None, + method_chain: expr.chain.iter().map(|m| String::from(m.method)).collect(), + initial_object: Some(String::from(expr.object)), + is_nullable: false, + line: line_num as u32 + 1, + }); + continue; } // Try simple function call: const x = func() @@ -1425,23 +1420,25 @@ pub fn extract_all_property_accesses(source: &str) -> Vec { let is_method_call = next_check < bytes.len() && bytes[next_check] == b'('; // Only capture if it's NOT a method call and has valid object/property - if !is_method_call && obj_start < i && prop_start < prop_end { - if let (Ok(object), Ok(property)) = ( + if !is_method_call + && obj_start < i + && prop_start < prop_end + && let (Ok(object), Ok(property)) = ( core::str::from_utf8(&bytes[obj_start..i]), core::str::from_utf8(&bytes[prop_start..prop_end]), - ) { - // Skip builtins and skip if object starts with uppercase (likely a class/static) - if !is_builtin_property(property) - && !object.is_empty() - && !property.is_empty() - && !object.chars().next().unwrap().is_uppercase() - { - accesses.push(PropertyAccessInfo { - object: String::from(object), - property: String::from(property), - line: line_num as u32 + 1, - }); - } + ) + { + // Skip builtins and skip if object starts with uppercase (likely a class/static) + if !is_builtin_property(property) + && !object.is_empty() + && !property.is_empty() + && !object.chars().next().is_some_and(|c| c.is_uppercase()) + { + accesses.push(PropertyAccessInfo { + object: String::from(object), + property: String::from(property), + line: line_num as u32 + 1, + }); } } diff --git a/src/code-validator/guest/runtime/src/lib.rs b/src/code-validator/guest/runtime/src/lib.rs index 4e50216..5e90c84 100644 --- a/src/code-validator/guest/runtime/src/lib.rs +++ b/src/code-validator/guest/runtime/src/lib.rs @@ -34,7 +34,6 @@ pub mod validator; use alloc::format; use alloc::string::String; -use alloc::vec::Vec; use serde::{Deserialize, Serialize}; pub use metadata::{ diff --git a/src/code-validator/guest/runtime/src/metadata.rs b/src/code-validator/guest/runtime/src/metadata.rs index 2aee31f..e4dbbea 100644 --- a/src/code-validator/guest/runtime/src/metadata.rs +++ b/src/code-validator/guest/runtime/src/metadata.rs @@ -363,10 +363,10 @@ pub fn extract_dts_metadata(source: &str, _config: &MetadataConfig) -> ModuleMet // Handle multi-line function declarations (e.g., shapes() with complex type params) if is_multiline_function_start(line) { let (full_decl, next_i) = collect_multiline_function(&lines, i); - if let Some(export) = parse_dts_declaration(&full_decl, &[]) { - if !exports.iter().any(|e| e.name == export.name) { - exports.push(export); - } + if let Some(export) = parse_dts_declaration(&full_decl, &[]) + && !exports.iter().any(|e| e.name == export.name) + { + exports.push(export); } i = next_i; continue; @@ -641,8 +641,8 @@ fn parse_ts_params(params_str: &str, jsdoc: Option<&JsDocInfo>) -> Vec Option { let line = line.trim_start_matches("readonly "); // Skip if it's a method (has parentheses before colon) - if let Some(paren_pos) = line.find('(') { - if let Some(colon_pos) = line.find(':') { - if paren_pos < colon_pos { - return None; - } - } + if let Some(paren_pos) = line.find('(') + && let Some(colon_pos) = line.find(':') + && paren_pos < colon_pos + { + return None; } // Extract property name (before ':' or '?:') @@ -1187,12 +1186,13 @@ fn extract_class_members( } // Extract this.propName = ... assignments in constructor - if in_constructor && depth >= constructor_depth { - if let Some(prop_name) = extract_this_assignment(trimmed) { - if !prop_name.starts_with('_') && !properties.contains(&prop_name) { - properties.push(prop_name); - } - } + if in_constructor + && depth >= constructor_depth + && let Some(prop_name) = extract_this_assignment(trimmed) + && !prop_name.starts_with('_') + && !properties.contains(&prop_name) + { + properties.push(prop_name); } // Look for method definitions at depth 1 (direct class members) @@ -1205,21 +1205,22 @@ fn extract_class_members( methods.push(method_name.clone()); // Extract return type from JSDoc if present - if let Some(ref jsdoc_lines) = pending_jsdoc { - if let Some(return_type) = extract_jsdoc_return_type(jsdoc_lines) { - method_returns.insert(method_name, return_type); - } + if let Some(ref jsdoc_lines) = pending_jsdoc + && let Some(return_type) = extract_jsdoc_return_type(jsdoc_lines) + { + method_returns.insert(method_name, return_type); } pending_jsdoc = None; } // Look for class field declarations at depth 1: propName = value; - if depth == 1 && parse_method_definition(trimmed).is_none() { - if let Some(prop_name) = extract_class_field(trimmed) { - if !prop_name.starts_with('_') && !properties.contains(&prop_name) { - properties.push(prop_name); - } - } + if depth == 1 + && parse_method_definition(trimmed).is_none() + && let Some(prop_name) = extract_class_field(trimmed) + && !prop_name.starts_with('_') + && !properties.contains(&prop_name) + { + properties.push(prop_name); } // Clear pending JSDoc if we hit a non-method line at depth 1 @@ -1227,10 +1228,9 @@ fn extract_class_members( && !trimmed.starts_with("/**") && !trimmed.is_empty() && !trimmed.starts_with("//") + && parse_method_definition(trimmed).is_none() { - if parse_method_definition(trimmed).is_none() { - pending_jsdoc = None; - } + pending_jsdoc = None; } // Update depth @@ -1547,10 +1547,10 @@ fn parse_schema_object(content: &str) -> Option { // Check if this is a new field declaration at depth 0 if brace_depth == 0 && trimmed.contains(':') && !trimmed.starts_with("//") { // If we have a previous field, parse it - if let Some(name) = current_field_name.take() { - if let Some(field) = parse_schema_field(¤t_field_content) { - schema.insert(name, field); - } + if let Some(name) = current_field_name.take() + && let Some(field) = parse_schema_field(¤t_field_content) + { + schema.insert(name, field); } current_field_content.clear(); @@ -1591,10 +1591,10 @@ fn parse_schema_object(content: &str) -> Option { } // Don't forget the last field - if let Some(name) = current_field_name { - if let Some(field) = parse_schema_field(¤t_field_content) { - schema.insert(name, field); - } + if let Some(name) = current_field_name + && let Some(field) = parse_schema_field(¤t_field_content) + { + schema.insert(name, field); } if schema.is_empty() { @@ -2661,7 +2661,12 @@ export declare function anotherFunc(y: string): void; "Function after multi-line 'anotherFunc' should be extracted. Got: {:?}", names ); - assert_eq!(result.exports.len(), 3, "Expected 3 exports, got {:?}", names); + assert_eq!( + result.exports.len(), + 3, + "Expected 3 exports, got {:?}", + names + ); } #[test] @@ -2692,11 +2697,12 @@ export declare const FOO: string; "Should have re-exported kvTable. Got: {:?}", names ); - assert!( - names.contains(&"FOO"), - "Should have FOO. Got: {:?}", + assert!(names.contains(&"FOO"), "Should have FOO. Got: {:?}", names); + assert_eq!( + result.exports.len(), + 4, + "Expected 4 exports, got {:?}", names ); - assert_eq!(result.exports.len(), 4, "Expected 4 exports, got {:?}", names); } } diff --git a/src/code-validator/guest/runtime/src/plugin_scan.rs b/src/code-validator/guest/runtime/src/plugin_scan.rs index 8d1c143..e824d5d 100644 --- a/src/code-validator/guest/runtime/src/plugin_scan.rs +++ b/src/code-validator/guest/runtime/src/plugin_scan.rs @@ -61,6 +61,9 @@ struct ScanPattern { /// - Avoid `\b` entirely — use explicit patterns or accept minor false positives /// /// This keeps binary size small while still catching security patterns. +// All regex patterns here are static string literals validated via Regex::new(...).expect(...). +// Covered by test_get_patterns_compile() to catch regressions. +#[allow(clippy::expect_used)] fn get_patterns() -> Vec { vec![ // ── Process execution (DANGER) ─────────────────────────────── @@ -341,6 +344,16 @@ pub fn scan_plugin(source: &str, _config: &ScanConfig) -> ScanResult { mod tests { use super::*; + /// Ensures every regex in get_patterns() compiles without panicking. + #[test] + fn test_get_patterns_compile() { + let patterns = get_patterns(); + assert!( + !patterns.is_empty(), + "get_patterns() should return at least one pattern" + ); + } + fn scan(source: &str) -> Vec { scan_plugin(source, &ScanConfig::default()).findings } diff --git a/src/code-validator/guest/runtime/src/validator.rs b/src/code-validator/guest/runtime/src/validator.rs index 5cf5c07..6f54caf 100644 --- a/src/code-validator/guest/runtime/src/validator.rs +++ b/src/code-validator/guest/runtime/src/validator.rs @@ -322,59 +322,59 @@ fn validate_module_hashes( let is_system = meta.author == "system"; // Check .js source hash - if let Some(expected_hash) = &meta.source_hash { - if let Some(js_source) = context.module_sources.get(specifier) { - let actual_hash = sha256_short(js_source); - if expected_hash != &actual_hash { - let message = alloc::format!( - "{}: .js hash mismatch (expected {}, got {}). Run: npm run build:modules", - specifier, - expected_hash, - actual_hash - ); - if is_system { - errors.push(ValidationError { - error_type: "integrity".to_string(), - message, - line: None, - column: None, - }); - } else { - warnings.push(ValidationWarning { - warning_type: "drift".to_string(), - message, - line: None, - }); - } + if let Some(expected_hash) = &meta.source_hash + && let Some(js_source) = context.module_sources.get(specifier) + { + let actual_hash = sha256_short(js_source); + if expected_hash != &actual_hash { + let message = alloc::format!( + "{}: .js hash mismatch (expected {}, got {}). Run: npm run build:modules", + specifier, + expected_hash, + actual_hash + ); + if is_system { + errors.push(ValidationError { + error_type: "integrity".to_string(), + message, + line: None, + column: None, + }); + } else { + warnings.push(ValidationWarning { + warning_type: "drift".to_string(), + message, + line: None, + }); } } } // Check .d.ts source hash - if let Some(expected_hash) = &meta.dts_hash { - if let Some(dts_source) = context.dts_sources.get(specifier) { - let actual_hash = sha256_short(dts_source); - if expected_hash != &actual_hash { - let message = alloc::format!( - "{}: .d.ts hash mismatch (expected {}, got {}). Run: npm run build:modules", - specifier, - expected_hash, - actual_hash - ); - if is_system { - errors.push(ValidationError { - error_type: "integrity".to_string(), - message, - line: None, - column: None, - }); - } else { - warnings.push(ValidationWarning { - warning_type: "drift".to_string(), - message, - line: None, - }); - } + if let Some(expected_hash) = &meta.dts_hash + && let Some(dts_source) = context.dts_sources.get(specifier) + { + let actual_hash = sha256_short(dts_source); + if expected_hash != &actual_hash { + let message = alloc::format!( + "{}: .d.ts hash mismatch (expected {}, got {}). Run: npm run build:modules", + specifier, + expected_hash, + actual_hash + ); + if is_system { + errors.push(ValidationError { + error_type: "integrity".to_string(), + message, + line: None, + column: None, + }); + } else { + warnings.push(ValidationWarning { + warning_type: "drift".to_string(), + message, + line: None, + }); } } } @@ -592,12 +592,11 @@ pub fn validate_javascript(source: &str, context: &ValidationContext) -> Validat continue; } // Check if this is a native module (has module.json with type: "native") - if let Some(json_str) = context.module_jsons.get(import) { - if let Ok(meta) = serde_json::from_str::(json_str) { - if meta.r#type.as_deref() == Some("native") { - continue; - } - } + if let Some(json_str) = context.module_jsons.get(import) + && let Ok(meta) = serde_json::from_str::(json_str) + && meta.r#type.as_deref() == Some("native") + { + continue; } missing_sources.push(import.clone()); } @@ -768,14 +767,14 @@ fn check_syntax_with_quickjs(source: &str) -> Option { let message = obj .get::<_, rquickjs::Value>("message") .ok() - .and_then(|v| v.as_string().map(|s| s.to_string().ok()).flatten()); + .and_then(|v| v.as_string().and_then(|s| s.to_string().ok())); // Get stack trace which contains line:column info // Format: " at __validate_0__:5:16\n" let stack = obj .get::<_, rquickjs::Value>("stack") .ok() - .and_then(|v| v.as_string().map(|s| s.to_string().ok()).flatten()); + .and_then(|v| v.as_string().and_then(|s| s.to_string().ok())); // Extract line:col from stack trace manually // Pattern: __:N:M where N is line, M is column @@ -1122,11 +1121,11 @@ impl SymbolTable { } // Track type from simple function call - if let Some(ref func_name) = assign.func_name { - if let Some(&return_type) = func_return_types.get(func_name.as_str()) { - self.bindings - .insert(assign.var_name.clone(), return_type.to_string()); - } + if let Some(ref func_name) = assign.func_name + && let Some(&return_type) = func_return_types.get(func_name.as_str()) + { + self.bindings + .insert(assign.var_name.clone(), return_type.to_string()); } // Track type from chained method call - FULL CHAIN WALKING @@ -1138,46 +1137,46 @@ impl SymbolTable { // Where pptx is `import * as pptx from "ha:pptx"` // The first call (createPresentation) is a function call on the namespace, // so we look up its return type directly from func_return_types. - if let Some(ref initial_obj) = assign.initial_object { - if !assign.method_chain.is_empty() { - // Check if initial_obj is a namespace import - let is_namespace = self.namespace_imports.contains_key(initial_obj); - - // Start with the initial object's type - let mut current_type: Option<&str> = if is_namespace { - // For namespace imports, the first method in the chain is a function call. - // e.g., pptx.createPresentation() - look up createPresentation's return type - if let Some(first_method) = assign.method_chain.first() { - func_return_types.get(first_method.as_str()).copied() - } else { - None - } + if let Some(ref initial_obj) = assign.initial_object + && !assign.method_chain.is_empty() + { + // Check if initial_obj is a namespace import + let is_namespace = self.namespace_imports.contains_key(initial_obj); + + // Start with the initial object's type + let mut current_type: Option<&str> = if is_namespace { + // For namespace imports, the first method in the chain is a function call. + // e.g., pptx.createPresentation() - look up createPresentation's return type + if let Some(first_method) = assign.method_chain.first() { + func_return_types.get(first_method.as_str()).copied() } else { - // Normal case: look up the object's type - self.bindings.get(initial_obj).map(|s| s.as_str()) - }; - - // Walk through the method chain - // For namespace imports, start from index 1 (we already handled the first) - let start_index = if is_namespace { 1 } else { 0 }; - for method in assign.method_chain.iter().skip(start_index) { - if let Some(obj_type) = current_type { - // Look up this method's return type - current_type = method_return_types - .get(&(obj_type, method.as_str())) - .copied(); - } else { - // Lost track of type, can't continue - break; - } + None } + } else { + // Normal case: look up the object's type + self.bindings.get(initial_obj).map(|s| s.as_str()) + }; - // Store the final type - if let Some(final_type) = current_type { - self.bindings - .insert(assign.var_name.clone(), final_type.to_string()); + // Walk through the method chain + // For namespace imports, start from index 1 (we already handled the first) + let start_index = if is_namespace { 1 } else { 0 }; + for method in assign.method_chain.iter().skip(start_index) { + if let Some(obj_type) = current_type { + // Look up this method's return type + current_type = method_return_types + .get(&(obj_type, method.as_str())) + .copied(); + } else { + // Lost track of type, can't continue + break; } } + + // Store the final type + if let Some(final_type) = current_type { + self.bindings + .insert(assign.var_name.clone(), final_type.to_string()); + } } } @@ -1204,11 +1203,6 @@ impl SymbolTable { fn is_nullable(&self, var_name: &str) -> bool { self.nullable.contains(var_name) } - - /// Get the type of a variable, if known. - fn get_type(&self, var_name: &str) -> Option<&str> { - self.bindings.get(var_name).map(|s| s.as_str()) - } } // parse_simple_assignment moved to js_parser module @@ -1244,36 +1238,35 @@ fn detect_guarded_scopes( // Look for if statements that guard a nullable variable if line.starts_with("if") && line.contains('(') { // Extract the condition from if (condition) - if let Some(start) = line.find('(') { - if let Some(end) = find_matching_paren(line, start) { - let condition = &line[start + 1..end].trim(); - - // Check if this condition guards a nullable variable - for var in nullable_vars { - // Simple guard: if (varName) - if *condition == var.as_str() { - if let Some(end_line) = find_block_end(&lines, i) { - guarded_ranges - .entry(var.clone()) - .or_default() - .push((line_num, end_line as u32 + 1)); - } - } - // Null check: if (varName !== null) or if (varName != null) - else if condition.contains(var.as_str()) - && (condition.contains("!== null") - || condition.contains("!= null") - || condition.contains("!== undefined") - || condition.contains("!= undefined")) - { - if let Some(end_line) = find_block_end(&lines, i) { - guarded_ranges - .entry(var.clone()) - .or_default() - .push((line_num, end_line as u32 + 1)); - } + if let Some(start) = line.find('(') + && let Some(end) = find_matching_paren(line, start) + { + let condition = &line[start + 1..end].trim(); + + // Check if this condition guards a nullable variable + for var in nullable_vars { + // Simple guard: if (varName) + if *condition == var.as_str() { + if let Some(end_line) = find_block_end(&lines, i) { + guarded_ranges + .entry(var.clone()) + .or_default() + .push((line_num, end_line as u32 + 1)); } } + // Null check: if (varName !== null) or if (varName != null) + else if condition.contains(var.as_str()) + && (condition.contains("!== null") + || condition.contains("!= null") + || condition.contains("!== undefined") + || condition.contains("!= undefined")) + && let Some(end_line) = find_block_end(&lines, i) + { + guarded_ranges + .entry(var.clone()) + .or_default() + .push((line_num, end_line as u32 + 1)); + } } } } @@ -1288,8 +1281,8 @@ fn find_matching_paren(input: &str, start: usize) -> Option { let bytes = input.as_bytes(); let mut depth = 0; - for i in start..bytes.len() { - match bytes[i] { + for (i, &byte) in bytes.iter().enumerate().skip(start) { + match byte { b'(' => depth += 1, b')' => { depth -= 1; @@ -1348,7 +1341,7 @@ fn validate_method_calls( // Phase 4.5.4: Warn when calling methods on nullable variables // Skip warning if the call is within a guarded scope for this variable if symbols.is_nullable(&call.object) { - let is_guarded = guarded_ranges.get(&call.object).map_or(false, |ranges| { + let is_guarded = guarded_ranges.get(&call.object).is_some_and(|ranges| { ranges .iter() .any(|(start, end)| call.line >= *start && call.line <= *end) @@ -1467,9 +1460,10 @@ fn validate_function_call_params( || name_lower == "options" || name_lower == "config" || name_lower == "settings" - || param.param_type.as_ref().map_or(false, |t| { - t == "Object" || t == "object" || t.starts_with("{") - }); + || param + .param_type + .as_ref() + .is_some_and(|t| t == "Object" || t == "object" || t.starts_with("{")); if is_options_param { continue; // Skip validation - single options object pattern } @@ -1521,10 +1515,10 @@ fn validate_void_returns(source: &str, context: &ValidationContext) -> Vec Vec = Vec::new(); diff --git a/src/sandbox/runtime/modules/native-globals/Cargo.toml b/src/sandbox/runtime/modules/native-globals/Cargo.toml index 02d2f5b..11f0757 100644 --- a/src/sandbox/runtime/modules/native-globals/Cargo.toml +++ b/src/sandbox/runtime/modules/native-globals/Cargo.toml @@ -3,5 +3,8 @@ name = "native-globals" version = "0.1.0" edition = "2021" +[lints.rust] +unexpected_cfgs = { level = "allow", check-cfg = ['cfg(hyperlight)'] } + [dependencies] rquickjs = { version = "0.11", default-features = false, features = ["bindgen", "macro"] } diff --git a/src/sandbox/runtime/modules/native-globals/src/lib.rs b/src/sandbox/runtime/modules/native-globals/src/lib.rs index 5d33eb4..734a7ff 100644 --- a/src/sandbox/runtime/modules/native-globals/src/lib.rs +++ b/src/sandbox/runtime/modules/native-globals/src/lib.rs @@ -59,7 +59,10 @@ fn b64_decode_char(c: u8) -> Option { fn rust_atob(encoded: String) -> QjsResult { // Strip whitespace (browsers are lenient) - let clean: Vec = encoded.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + let clean: Vec = encoded + .bytes() + .filter(|b| !b.is_ascii_whitespace()) + .collect(); let mut bytes = Vec::new(); let mut i = 0; @@ -152,7 +155,8 @@ pub fn setup_globals(ctx: &Ctx<'_>) -> QjsResult<()> { // We capture the Rust fn in a closure so it survives cleanup. let encode_fn = Function::new(ctx.clone(), text_encoder_encode)?; globals.set("__ha_encode", encode_fn)?; - ctx.eval::<(), _>(r#" + ctx.eval::<(), _>( + r#" (function() { const encode = globalThis.__ha_encode; globalThis.TextEncoder = function TextEncoder() { @@ -169,7 +173,8 @@ pub fn setup_globals(ctx: &Ctx<'_>) -> QjsResult<()> { }; delete globalThis.__ha_encode; })(); - "#)?; + "#, + )?; // ── TextDecoder constructor ────────────────────────────────── // Rust function handles the UTF-8 decoding, JS wraps it. @@ -216,13 +221,15 @@ pub fn setup_globals(ctx: &Ctx<'_>) -> QjsResult<()> { "#)?; // ── queueMicrotask ────────────────────────────────────────── - ctx.eval::<(), _>(r#" + ctx.eval::<(), _>( + r#" if (typeof globalThis.queueMicrotask === 'undefined') { globalThis.queueMicrotask = function(fn) { Promise.resolve().then(fn); }; } - "#)?; + "#, + )?; Ok(()) } diff --git a/src/sandbox/runtime/src/main.rs b/src/sandbox/runtime/src/main.rs index 853d658..dcb9aef 100644 --- a/src/sandbox/runtime/src/main.rs +++ b/src/sandbox/runtime/src/main.rs @@ -14,10 +14,10 @@ #![cfg_attr(hyperlight, no_main)] use native_deflate::js_deflate; +use native_globals::setup_globals; use native_html::js_html; use native_image::js_image; use native_markdown::js_markdown; -use native_globals::setup_globals; // Register native modules into the global registry. // Built-in modules (io, crypto, console, require) are inherited automatically. diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index b589235..a5d20c0 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -6,7 +6,7 @@ Vitest test suite for Hyperagent. ```bash just test # Run all TypeScript tests -just test-rust # Run Rust tests (analysis-guest) +just test-analysis-guest # Run Rust tests (analysis-guest) just test-all # Run both ```