From 5e167b68c35be1a31b00ac24225181b82cc98a4c Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 02:47:28 +0900 Subject: [PATCH 01/21] fix(extractor): report original parse and module reference locations Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/diagnostics_tests.rs | 46 ++++++++ libs/extractor/src/lib.rs | 58 ++++++++-- libs/extractor/src/module_reference.rs | 95 ++++++++++++++++ libs/extractor/src/module_reference_tests.rs | 111 +++++++++++++++++++ 4 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 libs/extractor/src/diagnostics_tests.rs create mode 100644 libs/extractor/src/module_reference.rs create mode 100644 libs/extractor/src/module_reference_tests.rs diff --git a/libs/extractor/src/diagnostics_tests.rs b/libs/extractor/src/diagnostics_tests.rs new file mode 100644 index 000000000..f164a42ae --- /dev/null +++ b/libs/extractor/src/diagnostics_tests.rs @@ -0,0 +1,46 @@ +use crate::{ExtractOption, extract_without_source_map}; +use serial_test::serial; + +#[test] +#[serial] +fn fatal_parse_reports_original_file_and_first_parser_error() { + let source = "import { Box } from '@devup-ui/react';\nexport const view = ;"; + + let error = extract_without_source_map("/src/broken.tsx", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.tsx:2:31:"), "{error}"); + assert!(error.contains("Unterminated regular expression"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[test] +#[serial] +fn unsupported_extension_reports_the_input_file_and_repair() { + let source = "import { Box } from '@devup-ui/react';"; + + let error = extract_without_source_map("/src/broken.unknown", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/broken.unknown:1:1:"), "{error}"); + assert!(error.contains("Unknown file extension"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("\n")] +#[case("\r")] +#[case("\r\n")] +#[case("\u{2028}")] +#[case("\u{2029}")] +fn locations_follow_javascript_line_terminators(#[case] separator: &str) { + let source = format!("first{separator}한😀x"); + + let location = crate::locate("/src/unicode.ts", &source, source.len()); + + assert_eq!(location, "/src/unicode.ts:2:4"); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index d3c60b13a..036be01b7 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -5,6 +5,8 @@ mod component; mod composition; mod css_prop; mod css_utils; +#[cfg(test)] +mod diagnostics_tests; pub mod extract_style; mod extractor; mod gen_class_name; @@ -12,6 +14,7 @@ mod gen_style; mod import_alias_visit; mod imported_constants; mod module_loader; +pub mod module_reference; mod mutations; mod prop_modify_utils; mod prop_valid; @@ -434,7 +437,11 @@ fn extract_source( let code_to_parse = processed_code.as_deref().unwrap_or(&transformed_code); - let source_type = SourceType::from_path(filename)?; + let source_type = SourceType::from_path(filename).map_err(|error| { + format!( + "{filename}:1:1: {error}. Fix: use a supported JavaScript or TypeScript file extension" + ) + })?; let (bucket, global, css_file) = resolve_css_target(filename, &option); let import_main = option.import_main_css && !global; // Presize to the exact final length (1 target + optional main-css entry) and @@ -450,10 +457,30 @@ fn extract_source( let ParserReturn { mut program, // AST fatal_error, // Parser encountered an error it couldn't recover from + diagnostics, .. } = Parser::new(&allocator, code_to_parse, source_type).parse(); if fatal_error { - return Err("Parser panicked".into()); + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + let errors = diagnostics + .iter() + .take(1) + .map(|error| { + ( + error + .labels + .first() + .map_or(0, oxc_span::LabeledSpan::offset), + format!( + "Cannot parse source: {error}. Fix: correct the syntax at this location" + ), + ) + }) + .collect(); + return Err(located_errors(filename, source, &edits, errors).into()); } let inlined = if processed_code.is_none() { imported_constants::inline_constants( @@ -622,12 +649,23 @@ fn located_errors( /// `filename:line:column` of `offset` in `source` fn locate(filename: &str, source: &str, offset: usize) -> String { let before = source.get(..offset).unwrap_or(source); - let line_start = before.rfind('\n').map_or(0, |index| index + 1); - format!( - "{filename}:{}:{}", - before.matches('\n').count() + 1, - before[line_start..].chars().count() + 1 - ) + let (mut line, mut column) = (1, 1); + let mut characters = before.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\r' => { + characters.next_if_eq(&'\n'); + line += 1; + column = 1; + } + '\n' | '\u{2028}' | '\u{2029}' => { + line += 1; + column = 1; + } + _ => column += 1, + } + } + format!("{filename}:{line}:{column}") } /// A line for each binding `message` names that code changes, telling where @@ -4863,7 +4901,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string() - .starts_with("Unknown file extension") + .starts_with("test.wrong:1:1: Unknown file extension") ); reset_class_map(); @@ -4882,7 +4920,7 @@ import clsx from 'clsx' ) .unwrap_err() .to_string(), - "Parser panicked" + "test.tsx:1:11: Cannot parse source: Expected `from` but found `string`. Fix: correct the syntax at this location" ); } diff --git a/libs/extractor/src/module_reference.rs b/libs/extractor/src/module_reference.rs new file mode 100644 index 000000000..ec9100b59 --- /dev/null +++ b/libs/extractor/src/module_reference.rs @@ -0,0 +1,95 @@ +use oxc_allocator::Allocator; +use oxc_ast::{ + AstKind, + ast::{Argument, Statement}, +}; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; + +/// An original module reference's one-based source coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct ModuleReferenceLocation { + pub line: usize, + pub column: usize, +} + +/// Locate all matching imports, re-exports and unshadowed literal `require` calls. +/// +/// Positions point at the original specifier literal, in source order. Empty +/// results mean no matching AST reference exists, not a synthetic `1:1` site. +#[must_use] +pub fn original_module_reference_locations( + filename: &str, + source: &str, + specifier: &str, +) -> Vec { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); + let parsed = Parser::new(&allocator, source, source_type).parse(); + let program = parsed.program; + let mut offsets = Vec::new(); + for statement in &program.body { + let literal = match statement { + Statement::ImportDeclaration(import) => Some(&import.source), + Statement::ExportFromDeclaration(export) => Some(&export.source), + Statement::ExportAllDeclaration(export) => Some(&export.source), + _ => None, + }; + if let Some(literal) = literal + && literal.value == specifier + { + offsets.push(literal.span.start); + } + } + let semantic = SemanticBuilder::new() + .with_build_nodes(true) + .build(&program) + .semantic; + if let Some(references) = semantic + .scoping() + .root_unresolved_references() + .get("require") + { + for reference in references { + let node = semantic.scoping().get_reference(*reference).node_id(); + if let AstKind::CallExpression(call) = semantic.nodes().parent_kind(node) + && let [Argument::StringLiteral(literal)] = call.arguments.as_slice() + && literal.value == specifier + { + offsets.push(literal.span.start); + } + } + } + offsets.sort_unstable(); + offsets.dedup(); + offsets + .into_iter() + .filter_map(|offset| { + let prefix = source.get(..usize::try_from(offset).ok()?)?; + let mut location = ModuleReferenceLocation { line: 1, column: 1 }; + let mut chars = prefix.chars().peekable(); + while let Some(character) = chars.next() { + match character { + '\r' => { + if chars.peek() == Some(&'\n') { + chars.next(); + } + location.line += 1; + location.column = 1; + } + '\n' | '\u{2028}' | '\u{2029}' => { + location.line += 1; + location.column = 1; + } + _ => location.column += 1, + } + } + Some(location) + }) + .collect() +} + +#[cfg(test)] +#[path = "module_reference_tests.rs"] +mod tests; diff --git a/libs/extractor/src/module_reference_tests.rs b/libs/extractor/src/module_reference_tests.rs new file mode 100644 index 000000000..d1d5fdcdb --- /dev/null +++ b/libs/extractor/src/module_reference_tests.rs @@ -0,0 +1,111 @@ +use super::*; + +#[rstest::rstest] +#[case("\n")] +#[case("\r")] +#[case("\r\n")] +#[case("\u{2028}")] +#[case("\u{2029}")] +fn locations_when_extensionless_typescript_uses_javascript_line_separators( + #[case] separator: &str, +) { + // Given + let source = format!( + "type Color = string;{separator}import {{{separator} color{separator}}} from './colors';{separator}const 이름 = '✓🦀'; require('./colors');" + ); + // When + let locations = original_module_reference_locations("theme", &source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { line: 4, column: 8 }, + ModuleReferenceLocation { + line: 5, + column: 26 + }, + ] + ); +} + +#[test] +fn locations_when_unknown_extension_uses_typescript() { + // Given + let source = "type Color = string;\r\nexport { color } from './colors';"; + // When + let locations = original_module_reference_locations("theme.custom", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ModuleReferenceLocation { + line: 2, + column: 23 + }] + ); +} + +#[test] +fn locations_when_typescript_imports_and_reexports_span_lines() { + // Given + let source = "type Color = string;\nimport {\n color\n} from './colors';\nexport { color } from './colors';\nexport * from './colors';"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { line: 4, column: 8 }, + ModuleReferenceLocation { + line: 5, + column: 23 + }, + ModuleReferenceLocation { + line: 6, + column: 15 + }, + ] + ); +} + +#[test] +fn locations_when_only_global_literal_require_is_a_reference() { + // Given + let source = "// require('./colors')\nconst text = \"./colors\";\nfunction f(require) { require('./colors'); }\nconst a = require(\n './colors'\n);\nrequire(variable);\nobj.require('./colors');\nrequire('./other');"; + // When + let locations = original_module_reference_locations("theme.cts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ModuleReferenceLocation { line: 5, column: 2 }] + ); +} + +#[test] +fn locations_when_references_are_absent_are_not_invented() { + // Given + let source = "const text = './colors';"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!(locations, vec![]); +} + +#[test] +fn locations_when_duplicate_specifiers_and_unicode_are_present() { + // Given + let source = "const 이름 = '✓'; require('./colors');\nimport './colors';\nrequire('./colors');"; + // When + let locations = original_module_reference_locations("theme.ts", source, "./colors"); + // Then + assert_eq!( + locations, + vec![ + ModuleReferenceLocation { + line: 1, + column: 25 + }, + ModuleReferenceLocation { line: 2, column: 8 }, + ModuleReferenceLocation { line: 3, column: 9 }, + ] + ); +} From 5574d5532473ba9b1c8b488443e3ca0fa7f56d54 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 03:07:28 +0900 Subject: [PATCH 02/21] fix(extractor): locate deterministic stylesheet evaluation failures Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/build_time_values.rs | 204 ++++----- .../src/build_time_values/scope_tests.rs | 18 + .../src/build_time_values_execute.rs | 146 ++++++ ...uild_time_values_execute_coverage_tests.rs | 26 ++ .../src/build_time_values_mapping.rs | 74 ++++ ...uild_time_values_mapping_coverage_tests.rs | 40 ++ .../src/build_time_values_sandbox_tests.rs | 87 ++++ libs/extractor/src/evaluation_sandbox.rs | 315 +++++++++++++ .../src/evaluation_sandbox/coverage_tests.rs | 148 +++++++ .../src/evaluation_sandbox_guards.rs | 234 ++++++++++ .../extractor/src/evaluation_sandbox_sites.rs | 260 +++++++++++ .../extractor/src/evaluation_sandbox_tests.rs | 268 +++++++++++ libs/extractor/src/lib.rs | 97 ++-- libs/extractor/src/module_loader.rs | 279 ++++++++---- .../src/module_loader/coverage_tests.rs | 65 +++ .../extractor/src/module_loader/operations.rs | 200 +++++++++ .../operations/coverage_tests.rs | 37 ++ libs/extractor/src/module_loader/script.rs | 220 +++++++++ .../module_loader/script/coverage_tests.rs | 98 +++++ .../src/module_loader/script/explain.rs | 166 +++++++ .../script/explain/coverage_tests.rs | 25 ++ .../src/module_loader/script/explain/tests.rs | 135 ++++++ .../src/module_loader/script/retained.rs | 106 +++++ .../script/retained/coverage_tests.rs | 15 + .../src/module_loader/script/tests.rs | 94 ++++ .../src/module_loader/serialization.rs | 60 +++ .../serialization_coverage_tests.rs | 45 ++ libs/extractor/src/module_loader/tests.rs | 323 ++++++++++++++ .../src/module_loader/tests/regressions.rs | 188 ++++++++ .../src/module_loader/tests/sandbox.rs | 124 ++++++ libs/extractor/src/module_loader/validate.rs | 37 ++ libs/extractor/src/source_map.rs | 31 +- libs/extractor/src/source_map/trace.rs | 64 +++ libs/extractor/src/source_map/trace/tests.rs | 51 +++ libs/extractor/src/stylesheet_policy.rs | 274 ++++++++++++ .../src/stylesheet_policy/coverage_tests.rs | 50 +++ .../src/stylesheet_policy_dispatch.rs | 72 +++ .../src/stylesheet_policy_import_tests.rs | 54 +++ libs/extractor/src/stylesheet_policy_tests.rs | 254 +++++++++++ .../extractor/src/stylesheet_policy_values.rs | 258 +++++++++++ .../src/stylesheet_regression_tests.rs | 96 ++++ libs/extractor/src/vanilla_extract.rs | 416 +++++++++++------- .../src/vanilla_extract/coverage_tests.rs | 71 +++ libs/extractor/tests/vanilla_extract.rs | 23 +- 44 files changed, 5441 insertions(+), 407 deletions(-) create mode 100644 libs/extractor/src/build_time_values_execute.rs create mode 100644 libs/extractor/src/build_time_values_execute_coverage_tests.rs create mode 100644 libs/extractor/src/build_time_values_mapping.rs create mode 100644 libs/extractor/src/build_time_values_mapping_coverage_tests.rs create mode 100644 libs/extractor/src/build_time_values_sandbox_tests.rs create mode 100644 libs/extractor/src/evaluation_sandbox.rs create mode 100644 libs/extractor/src/evaluation_sandbox/coverage_tests.rs create mode 100644 libs/extractor/src/evaluation_sandbox_guards.rs create mode 100644 libs/extractor/src/evaluation_sandbox_sites.rs create mode 100644 libs/extractor/src/evaluation_sandbox_tests.rs create mode 100644 libs/extractor/src/module_loader/coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/operations.rs create mode 100644 libs/extractor/src/module_loader/operations/coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/script.rs create mode 100644 libs/extractor/src/module_loader/script/coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/script/explain.rs create mode 100644 libs/extractor/src/module_loader/script/explain/coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/script/explain/tests.rs create mode 100644 libs/extractor/src/module_loader/script/retained.rs create mode 100644 libs/extractor/src/module_loader/script/retained/coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/script/tests.rs create mode 100644 libs/extractor/src/module_loader/serialization.rs create mode 100644 libs/extractor/src/module_loader/serialization_coverage_tests.rs create mode 100644 libs/extractor/src/module_loader/tests.rs create mode 100644 libs/extractor/src/module_loader/tests/regressions.rs create mode 100644 libs/extractor/src/module_loader/tests/sandbox.rs create mode 100644 libs/extractor/src/module_loader/validate.rs create mode 100644 libs/extractor/src/source_map/trace.rs create mode 100644 libs/extractor/src/source_map/trace/tests.rs create mode 100644 libs/extractor/src/stylesheet_policy.rs create mode 100644 libs/extractor/src/stylesheet_policy/coverage_tests.rs create mode 100644 libs/extractor/src/stylesheet_policy_dispatch.rs create mode 100644 libs/extractor/src/stylesheet_policy_import_tests.rs create mode 100644 libs/extractor/src/stylesheet_policy_tests.rs create mode 100644 libs/extractor/src/stylesheet_policy_values.rs create mode 100644 libs/extractor/src/stylesheet_regression_tests.rs create mode 100644 libs/extractor/src/vanilla_extract/coverage_tests.rs diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 38a55b80b..85cde50d7 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -21,6 +21,13 @@ use oxc_syntax::reference::ReferenceId; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +#[path = "build_time_values_execute.rs"] +mod execute; +#[path = "build_time_values_mapping.rs"] +mod mapping; +use execute::compute; +use mapping::Generated; + use crate::stylex::StylexFunction; use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; use crate::{ExtractOption, ModuleResolver}; @@ -279,7 +286,10 @@ impl<'s, 'a> Finder<'s, 'a> { Statement::ExportDefaultDeclaration(export) => { match &export.declaration { ExportDefaultDeclarationKind::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } ExportDefaultDeclarationKind::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -299,13 +309,18 @@ impl<'s, 'a> Finder<'s, 'a> { for identifier in declarator.id.get_binding_identifiers() { declare( identifier.symbol_id.get(), - declaration.kind == VariableDeclarationKind::Const, + declaration.kind == VariableDeclarationKind::Const + && !declaration.declare + && declarator.init.is_some(), ); } } } Declaration::FunctionDeclaration(function) => { - declare(function.id.as_ref().and_then(|id| id.symbol_id.get()), true); + declare( + function.id.as_ref().and_then(|id| id.symbol_id.get()), + function.body.is_some(), + ); } Declaration::ClassDeclaration(class) => { declare(class.id.as_ref().and_then(|id| id.symbol_id.get()), false); @@ -910,22 +925,36 @@ impl<'a> Visit<'a> for Reads<'_> { fn visit_ts_type(&mut self, _: &oxc_ast::ast::TSType<'a>) {} } +/// What evaluating computed: the code with the values written in their place, +/// the replacements made, and the files read +pub(crate) type Evaluated = ( + String, + Vec, + BTreeSet, +); + +/// An error the build reports: the offset in the code evaluated that it names, +/// and what it tells +pub(crate) type Located = (usize, String); + /// `code` with what its style values compute written in their place, the /// replacements made, and the files read; `None` when running the code it /// reads computes none of them as a string, a finite number, or a plain -/// object or array of those -pub(crate) fn evaluate( +/// object or array of those. Code that reads what differs between builds or +/// pages (the clock, chance, the environment, the locale) is an error naming +/// the style value it computes, whether or not the code caught what the read +/// threw. +pub(crate) fn evaluate_located( code: &str, filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, unknown: &crate::imported_constants::Unknown, -) -> Option<( - String, - Vec, - BTreeSet, -)> { - let (mut values, dependencies) = compute(code, filename, option, resolver, unknown)?; +) -> Result, Vec> { + let Some((mut values, dependencies)) = compute(code, filename, option, resolver, unknown)? + else { + return Ok(None); + }; values.sort_unstable_by_key(|(span, _)| span.start); let mut result = String::with_capacity(code.len()); let mut edits = Vec::with_capacity(values.len()); @@ -938,61 +967,17 @@ pub(crate) fn evaluate( copied = end; } result.push_str(&code[copied..]); - Some((result, edits, dependencies)) + Ok(Some((result, edits, dependencies))) } /// A value's source text by the span of the code computing it type Replacement = (Span, String); -/// Run before the values: the code checked to read nothing that differs -/// between builds or pages, nothing does even if the check missed it -/// (reading `Date`, `Math.random`, the environment or the locale throws), and -/// the source text of a value the build can read. A statement that throws is -/// recorded, and a value reading it is not computed -const PRELUDE: &str = r#"delete globalThis.Date; -Math.random = undefined; -for (const name of ["window", "self", "document", "navigator", "location", "history", "localStorage", "sessionStorage", "matchMedia", "screen", "innerWidth", "innerHeight", "devicePixelRatio", "process", "global", "Deno", "Bun", "Intl"]) Object.defineProperty(globalThis, name, { get() { throw new ReferenceError(`${name} is only known at runtime`); }, configurable: true }); -for (const [prototype, names] of [[Object.prototype, ["toLocaleString"]], [Number.prototype, ["toLocaleString"]], [BigInt.prototype, ["toLocaleString"]], [Array.prototype, ["toLocaleString"]], [String.prototype, ["localeCompare", "toLocaleUpperCase", "toLocaleLowerCase", "normalize"]]]) for (const name of names) Object.defineProperty(prototype, name, { value() { throw new ReferenceError(`${name} depends on the locale`); }, configurable: true, writable: true }); -Object.setPrototypeOf(globalThis, new Proxy(Object.getPrototypeOf(globalThis), { get(target, key, receiver) { if (typeof key === "string" && !(key in target)) throw new ReferenceError(`${key} is only known at runtime`); return Reflect.get(target, key, receiver); } })); -const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); -const __failed_statements__ = new Set(); -const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; -const __literal__ = (value) => { - const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) - || (Array.isArray(item) && item.every(plain)) - || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); - if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; - return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; -}; -"#; - -fn compute( - code: &str, - filename: &str, - option: &ExtractOption, - resolver: Option<&ModuleResolver>, - unknown: &crate::imported_constants::Unknown, -) -> Option<(Vec, BTreeSet)> { +/// The statements the values read, run as they are written, and the imports +/// they read as the build knows them: the modules those come from never run +fn definitions(program: &Program<'_>, code: &str, found: &[Found]) -> Generated { use std::fmt::Write; - use boa_engine::{Context, JsObject, Source}; - - let allocator = Allocator::default(); - let program = parse(&allocator, filename, code)?; - let is_style = |source: &str| { - source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) - }; - let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); - let found = find( - &program, - &is_style, - unknown, - &|name| changes.is_changed(name), - &|name| changes.known(name), - ); - if found.is_empty() { - return None; - } let statements: BTreeSet = found .iter() .flat_map(|found| found.closure.statements.iter().copied()) @@ -1002,11 +987,9 @@ fn compute( .flat_map(|found| &found.closure.imports) .map(|(name, value)| (name.as_str(), value.as_str())) .collect(); - // The modules imports come from never run: their values are written as - // the build knows them - let mut module = String::new(); + let mut module = Generated::default(); for (name, value) in imports { - let _ = writeln!(module, "const {name} = {value};"); + let _ = writeln!(module.text, "const {name} = {value};"); } for index in statements { let statement = &program.body[index]; @@ -1025,13 +1008,16 @@ fn compute( let Some(init) = &declarator.init else { continue; }; - let _ = writeln!( - module, - "{} {}__try__(() => ({}), {index});", - declaration.kind.as_str(), - &code[declarator.span.start as usize..init.span().start as usize], - &code[init.span().start as usize..init.span().end as usize], + module.text.push_str(declaration.kind.as_str()); + module.text.push(' '); + module.copy( + code, + declarator.span.start as usize, + init.span().start as usize, ); + module.text.push_str("__try__(() => ("); + module.copy(code, init.span().start as usize, init.span().end as usize); + let _ = writeln!(module.text, "), {index});"); } continue; } @@ -1040,62 +1026,36 @@ fn compute( Statement::ExportDefaultDeclaration(export) => export.declaration.span(), statement => statement.span(), }; - module.push_str(&code[span.start as usize..span.end as usize]); - module.push('\n'); - } - // Each runs apart, so one that throws leaves the others - for (index, found) in found.iter().enumerate() { - let statements: Vec = found - .closure - .statements - .iter() - .map(ToString::to_string) - .collect(); - let _ = writeln!( - module, - "const __value_{index}__ = (() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__(({})); }} catch {{ return undefined; }} }})();", - statements.join(", "), - &code[found.span.start as usize..found.span.end as usize] - ); + module.copy(code, span.start as usize, span.end as usize); + module.text.push('\n'); } - let names: Vec = (0..found.len()) - .map(|index| format!("__value_{index}__")) + module +} + +/// The source text of what `found` computes, or nothing when a statement it +/// reads threw. Each value runs apart, so one that throws leaves the others. +fn value_script(code: &str, found: &Found) -> Generated { + let statements: Vec = found + .closure + .statements + .iter() + .map(ToString::to_string) .collect(); - let _ = writeln!(module, "[{}];", names.join(", ")); - - let script = crate::vanilla_extract::strip_typescript(&module, filename); - let mut context = Context::default(); - context - .runtime_limits_mut() - .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT); - let values = context - .eval(Source::from_bytes(format!("{PRELUDE}{script}").as_bytes())) - .ok()?; - let values = values.as_object().filter(JsObject::is_array)?; - let mut computed = Vec::new(); - for (index, found) in found.into_iter().enumerate() { - let Some(literal) = values - .get(index, &mut context) - .ok()? - .as_string() - .map(|literal| literal.to_std_string_escaped()) - else { - continue; - }; - // A part joined gives rules, or a class as a string - if found.rules_only && !literal.starts_with(['{', '[', '"']) { - continue; - } - computed.push(( - found.span, - match found.shorthand { - Some(key) => format!("{key}: {literal}"), - None => literal, - }, - )); - } - (!computed.is_empty()).then_some((computed, changes.dependencies())) + let mut generated = Generated::default(); + generated.text = format!( + "(() => {{ if ([{}].some((statement) => __failed_statements__.has(statement))) return undefined; try {{ return __literal__((", + statements.join(", ") + ); + generated.copy(code, found.span.start as usize, found.span.end as usize); + generated + .text + .push_str(")); } catch { return undefined; } })();"); + generated } #[cfg(test)] mod scope_tests; + +#[cfg(test)] +#[path = "build_time_values_sandbox_tests.rs"] +mod sandbox_tests; diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs index 310d45aac..5529f9c8c 100644 --- a/libs/extractor/src/build_time_values/scope_tests.rs +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -1,6 +1,24 @@ use super::has_build_time_values; use crate::ExtractOption; +#[test] +fn erased_bindings_are_runtime_only_inputs() { + let option = ExtractOption::default(); + for body in [ + "declare const a: number, b: number; export const x = css({ w: a + b });", + "declare function getHover(): object; export const x = ;", + "export declare function getHover(): object; export const x = ;", + ] { + // Given erased declarations referenced by a style expression. + let code = format!("import {{ css, Box }} from '@devup-ui/react'; {body}"); + // When selecting exact computations, then none may execute those inputs. + assert!( + !has_build_time_values("a.tsx", &code, &option, None), + "{body}" + ); + } +} + #[test] fn a_local_named_like_a_style_api_computes_nothing() { let option = ExtractOption::default(); diff --git a/libs/extractor/src/build_time_values_execute.rs b/libs/extractor/src/build_time_values_execute.rs new file mode 100644 index 000000000..6a019aa9f --- /dev/null +++ b/libs/extractor/src/build_time_values_execute.rs @@ -0,0 +1,146 @@ +//! Execution of the exact value closures selected by the lexical reader. + +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use std::{collections::BTreeSet, path::Path}; + +use super::mapping::Mapped; +use super::{Located, Replacement, definitions, find, parse, value_script}; +use crate::evaluation_sandbox::{Failure, Sandbox}; +use crate::{ExtractOption, ModuleResolver}; + +const HELPERS: &str = r#"const __failed__ = (() => { const fail = () => { throw new ReferenceError("its value threw"); }; return new Proxy(function () {}, { get: fail, apply: fail, construct: fail, getPrototypeOf: fail }); })(); +const __failed_statements__ = new Set(); +const __try__ = (compute, statement) => { try { return compute(); } catch { __failed_statements__.add(statement); return __failed__; } }; +const __literal__ = (value) => { + const plain = (item) => item === undefined || item === null || typeof item === "string" || typeof item === "boolean" || (typeof item === "number" && Number.isFinite(item)) + || (Array.isArray(item) && item.every(plain)) + || (typeof item === "object" && Object.getPrototypeOf(item) === Object.prototype && Object.getOwnPropertySymbols(item).length === 0 && !Object.prototype.hasOwnProperty.call(item, "__proto__") && Object.values(item).every(plain)); + if (typeof value === "number") return Number.isFinite(value) ? String(value) : undefined; + return value !== undefined && plain(value) ? JSON.stringify(value) : undefined; +}; +"#; + +const HELPERS_SOURCE: &str = ""; +const DEFINITIONS_SOURCE: &str = ""; +const VALUE_SOURCE: &str = ""; +type Computed = (Vec, BTreeSet); + +fn stop( + failure: Failure, + scripts: &[Mapped], + code: &str, +) -> Result, Vec> { + let violations = match failure { + Failure::Forbidden(violations) => violations, + Failure::Js(_) => return Ok(None), + }; + let mut errors: Vec = violations + .iter() + .map(|violation| { + let offset = scripts.iter().find_map(|script| script.locate(violation)); + let Some(offset) = offset else { + return (0, violation.error().to_string()); + }; + let tail = &code[offset..]; + let read = tail + .split_once('\n') + .map_or(tail, |(line, _)| line) + .trim_end_matches(';'); + ( + offset, + format!( + "`{}` cannot use `{}` at build time: {}", + violation.name(), + read, + violation.requirement() + ), + ) + }) + .collect(); + errors.dedup(); + Err(errors) +} + +pub(super) fn compute( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + unknown: &crate::imported_constants::Unknown, +) -> Result, Vec> { + let allocator = Allocator::default(); + let Some(program) = parse(&allocator, filename, code) else { + return Ok(None); + }; + let is_style = |source: &str| { + source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) + }; + let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); + let found = find( + &program, + &is_style, + unknown, + &|name| changes.is_changed(name), + &|name| changes.known(name), + ); + if found.is_empty() { + return Ok(None); + } + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| vec![(0, error.to_string())])?; + if let Err(failure) = sandbox.run_source( + &mut context, + Source::from_bytes(HELPERS).with_path(Path::new(HELPERS_SOURCE)), + ) { + return stop(failure, &[], code); + } + let module = definitions(&program, code, &found); + let mut scripts = vec![Mapped::new(module, filename, DEFINITIONS_SOURCE)]; + sandbox + .prepare(&mut context, &scripts[0].instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + if let Err(failure) = sandbox.run_source( + &mut context, + Source::from_bytes(&scripts[0].instrumented.code).with_path(Path::new(DEFINITIONS_SOURCE)), + ) { + return stop(failure, &scripts, code); + } + let mut computed = Vec::new(); + for found in found { + let value_source = format!("{VALUE_SOURCE}:{}", found.span.start); + let script = Mapped::new(value_script(code, &found), filename, &value_source); + sandbox + .prepare(&mut context, &script.instrumented) + .map_err(|error| vec![(0, error.to_string())])?; + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script.instrumented.code).with_path(Path::new(&value_source)), + ); + scripts.push(script); + let value = match result { + Ok(value) => value, + Err(failure) => return stop(failure, &scripts, code), + }; + let Some(literal) = value + .as_string() + .map(|literal| literal.to_std_string_escaped()) + else { + continue; + }; + if found.rules_only && !literal.starts_with(['{', '[', '"']) { + continue; + } + computed.push(( + found.span, + match found.shorthand { + Some(key) => format!("{key}: {literal}"), + None => literal, + }, + )); + } + Ok((!computed.is_empty()).then_some((computed, changes.dependencies()))) +} + +#[cfg(test)] +mod build_time_values_execute_coverage_tests; diff --git a/libs/extractor/src/build_time_values_execute_coverage_tests.rs b/libs/extractor/src/build_time_values_execute_coverage_tests.rs new file mode 100644 index 000000000..195d7b482 --- /dev/null +++ b/libs/extractor/src/build_time_values_execute_coverage_tests.rs @@ -0,0 +1,26 @@ +use boa_engine::{Context, Source}; + +use super::stop; +use crate::evaluation_sandbox::Sandbox; + +#[test] +fn execution_failure_preserves_guard_error_when_no_generated_script_can_locate_it() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("guard succeeded")?; + // When + let errors = stop(failure, &[], "Date.now()") + .err() + .ok_or("failure was lost")?; + // Then + assert_eq!(errors.len(), 1); + assert_eq!(errors[0].0, 0); + assert!(errors[0].1.contains("`Date`")); + assert!(errors[0].1.starts_with("ReferenceError:")); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_mapping.rs b/libs/extractor/src/build_time_values_mapping.rs new file mode 100644 index 000000000..00606cc7b --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping.rs @@ -0,0 +1,74 @@ +//! Original-source tracing for generated build-time helper statements. + +use crate::evaluation_sandbox::{Violation, instrument}; +use crate::source_map::{Lines, Trace}; + +#[derive(Default)] +pub(super) struct Generated { + pub(super) text: String, + copied: Vec<(usize, usize, usize)>, +} + +impl Generated { + pub(super) fn copy(&mut self, source: &str, from: usize, to: usize) { + let start = self.text.len(); + self.text.push_str(&source[from..to]); + self.copied.push((start, self.text.len(), from)); + } + + fn locate(&self, offset: usize) -> Option { + self.copied + .iter() + .find(|(start, end, _)| (*start..*end).contains(&offset)) + .map(|(start, _, original)| original + offset - start) + } +} + +pub(super) struct Mapped { + generated: Generated, + stripped: std::rc::Rc, + trace: Trace, + path: String, + pub(super) instrumented: crate::evaluation_sandbox::Instrumented, +} + +impl Mapped { + pub(super) fn new(generated: Generated, filename: &str, path: &str) -> Self { + let stripped = crate::vanilla_extract::strip_typescript_marked(&generated.text, filename); + let trace = Trace::new(&stripped.marks, &[]); + let instrumented = instrument(&stripped.code, path); + Self { + generated, + stripped, + trace, + path: path.to_string(), + instrumented, + } + } + + pub(super) fn locate(&self, violation: &Violation) -> Option { + let offset = match violation.site() { + Some((place, offset)) if place.starts_with(&format!("{}:", self.path)) => offset, + Some(_) => return None, + None => { + let error = self.instrumented.explain(&violation.error().to_string()); + let frame = error.lines().find_map(|line| { + line.split_once(&format!("({}:", self.path)) + .map(|(_, position)| position) + })?; + let (line, column) = frame.trim_end_matches(')').split_once(':')?; + Lines::new(&self.stripped.code).code_point_offset( + line.parse::().ok()?.saturating_sub(1), + column.parse::().ok()?.saturating_sub(1), + ) + } + }; + self.generated.locate( + self.trace + .resolve(&self.stripped.code, &self.generated.text, offset), + ) + } +} + +#[cfg(test)] +mod build_time_values_mapping_coverage_tests; diff --git a/libs/extractor/src/build_time_values_mapping_coverage_tests.rs b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs new file mode 100644 index 000000000..7345f4f0e --- /dev/null +++ b/libs/extractor/src/build_time_values_mapping_coverage_tests.rs @@ -0,0 +1,40 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; + +use super::{Generated, Mapped}; +use crate::evaluation_sandbox::{Failure, Sandbox}; + +#[rstest] +#[case("Date.now()", "Date")] +#[case("Math.random()", "Math.random")] +fn fallback_frames_trace_to_copied_source_when_execution_has_no_instrumentation( + #[case] read: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let script = format!("function read() {{ return {read}; }} read();"); + let original = format!("// original\n{script}"); + let start = original.find(&script).ok_or("missing fixture read")?; + let mut generated = Generated::default(); + generated.copy(&original, start, original.len()); + let mapped = Mapped::new(generated, "boundary.ts", "mapped.js"); + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source( + &mut context, + Source::from_bytes(&script).with_path(std::path::Path::new("mapped.js")), + ); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads[0].name(), name); + assert_eq!( + mapped.locate(&reads[0]), + Some(original.find('{').ok_or("missing original function body")?), + "{}", + reads[0].error() + ); + Ok(()) +} diff --git a/libs/extractor/src/build_time_values_sandbox_tests.rs b/libs/extractor/src/build_time_values_sandbox_tests.rs new file mode 100644 index 000000000..177a8e3dc --- /dev/null +++ b/libs/extractor/src/build_time_values_sandbox_tests.rs @@ -0,0 +1,87 @@ +use rstest::rstest; + +fn evaluate(code: &str) -> Result, Vec<(usize, String)>> { + super::evaluate_located( + code, + "a.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .map(|evaluated| evaluated.map(|(code, ..)| code)) +} + +const COMPARE: &str = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\n"; + +#[test] +fn a_later_value_reports_its_own_read_offset() -> Result<(), String> { + // Given two separate computed values, with the forbidden read in the second. + let code = "import { css } from '@devup-ui/react';\nexport const a = css({ w: Number('2'), h: Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value('a', 'b') });"; + // When evaluation records the read. + let errors = evaluate(code).err().ok_or("evaluation succeeded")?; + // Then its location names the second value, not the first generated script. + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("missing read")? + ); + Ok(()) +} + +#[rstest] +#[case("export const a = css({ w: compare('a', 'b') });")] +#[case( + "const sorted = ['b', 'a'].map((item) => compare(item, 'a'));\nexport const a = css({ w: sorted[0] });" +)] +fn forbidden_helper_references_report_the_helper_not_the_style_value( + #[case] tail: &str, +) -> Result<(), String> { + let code = format!("{COMPARE}{tail}"); + let errors = evaluate(&code).err().ok_or("evaluation succeeded")?; + assert_eq!(errors.len(), 1); + assert_eq!( + errors[0].0, + code.find("Object.getOwnPropertyDescriptor") + .ok_or("fixture occurrence missing")? + ); + assert!(errors[0].1.contains("String.prototype.localeCompare")); + assert!(!errors[0].1.contains("Fix:")); + assert!( + errors[0] + .1 + .contains("cannot use `Object.getOwnPropertyDescriptor") + ); + Ok(()) +} + +#[test] +fn deterministic_code_still_computes_its_values() -> Result<(), String> { + let code = "import { css } from '@devup-ui/react';\nconst double = (n) => Math.max(n, 1) * 2;\nexport const a = css({ w: double(2) });"; + let result = evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .ok_or("no computed value")?; + assert!(result.contains("w: 4")); + Ok(()) +} + +#[rstest] +#[case( + "import { css } from '@devup-ui/react';\nconst fail = () => { throw 1; };\nexport const a = css({ w: fail() });" +)] +#[case("import { css } from '@devup-ui/react';\nconst NaN = 1;\nexport const a = css({ w: NaN });")] +#[case( + "import { css } from '@devup-ui/react';\ndeclare const a: number, b: number;\nexport const x = css({ w: a + b });" +)] +#[case( + "import { Box } from '@devup-ui/react';\ndeclare function getHover(): object;\nexport const x = ;" +)] +fn a_definition_that_fails_for_another_reason_leaves_runtime_values( + #[case] code: &str, +) -> Result<(), String> { + assert!( + evaluate(code) + .map_err(|errors| format!("{errors:?}"))? + .is_none() + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox.rs b/libs/extractor/src/evaluation_sandbox.rs new file mode 100644 index 000000000..e32c2cdf9 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox.rs @@ -0,0 +1,315 @@ +//! The deterministic Boa sandbox every build-time evaluation runs in. +//! +//! What the build computes must be the same on every build and page, so what +//! differs between them (the clock, chance, the page or process running the +//! code, the locale) cannot be read. The host records each such read before +//! it throws, so code catching the error cannot hide it: a run that recorded +//! one fails whatever its code did after, and the engine's call frames of the +//! read are kept for the error that names where it happened. +//! +//! The sandbox is set up by native code and by one small source of its own, +//! [`INTERNAL_SOURCE`], so no frame of the code that runs is shifted by it. + +use std::cell::RefCell; +use std::path::Path; +use std::rc::Rc; + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, + Source, js_string, + object::{FunctionObjectBuilder, builtins::JsProxy}, + parser::source::ReadChar, + property::Attribute, +}; + +/// The path of the source setting the sandbox up: its call frames are the +/// sandbox's own, not the code's +pub(crate) const INTERNAL_SOURCE: &str = ""; + +/// Loop iterations an evaluation may run before it fails instead of hanging +/// the build +const LOOP_ITERATION_LIMIT: u64 = 10_000_000; + +/// What a read gives that only the build, the page or the engine decides +#[derive(Clone, Copy)] +enum Kind { + Clock, + Random, + Environment, + Locale, +} + +impl Kind { + const fn requirement(self) -> &'static str { + match self { + Self::Clock => "the time it gives differs on every build", + Self::Random => "the chance it gives differs on every build", + Self::Environment => "only the page or process running the code knows it", + Self::Locale => "its result depends on the locale and the engine's Unicode data", + } + } + + /// Of what `name` is: a global or `owner.member`, or `Type.prototype.method` + fn of(name: &str) -> Self { + match name.split('.').next().unwrap_or_default() { + "Date" | "performance" => Self::Clock, + "crypto" | "Math" => Self::Random, + "Intl" | "Temporal" => Self::Locale, + _ if name.contains(".prototype.") => Self::Locale, + _ => Self::Environment, + } + } +} + +#[path = "evaluation_sandbox_guards.rs"] +mod guards; +use guards::{GUARDED_GLOBALS, SETUP, guard_methods}; + +#[path = "evaluation_sandbox_sites.rs"] +mod sites; +pub(crate) use sites::{Instrumented, instrument}; + +struct Recorded { + name: String, + error: JsValue, + site: Option<(String, u32)>, +} + +/// What the host holds of the forbidden reads a run made, out of the code's +/// reach +#[derive(Clone, Default)] +struct Evidence { + reads: Rc>>, + site: Rc>>, + methods: Rc>>, +} + +/// A forbidden read the code made +pub(crate) struct Violation { + name: String, + kind: Kind, + error: JsError, + site: Option<(String, u32)>, +} + +impl Violation { + /// What was read, as the code writes it (`Date`, `String.prototype.normalize`) + pub(crate) fn name(&self) -> &str { + &self.name + } + + /// Why the read cannot be known at build time + pub(crate) const fn requirement(&self) -> &'static str { + self.kind.requirement() + } + + /// The error with an instrumented read frame, or the engine's actual call frames. + pub(crate) const fn error(&self) -> &JsError { + &self.error + } + + /// The original offset of an instrumented read and the source containing it. + pub(crate) fn site(&self) -> Option<(&str, usize)> { + self.site + .as_ref() + .map(|(place, offset)| (place.as_str(), *offset as usize)) + } +} + +/// Why a run failed +pub(crate) enum Failure { + /// The code read what the build cannot know, however it was written: + /// every read it made is listed + Forbidden(Vec), + /// The code failed for another reason + Js(JsError), +} + +/// The deterministic environment of one [`Context`], and the evidence of the +/// reads it forbade +pub(crate) struct Sandbox { + evidence: Evidence, +} + +fn record(context: &mut Context, name: &str, site: Option<(String, u32)>) -> JsValue { + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let error = JsNativeError::reference() + .with_message(format!( + "`{name}` cannot be read at build time: {}{frame}", + Kind::of(name).requirement() + )) + .into_opaque(context); + let error = JsValue::from(error); + if let Some(evidence) = context.get_data::() { + evidence.reads.borrow_mut().push(Recorded { + name: name.to_string(), + error: error.clone(), + site, + }); + } + error +} + +/// The recorder called by the guards written in [`SETUP`]. +fn forbid_native(_: &JsValue, args: &[JsValue], context: &mut Context) -> JsResult { + let name = args.get_or_undefined(0).to_string(context)?; + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Ok(record(context, &name.to_std_string_escaped(), site)) +} + +/// A function the realm owns +fn function(context: &Context, name: &str, native: NativeFunction) -> JsObject { + let realm = context.realm().clone(); + FunctionObjectBuilder::new(&realm, native) + .name(JsString::from(name)) + .build() + .into() +} + +fn guard_globals(context: &mut Context) -> JsResult<()> { + let setup = context.eval(Source::from_reader( + SETUP.as_bytes(), + Some(Path::new(INTERNAL_SOURCE)), + ))?; + let forbid = function( + context, + "forbid", + NativeFunction::from_fn_ptr(forbid_native), + ); + let config = JsValue::from_json(&serde_json::json!({ "names": GUARDED_GLOBALS }), context)?; + setup + .to_object(context)? + .call(&JsValue::undefined(), &[forbid.into(), config], context)?; + Ok(()) +} + +/// A console whose calls do nothing, as what a module logs changes no value +fn install_console(context: &mut Context) -> JsResult<()> { + let target = JsObject::with_null_proto(); + let console = JsProxy::builder(target) + .get(|_, _, context| { + let call = NativeFunction::from_copy_closure(|_, _, _| Ok(JsValue::undefined())); + Ok(function(context, "", call).into()) + }) + .build(context)?; + context.register_global_property( + js_string!("console"), + console, + Attribute::WRITABLE | Attribute::CONFIGURABLE, + ) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_tests.rs"] +mod tests; + +impl Sandbox { + /// Installs the instrumented source's native identity/thunk helper. + pub(crate) fn prepare(&self, context: &mut Context, source: &Instrumented) -> JsResult<()> { + // A new script has no read site until its instrumentation establishes one. + *self.evidence.site.borrow_mut() = None; + context.register_global_builtin_callable( + JsString::from(source.helper.as_str()), + 3, + NativeFunction::from_copy_closure_with_captures( + |_, args, sites, context| { + let index = args.get_or_undefined(0).to_u32(context)? as usize; + if let Some((place, offset)) = sites.get(index) + && let Some(evidence) = context.get_data::() + { + *evidence.site.borrow_mut() = Some((place.clone(), *offset)); + } + let value = args.get_or_undefined(1); + if args.get_or_undefined(2).to_boolean() { + value + .to_object(context)? + .call(&JsValue::undefined(), &[], context) + } else { + Ok(value.clone()) + } + }, + source.sites.clone(), + ), + ) + } + + /// Makes `context` deterministic: the clock, chance, the environment and + /// the locale cannot be read, an unbounded loop fails instead of hanging + /// the build, and `console` calls do nothing + pub(crate) fn new(context: &mut Context) -> JsResult { + context + .runtime_limits_mut() + .set_loop_iteration_limit(LOOP_ITERATION_LIMIT); + let evidence = Evidence::default(); + context.insert_data(evidence.clone()); + install_console(context)?; + guard_methods(context)?; + guard_globals(context)?; + Ok(Self { evidence }) + } + + /// Runs `source`, failing if it or anything run before it read what the + /// build cannot know, whether or not its code caught the error. A source + /// with a path names its call frames by it. + pub(crate) fn run_source( + &self, + context: &mut Context, + source: Source<'_, R>, + ) -> Result { + match context.eval(source) { + Ok(value) => self.check(&[]).map(|()| value), + Err(error) => { + self.check(&[&error])?; + Err(Failure::Js(error)) + } + } + } + + /// Fails if a forbidden read was recorded. `errors` are the errors the host + /// got back from code it ran: one a read threw gives that read its call + /// frames when the code did not rethrow it first, so a host that serializes + /// the values the code made passes the errors that failed it here. + pub(crate) fn check(&self, errors: &[&JsError]) -> Result<(), Failure> { + let recorded = self.evidence.reads.borrow(); + if recorded.is_empty() { + return Ok(()); + } + Err(Failure::Forbidden( + recorded + .iter() + .map(|read| Violation { + kind: Kind::of(&read.name), + name: read.name.clone(), + site: read.site.clone(), + error: match &read.site { + Some((place, _)) => JsNativeError::reference() + .with_message(format!( + "`{}` cannot be read at build time: {}\n at ({place})", + read.name, + Kind::of(&read.name).requirement() + )) + .into(), + None => errors + .iter() + .find(|error| { + error + .as_opaque() + .is_some_and(|thrown| thrown.strict_equals(&read.error)) + }) + .map_or_else( + || JsError::from_opaque(read.error.clone()), + |error| (*error).clone(), + ), + }, + }) + .collect(), + )) + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/evaluation_sandbox/coverage_tests.rs b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs new file mode 100644 index 000000000..e3f9cda28 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox/coverage_tests.rs @@ -0,0 +1,148 @@ +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox, instrument}; + +fn evaluate(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument(script, "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + match sandbox.run_source(&mut context, Source::from_bytes(&source.code)) { + Ok(value) => value + .to_string(&mut context) + .map(|text| text.to_std_string_escaped()) + .map_err(|error| error.to_string()), + Err(Failure::Js(error)) => Err(error.to_string()), + Err(Failure::Forbidden(reads)) => Err(format!( + "forbidden: {}", + reads + .iter() + .map(super::Violation::name) + .collect::>() + .join(",") + )), + } +} + +#[rstest] +#[case("Math.random = () => 0.25; Math.random()", "0.25")] +#[case( + "String.prototype.normalize = function() { return this + '!'; }; 'a'.normalize()", + "a!" +)] +#[case( + "const o = { x: 4 }; Object.getOwnPropertyDescriptor(o, 'x').value", + "4" +)] +#[case("Object.getOwnPropertyDescriptor({}, 'missing')", "undefined")] +#[case( + "const o = { get x() { return 5 } }; Object.getOwnPropertyDescriptor(o, 'x').get.call(o)", + "5" +)] +#[case("const key = 'x'; const { [key]: value } = { x: 6 }; value", "6")] +#[case("const { ['x']: value } = { x: 8 }; value", "8")] +#[case( + "const o = { n: 2, add(v) { this.n += v; return this.n } }; o?.add?.(3)", + "5" +)] +#[case("let n = 1; eval('n += 2'); n", "3")] +fn semantics_survive_instrumentation_when_values_are_local( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let result = evaluate(script)?; + // Then + assert_eq!(result, expected); + Ok(()) +} + +#[rstest] +#[case("({ window })", "window")] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "Object.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +fn reflection_and_shorthand_fail_when_they_reach_real_guards( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given / When + let error = evaluate(script) + .err() + .ok_or("guard did not reject the read")?; + // Then + assert_eq!(error, format!("forbidden: {name}")); + Ok(()) +} + +#[rstest] +#[case("Date.now()", true)] +#[case("try { Date.now() } catch {} 4", false)] +fn uninstrumented_reads_keep_evidence_when_errors_are_thrown_or_caught( + #[case] script: &str, + #[case] thrown: bool, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = sandbox.run_source(&mut context, Source::from_bytes(script)); + let Err(Failure::Forbidden(reads)) = result else { + return Err("missing forbidden read".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), "Date"); + assert_eq!(reads[0].site(), None); + assert!(reads[0].error().as_opaque().is_some()); + if thrown { + assert!(reads[0].error().to_string().contains("
")); + } + Ok(()) +} + +#[test] +fn prepare_helper_returns_identity_when_an_index_has_no_read_site() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = instrument("1", "boundary.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let value = context + .eval(Source::from_bytes(&format!("{}(999, 7)", source.helper))) + .map_err(|error| error.to_string())?; + // Then + assert!(value.strict_equals(&JsValue::from(7))); + assert!(sandbox.check(&[]).is_ok()); + Ok(()) +} + +#[test] +fn ordinary_javascript_failures_remain_errors_instead_of_becoming_forbidden_reads() +-> Result<(), String> { + // Given / When + let error = evaluate("throw new TypeError('local failure')") + .err() + .ok_or("throw succeeded")?; + // Then + assert!(error.starts_with("TypeError: local failure")); + Ok(()) +} + +#[rstest] +#[case("TypeError: bad\n at eval (boundary.js:unknown)")] +#[case("TypeError: bad\n at eval (boundary.js:x:y)")] +fn frame_rebasing_preserves_foreign_coordinates_when_eval_has_no_numeric_site(#[case] error: &str) { + // Given + let source = instrument("eval('throw new Error()')", "boundary.js"); + // When / Then + assert_eq!(source.explain(error), error); +} diff --git a/libs/extractor/src/evaluation_sandbox_guards.rs b/libs/extractor/src/evaluation_sandbox_guards.rs new file mode 100644 index 000000000..2a18af4d6 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_guards.rs @@ -0,0 +1,234 @@ +//! What the sandbox forbids reading, and the source that guards the globals. + +use super::{Evidence, function, record}; + +pub(super) const EXACT_GLOBALS: &[&str] = &[ + "undefined", + "NaN", + "Infinity", + "globalThis", + "Math", + "Object", + "Array", + "String", + "Number", + "Boolean", + "BigInt", + "Symbol", + "JSON", + "Reflect", + "Proxy", + "Promise", + "Map", + "Set", + "WeakMap", + "WeakSet", + "Error", + "TypeError", + "ReferenceError", + "SyntaxError", + "RangeError", + "URIError", + "EvalError", + "Uint8Array", + "Uint8ClampedArray", + "Uint16Array", + "Uint32Array", + "Int8Array", + "Int16Array", + "Int32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", + "ArrayBuffer", + "DataView", + "parseInt", + "parseFloat", + "isNaN", + "isFinite", + "encodeURI", + "decodeURI", + "encodeURIComponent", + "decodeURIComponent", + "eval", + "console", +]; +use boa_engine::{ + Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, js_string, + property::PropertyDescriptor, +}; + +fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) -> JsResult<()> { + let (owner, method) = label; + let guard = function( + context, + method, + NativeFunction::from_copy_closure_with_captures( + |_, _, name: &String, context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + Err(JsError::from_opaque(record(context, name, site))) + }, + format!("{owner}.{method}"), + ), + ); + let setter = function( + context, + "set", + NativeFunction::from_copy_closure_with_captures( + |this, args, method: &String, context| { + this.to_object(context)?.define_property_or_throw( + JsString::from(method.as_str()), + PropertyDescriptor::builder() + .value(args.get_or_undefined(0).clone()) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + Ok(JsValue::undefined()) + }, + method.to_string(), + ), + ); + holder.define_property_or_throw( + JsString::from(method), + PropertyDescriptor::builder() + .get(guard.clone()) + .set(setter) + .enumerable(false) + .configurable(true), + context, + )?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((guard, format!("{owner}.{method}"))); + } + Ok(()) +} + +pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { + let math = context.intrinsics().objects().math(); + guard_method(context, &math, ("Math", "random"))?; + for (owner, methods) in LOCALE_METHODS { + let constructors = context.intrinsics().constructors(); + let holder = match owner { + "Object" => constructors.object(), + "Number" => constructors.number(), + "BigInt" => constructors.bigint(), + "Array" => constructors.array(), + "TypedArray" => constructors.typed_array(), + _ => constructors.string(), + } + .prototype(); + for method in methods { + guard_method(context, &holder, (&format!("{owner}.prototype"), method))?; + } + } + guard_descriptor(context) +} + +fn guard_descriptor(context: &mut Context) -> JsResult<()> { + let object = context.intrinsics().constructors().object().constructor(); + let original = object + .get(js_string!("getOwnPropertyDescriptor"), context)? + .to_object(context)?; + let wrapper = function( + context, + "getOwnPropertyDescriptor", + NativeFunction::from_copy_closure_with_captures( + |this, args, original: &JsObject, context| { + let value = original.call(this, args, context)?; + if let Some(descriptor) = value.as_object() { + let getter = descriptor.get(js_string!("get"), context)?; + let name = context.get_data::().and_then(|evidence| { + evidence + .methods + .borrow() + .iter() + .find(|(method, _)| { + getter.as_object().is_some_and(|getter| getter == *method) + }) + .map(|(_, name)| name.clone()) + }); + if let Some(name) = name { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + return Err(JsError::from_opaque(record(context, &name, site))); + } + } + Ok(value) + }, + original, + ), + ); + object.define_property_or_throw( + js_string!("getOwnPropertyDescriptor"), + PropertyDescriptor::builder() + .value(wrapper) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + Ok(()) +} + +/// Globals the engine gives whose values depend on the locale or the clock; +/// any other global nothing declares is the environment's +pub(super) const GUARDED_GLOBALS: [&str; 5] = ["Date", "Intl", "performance", "crypto", "Temporal"]; + +/// Methods giving what the locale or the engine's Unicode data make them, by +/// the prototype holding them +pub(super) const LOCALE_METHODS: [(&str, &[&str]); 6] = [ + ("Object", &["toLocaleString"]), + ("Number", &["toLocaleString"]), + ("BigInt", &["toLocaleString"]), + ("Array", &["toLocaleString"]), + ("TypedArray", &["toLocaleString"]), + ( + "String", + &[ + "localeCompare", + "toLocaleUpperCase", + "toLocaleLowerCase", + "normalize", + ], + ), +]; + +/// Guards the globals with `forbid`, which records a read and gives the error +/// that fails it. Assigning a guarded global gives the code a value of its +/// own. Globals nothing declares are guarded alike, through the prototype of +/// the global object. +/// Keep function bodies and descriptors out of nested call arguments: Boa's +/// debug parser otherwise exhausts the default Windows main-thread stack. +pub(super) const SETUP: &str = r#"(function (forbid, config) { + const global = globalThis; + function assign(object, name, value) { + const descriptor = { value, writable: true, enumerable: true, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + function read(object, name, label) { + const get = function () { throw forbid(label); }; + const set = function (value) { assign(object, name, value); }; + const descriptor = { get, set, enumerable: false, configurable: true }; + Object.defineProperty(object, name, descriptor); + } + for (const name of config.names) read(global, name, name); + function get(target, key, receiver) { + if (typeof key === "string" && !(key in target)) throw forbid(key); + return Reflect.get(target, key, receiver); + } + function has(target, key) { + return (typeof key === "string" && !(key in target)) || Reflect.has(target, key); + } + const handler = { get, has }; + const prototype = new Proxy(Object.getPrototypeOf(global), handler); + Object.setPrototypeOf(global, prototype); +})"#; diff --git a/libs/extractor/src/evaluation_sandbox_sites.rs b/libs/extractor/src/evaluation_sandbox_sites.rs new file mode 100644 index 000000000..73730357c --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites.rs @@ -0,0 +1,260 @@ +//! Lexically bound source instrumentation for exact sandbox read sites. + +use std::hash::{Hash, Hasher}; + +use oxc_ast::ast::{Expression, IdentifierReference, ObjectProperty}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::{Scoping, SemanticBuilder}; +use oxc_span::{GetSpan, SourceType, Span}; + +/// Source edits and read coordinates, reusable before module mapper code generation. +pub(crate) struct Instrumented { + pub(crate) code: String, + pub(crate) edits: Vec, + pub(super) sites: Vec<(String, u32)>, + pub(super) helper: String, + source: String, + path: String, +} + +struct Reads<'s> { + scoping: &'s Scoping, + source: &'s str, + helper: &'s str, + changes: Vec<(Span, String)>, + sites: Vec<(String, u32)>, + path: &'s str, +} + +impl Reads<'_> { + fn site(&mut self, span: Span) -> usize { + let id = self.sites.len(); + self.sites.push(( + crate::locate(self.path, self.source, span.start as usize), + span.start, + )); + id + } + + fn external(&self, identifier: &IdentifierReference<'_>) -> bool { + let Some(reference) = identifier.reference_id.get() else { + return false; + }; + let reference = self.scoping.get_reference(reference); + reference.symbol_id().is_none() + && reference.is_read() + && !reference.is_write() + && !super::guards::EXACT_GLOBALS.contains(&identifier.name.as_str()) + } + + fn write(&mut self, span: Span) { + let id = self.site(span); + self.changes.push(( + Span::new(span.start, span.start), + format!("({}({id}, undefined), ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Reads<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if self.external(identifier) { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!("({}({id}, () => {}, true))", self.helper, identifier.name), + )); + } + } + + fn visit_object_property(&mut self, property: &ObjectProperty<'a>) { + if property.shorthand + && let Expression::Identifier(identifier) = &property.value + && self.external(identifier) + { + let id = self.site(identifier.span); + self.changes.push(( + identifier.span, + format!( + "{}: {}({id}, () => {}, true)", + identifier.name, self.helper, identifier.name + ), + )); + } else { + walk::walk_object_property(self, property); + } + } + + fn visit_static_member_expression( + &mut self, + member: &oxc_ast::ast::StaticMemberExpression<'a>, + ) { + let id = self.site(member.span); + let access = Span::new(member.object.span().end, member.property.span.end); + let optional = if member.optional { "?." } else { "" }; + self.changes.push(( + access, + format!( + "{optional}[{}({id}, \"{}\")]", + self.helper, member.property.name + ), + )); + walk::walk_static_member_expression(self, member); + } + + fn visit_computed_member_expression( + &mut self, + member: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + // The key may itself read globals; establish the member site after it evaluates. + let id = self.site(member.span); + let key = member.expression.span(); + self.changes.push(( + Span::new(key.start, key.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(key.end, key.end), ")".to_string())); + walk::walk_computed_member_expression(self, member); + } + + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.visit_expression(&call.callee); + for (index, argument) in call.arguments.iter().enumerate() { + if index + 1 == call.arguments.len() && !argument.is_spread() { + let id = self.site(call.span); + let span = argument.span(); + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } + self.visit_argument(argument); + } + } + + fn visit_binding_property(&mut self, property: &oxc_ast::ast::BindingProperty<'a>) { + let id = self.site(property.key.span()); + let span = property.key.span(); + let written = &self.source[span.start as usize..span.end as usize]; + if property.computed { + self.changes.push(( + Span::new(span.start, span.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(span.end, span.end), ")".to_string())); + } else { + let key = match &property.key { + oxc_ast::ast::PropertyKey::StaticIdentifier(_) => format!("\"{written}\""), + _ => written.to_string(), + }; + let binding = if property.shorthand { + format!(": {written}") + } else { + String::new() + }; + self.changes + .push((span, format!("[{}({id}, {key})]{binding}", self.helper))); + } + walk::walk_binding_property(self, property); + } + + fn visit_assignment_expression(&mut self, assignment: &oxc_ast::ast::AssignmentExpression<'a>) { + self.write(assignment.span); + self.visit_assignment_target(&assignment.left); + self.visit_expression(&assignment.right); + } + + fn visit_update_expression(&mut self, update: &oxc_ast::ast::UpdateExpression<'a>) { + self.write(update.span); + walk::walk_update_expression(self, update); + } +} + +/// Adds identity calls to member keys and thunk calls around unbound identifiers. +/// Receivers, short-circuit optional chains and direct `eval` calls are unchanged. +pub(crate) fn instrument(source: &str, path: &str) -> Instrumented { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let mut hash = rustc_hash::FxHasher::default(); + source.hash(&mut hash); + path.hash(&mut hash); + let mut helper = format!("__devup_read_site_{:x}__", hash.finish()); + while source.contains(&helper) { + helper.push('_'); + } + let mut reads = Reads { + scoping: &scoping, + source, + helper: &helper, + changes: Vec::new(), + sites: Vec::new(), + path, + }; + if parsed.diagnostics.is_empty() { + reads.visit_program(&parsed.program); + } + reads + .changes + .sort_by_key(|(span, _)| (span.start, span.end)); + let mut code = String::with_capacity(source.len()); + let mut edits = Vec::new(); + let mut copied = 0; + for (span, text) in reads.changes { + let (start, end) = (span.start as usize, span.end as usize); + code.push_str(&source[copied..start]); + code.push_str(&text); + edits.push((start, end, text.len())); + copied = end; + } + code.push_str(&source[copied..]); + Instrumented { + code, + edits, + sites: reads.sites, + helper, + source: source.to_string(), + path: path.to_string(), + } +} + +impl Instrumented { + /// Maps engine frames to the pre-instrumentation text; explicit read frames already name it. + pub(crate) fn explain(&self, error: &str) -> String { + error + .lines() + .map(|line| { + if line.contains("at (") { + return line.to_string(); + } + let marker = format!("({}:", self.path); + let Some((prefix, position)) = line.rsplit_once(&marker) else { + return line.to_string(); + }; + let Some((row, column)) = position.trim_end_matches(')').split_once(':') else { + return line.to_string(); + }; + let (Ok(row), Ok(column)) = (row.parse::(), column.parse::()) else { + return line.to_string(); + }; + let offset = crate::source_map::Lines::new(&self.code) + .code_point_offset(row.saturating_sub(1), column.saturating_sub(1)); + let offset = crate::import_alias_visit::source_offset(&self.edits, offset); + format!( + "{prefix}({})", + crate::locate(&self.path, &self.source, offset) + ) + }) + .collect::>() + .join("\n") + } +} diff --git a/libs/extractor/src/evaluation_sandbox_tests.rs b/libs/extractor/src/evaluation_sandbox_tests.rs new file mode 100644 index 000000000..9abd5c3f5 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests.rs @@ -0,0 +1,268 @@ +use boa_engine::{Context, JsValue, Source, js_string}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) +} + +fn text(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = match sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("unexpected forbidden read".to_string()), + }; + value + .to_string(&mut context) + .map(|value| value.to_std_string_escaped()) + .map_err(|error| error.to_string()) +} + +fn reads(script: &str) -> Result, String> { + match run(script) { + Err(Failure::Forbidden(violations)) => Ok(violations + .iter() + .map(|violation| violation.name().to_string()) + .collect()), + Err(Failure::Js(error)) => Err(format!("failed another way: {error}")), + Ok(_) => Err("read nothing forbidden".to_string()), + } +} + +#[test] +fn deterministic_computation_and_console_calls_run() -> Result<(), String> { + assert_eq!( + text( + "console.log('a'); console.table(1); `${Math.max(1, 2)}${JSON.stringify({ a: [1] })}${'b'.toUpperCase()}`" + )?, + r#"2{"a":[1]}B"# + ); + Ok(()) +} + +#[rstest] +#[case("Date.now()", "Date")] +#[case("new Date()", "Date")] +#[case("Date()", "Date")] +#[case("Date.UTC(2020, 0)", "Date")] +#[case("globalThis['Da' + 'te'].parse('x')", "Date")] +#[case("const clock = Date; clock.now()", "Date")] +#[case("Math.random()", "Math.random")] +#[case("Math.random", "Math.random")] +#[case("performance.now()", "performance")] +#[case("performance.timeOrigin", "performance")] +#[case("performance.memory", "performance")] +#[case("crypto.randomUUID()", "crypto")] +#[case("crypto.getRandomValues(new Uint8Array(1))", "crypto")] +#[case("crypto.subtle", "crypto")] +#[case("typeof window", "window")] +#[case("typeof process", "process")] +#[case("navigator.language", "navigator")] +#[case("fetch('x')", "fetch")] +#[case("new Intl.NumberFormat('en')", "Intl")] +#[case("Intl.getCanonicalLocales('en')", "Intl")] +#[case("Boolean(Intl)", "Intl")] +#[case("typeof Intl", "Intl")] +#[case("typeof Date", "Date")] +#[case("typeof Temporal", "Temporal")] +#[case("(1).toLocaleString()", "Number.prototype.toLocaleString")] +#[case("[1].toLocaleString()", "Array.prototype.toLocaleString")] +#[case("({}).toLocaleString()", "Object.prototype.toLocaleString")] +#[case("1n.toLocaleString()", "BigInt.prototype.toLocaleString")] +#[case( + "new Uint8Array(1).toLocaleString()", + "TypedArray.prototype.toLocaleString" +)] +#[case("'a'.localeCompare('b')", "String.prototype.localeCompare")] +#[case("'a'.toLocaleUpperCase()", "String.prototype.toLocaleUpperCase")] +#[case("'A'.toLocaleLowerCase()", "String.prototype.toLocaleLowerCase")] +#[case("'a'.normalize()", "String.prototype.normalize")] +#[case("const normalize = 'a'.normalize; 1", "String.prototype.normalize")] +#[case( + "const key = 'normalize'; const normalize = 'a'[key]; 1", + "String.prototype.normalize" +)] +#[case("const global = globalThis; global['Intl']", "Intl")] +#[case("setTimeout", "setTimeout")] +#[case("typeof require", "require")] +#[case("const { random } = Math; 1", "Math.random")] +#[case("const { normalize: method } = 'a'; 1", "String.prototype.normalize")] +#[case("eval('typeof Intl')", "Intl")] +fn a_forbidden_read_fails_the_run(#[case] script: &str, #[case] name: &str) -> Result<(), String> { + assert_eq!(reads(script)?, [name]); + Ok(()) +} + +#[rstest] +#[case("try { Date.now() } catch { 1 }")] +#[case("try { Math.random() } catch (error) { throw new Error('other') }")] +#[case("try { window } catch {} 'fine'")] +#[case("const read = () => { try { return performance.now() } catch { return 0 } }; read()")] +#[case("function f() { return Date.now() } try { f() } catch (error) { delete error.message }")] +#[case("({ get a() { return Date.now() } }).a")] +#[case("JSON.stringify({ get a() { return Math.random() } })")] +#[case("[1, 2].map(() => crypto.getRandomValues(new Uint8Array(1)))")] +fn a_caught_forbidden_read_still_fails_the_run(#[case] script: &str) -> Result<(), String> { + assert_eq!(reads(script)?.len(), 1, "{script}"); + Ok(()) +} + +#[test] +fn every_read_is_listed_in_order() -> Result<(), String> { + assert_eq!( + reads( + "try { Date.now() } catch {} try { window } catch {} try { 'a'.normalize() } catch {}" + )?, + ["Date", "window", "String.prototype.normalize"] + ); + Ok(()) +} + +#[test] +fn code_cannot_clear_the_evidence() -> Result<(), String> { + assert_eq!(reads("try { Date.now() } catch {} const global = globalThis; for (const key of Reflect.ownKeys(global)) { try { delete global[key] } catch {} } Object.freeze(Object.prototype); 1")?.first().map(String::as_str), Some("Date")); + Ok(()) +} + +#[rstest] +#[case( + "const history = [1, 2]; var screen = 'big'; globalThis.document = { a: 1 }; const Date = 7; `${history.length}${screen}${document.a}${Date}`", + "2big17" +)] +#[case("const Intl = 3; Boolean(Intl)", "true")] +#[case( + "const o = { value: 7, get read() { return this.value }, method() { return this.read } }; o.method()", + "7" +)] +#[case("let o = null; o?.normalize()", "undefined")] +#[case("let o = null; o?.x.y", "undefined")] +#[case("let o = null; o?.['x'].y()", "undefined")] +#[case("let o = null; o?.['x']?.y", "undefined")] +#[case("const o = { normalize() { return 4 } }; o.normalize()", "4")] +#[case("let x = 1; eval('x = 3'); x", "3")] +#[case( + "let o = { x: 1 }; o.x++; o['x'] = 4; delete o.x; JSON.stringify(o)", + "{}" +)] +#[case("const { x = 4, y: value } = { y: 3 }; `${x}${value}`", "43")] +#[case("const Intl = 3; eval('Intl')", "3")] +fn declared_values_and_receivers_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + assert_eq!(text(script)?, expected); + Ok(()) +} + +#[test] +fn reads_in_a_host_run_function_are_found_by_the_check() -> Result<(), String> { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument("({ get width() { return Date.now() } })", "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) + .map_err(|_| "creation failed".to_string())?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())?; + let error = stringify + .to_object(&mut context) + .map_err(|error| error.to_string())? + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("serialization succeeded")?; + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("no violation".to_string()); + }; + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), "Date"); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(24)); + Ok(()) +} + +#[rstest] +#[case("function helper() {\n return typeof window\n}\nhelper()", "window")] +#[case( + "function helper() {\n return Date\n}\ntry { helper() } catch {}", + "Date" +)] +#[case( + "function helper() {\n return globalThis['Intl']\n}\nhelper()", + "globalThis" +)] +fn a_read_reports_its_actual_original_site( + #[case] script: &str, + #[case] occurrence: &str, +) -> Result<(), String> { + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("no violation".to_string()); + }; + let offset = script + .find(occurrence) + .ok_or("fixture occurrence missing")?; + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + assert!(violations[0].error().to_string().contains(&crate::locate( + "fixture.js", + script, + offset + ))); + Ok(()) +} + +#[test] +fn other_failures_stay_failures_of_their_own() { + assert!(matches!( + run("throw new Error('boom')"), + Err(Failure::Js(_)) + )); + assert!(matches!(run("for (;;) {}"), Err(Failure::Js(_)))); + assert!(matches!(run("missing.read"), Err(Failure::Forbidden(_)))); + assert!(matches!(run("let a = ;"), Err(Failure::Js(_)))); +} + +#[test] +fn generated_call_frames_map_back_to_original_columns() -> Result<(), String> { + let script = "const value = { fail() { throw new TypeError('bad') } };\nvalue.fail();"; + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let source = super::instrument(script, "fixture.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let Err(Failure::Js(error)) = sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(std::path::Path::new("fixture.js")), + ) else { + return Err("no TypeError".to_string()); + }; + let mapped = source.explain(&error.to_string()); + assert!(mapped.contains("TypeError: bad"), "{mapped}"); + assert!(mapped.contains("fixture.js:2:11"), "{mapped}"); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 036be01b7..d0dc8f5b9 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -7,6 +7,7 @@ mod css_prop; mod css_utils; #[cfg(test)] mod diagnostics_tests; +mod evaluation_sandbox; pub mod extract_style; mod extractor; mod gen_class_name; @@ -22,6 +23,9 @@ mod scope; mod source_map; mod style_values; mod styled_reads; +mod stylesheet_policy; +#[cfg(test)] +mod stylesheet_regression_tests; mod stylex; mod tailwind; mod util_type; @@ -361,13 +365,28 @@ fn extract_source( } let mut dependencies = std::collections::BTreeSet::new(); - let mut evaluation_error = None; // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). - let processed_code: Option = if utils::is_vanilla_extract_file(filename) { + let processed_code: Option = if utils::is_vanilla_extract_file(filename) + && stylesheet_policy::plan(&transformed_code, filename, &option, resolver, &|_| false) + == stylesheet_policy::Plan::Run + { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let layers: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); // Use transformed code (with imports already pointing to @devup-ui/react) - match vanilla_extract::execute_stylesheet(&transformed_code, filename, &option, resolver) { + match vanilla_extract::execute_located( + vanilla_extract::Stylesheet { + filename, + code: &transformed_code, + source, + edits: &layers, + }, + &option, + resolver, + ) { Ok((collected, imports)) => { dependencies = imports.dependencies; // Keyframes names are generated, so extract the referenced ones @@ -405,21 +424,7 @@ fn extract_source( .collect() }) } - // A stylesheet another one imports must give its own values, and an - // import cycle read too early fails as it does in ES modules, so both - // are reported rather than hidden behind plain extraction - Err(error) - if module_loader::loading_for_stylesheet() - || error.contains(module_loader::IMPORT_CYCLE) => - { - return Err(error.into()); - } - // Plain extraction still compiles Devup UI's own APIs; the error is - // reported when calls it cannot compile remain - Err(error) => { - evaluation_error = Some(error); - None - } + Err(error) => return Err(error.into()), } } else { None @@ -513,23 +518,36 @@ fn extract_source( // No element took the `css` prop the text seemed to give return Ok(unchanged()); } - if let Some(error) = evaluation_error - && imports_uncompiled(&program, &option.package) - { - return Err(error.into()); - } // Run the code a value computes, or tell rules the module computes from a // class it composes if (!visitor.errors.is_empty() || visitor.composes_unknown) && !values_run && !utils::is_vanilla_extract_file(filename) - && let Some((computed, value_edits, read)) = build_time_values::evaluate( + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( &transformed_code, filename, &option, resolver, &inlined.unknown, ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? { let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); let layers: Vec<&[import_alias_visit::Edit]> = @@ -608,21 +626,6 @@ fn main_css_path(css_dir: &str) -> String { /// kept for parse/sourcemap. Global (shared-chunk) files are emitted like /// single-css: into devup-ui.css with prefix-less global naming, so styles /// shared across routes ship once. -/// Whether `program` still imports a value from `package` that extraction did -/// not compile away -fn imports_uncompiled(program: &oxc_ast::ast::Program<'_>, package: &str) -> bool { - program.body.iter().any(|statement| { - matches!(statement, oxc_ast::ast::Statement::ImportDeclaration(import) - if import.source.value == package - && !import.import_kind.is_type() - && import.specifiers.iter().flatten().any(|specifier| matches!( - specifier, - oxc_ast::ast::ImportDeclarationSpecifier::ImportSpecifier(named) - if !named.import_kind.is_type() - ))) - }) -} - /// `errors` in source order, one per line, each led by `filename:line:column` /// of the code it is about; each layer of `edits`, last made first, maps the /// offsets back to `source` @@ -13737,7 +13740,8 @@ globalCss({ .map(|error| error.to_string()) .unwrap_or_default(); assert!( - error.starts_with("JS execution error: TypeError"), + error.starts_with("broken.css.ts:3:") + && error.contains(": JS execution error: TypeError"), "{error}" ); @@ -20820,7 +20824,6 @@ export const b = 2 ? 1 : 2} />;" .is_ok() ); for code in [ - "const Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", "const Math = { PI: 3 };\nexport const a = css({ width: Math.PI, height: Math.max(1, 2) });", "import Math from './math';\nexport const a = css({ width: Math.max(1, 2) });", "export const a = (Math) => css({ width: Math.max(1, 2) });", @@ -20837,6 +20840,18 @@ export const b = 2 ? 1 : 2} />;" .unwrap_or_default(); assert!(message.contains("Math."), "{code}: {message}"); } + reset_class_map(); + reset_file_map(); + let computed = extract( + "test.tsx", + "import { css } from '@devup-ui/react';\nconst Math = { max: () => 1 };\nexport const a = css({ width: Math.max(1, 2) });", + ExtractOption::default(), + ) + .unwrap(); + assert_eq!( + static_values(&computed), + BTreeSet::from([("width".to_string(), "4px".to_string())]) + ); } #[test] diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index ff8b315d0..a3a9348c4 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -1,6 +1,7 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::fmt::Write; +use std::rc::Rc; use oxc_allocator::Allocator; use oxc_ast::AstKind; @@ -10,22 +11,23 @@ use oxc_ast::ast::{ }; use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::{GetSpan, SourceType, Span}; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; -/// The object the package's API is bound to while a stylesheet runs -pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; +pub(crate) mod operations; +mod script; +#[cfg(test)] +mod tests; +mod validate; -/// A console whose calls do nothing, as what a module logs while it runs -/// changes no value -pub(crate) const CONSOLE: &str = "if (typeof console === \"undefined\") globalThis.console = new Proxy({}, { get: () => () => undefined });\n"; +pub(crate) use script::{Body, Origin, SCRIPT_PATH, Script, Unit}; +pub(crate) use validate::validate; -/// Loop iterations an evaluation may run before it fails instead of hanging -/// the build -pub(crate) const LOOP_ITERATION_LIMIT: u64 = 10_000_000; +/// The object the package's API is bound to while a stylesheet runs +pub(crate) const PACKAGE_BINDING: &str = "__vanilla_extract__"; /// The end of the error a module read before its evaluation throws pub(crate) const IMPORT_CYCLE: &str = "before its initialization: it is part of an import cycle"; @@ -54,12 +56,6 @@ impl Drop for Evaluating { } } -/// Whether a stylesheet is being evaluated, so the one extracted now is loaded -/// by it and must not fall back to plain extraction -pub(crate) fn loading_for_stylesheet() -> bool { - EVALUATING.with_borrow(|stack| !stack.is_empty()) -} - /// Whether the stylesheet evaluated now is one another evaluation imports pub(crate) fn evaluating_import() -> bool { EVALUATING.with_borrow(|stack| stack.len() > 1) @@ -69,13 +65,50 @@ fn is_evaluating(filename: &str) -> bool { EVALUATING.with_borrow(|stack| stack.iter().any(|entry| entry == filename)) } +/// Why a module could not be loaded +enum LoadError { + NoResolver, + Unresolved, + /// The module's own error, told where it is already + Failed(String), +} + +impl LoadError { + /// The error told for the import of `specifier` by `importer` at the place + /// `place` gives + fn describe(self, specifier: &str, importer: &str, place: impl FnOnce() -> String) -> String { + match self { + Self::NoResolver => format!( + "{}: Cannot load '{specifier}' without a module resolver. Fix: build through a Devup UI plugin, which resolves imports, or write what '{specifier}' provides in this file", + place() + ), + Self::Unresolved => format!( + "{}: Cannot resolve '{specifier}' from '{importer}'. Fix: check that '{specifier}' exists with a supported extension and that the bundler's resolver can find it from this file", + place() + ), + Self::Failed(error) => error, + } + } +} + +/// A module's definition, as a part of the script +enum Definition { + Generated(String), + Module { + head: String, + body: String, + tail: String, + origin: Rc, + }, +} + /// Loads what a stylesheet imports, each module once, as JavaScript that /// defines the module's exports object ahead of the stylesheet pub(crate) struct ModuleLoader<'r> { resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption, /// Definitions of the loaded modules, each after the ones it uses - definitions: Vec, + definitions: Vec, loaded: FxHashMap, /// `(path, name)` of the modules being defined, outermost first loading: Vec<(String, String)>, @@ -111,18 +144,33 @@ impl<'r> ModuleLoader<'r> { } } - /// The loaded modules' definitions, to run before the stylesheet - pub(crate) fn prelude(&self) -> String { - self.definitions.concat() + /// The script to run: the loaded modules' definitions, then the `entry` + /// stylesheet + pub(crate) fn script(&self, entry: &ModuleScript) -> Script { + let mut script = Script::default(); + for definition in &self.definitions { + match definition { + Definition::Generated(text) => script.generated(text), + Definition::Module { + head, + body, + tail, + origin, + } => { + script.generated(head); + script.body(body, origin); + script.generated(tail); + } + } + } + script.body(&entry.body, &entry.origin); + script } /// The name of the exports object of `specifier` imported by `importer` - fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { - let resolver = self - .resolver - .ok_or_else(|| format!("Cannot load '{specifier}' without a module resolver"))?; - let module = resolver(specifier, importer) - .ok_or_else(|| format!("Cannot resolve '{specifier}' from '{importer}'"))?; + fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { + let resolver = self.resolver.ok_or(LoadError::NoResolver)?; + let module = resolver(specifier, importer).ok_or(LoadError::Unresolved)?; let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { self.keep_import(specifier); @@ -140,12 +188,13 @@ impl<'r> ModuleLoader<'r> { // Created before the modules it imports, so a cycle among them can // reach it; reading it before it starts evaluating is an error if self.definitions.is_empty() { - self.definitions.push(MODULE_HELPER.to_string()); + self.definitions + .push(Definition::Generated(MODULE_HELPER.to_string())); } - self.definitions.push(format!( + self.definitions.push(Definition::Generated(format!( "const {name}$ = __module__({:?});\nconst {name} = {name}$.module;\n", module.path - )); + ))); if is_evaluating(&module.path) { // The stylesheet importing it is evaluated on its own, so it never // starts here @@ -157,7 +206,7 @@ impl<'r> ModuleLoader<'r> { self.loading.push((module.path.clone(), name.clone())); let defined = self.define(&name, module, stylesheet, resolver); let (path, _) = self.loading.pop().unwrap_or_default(); - defined?; + defined.map_err(LoadError::Failed)?; self.loaded.insert(path, name.clone()); Ok(name) } @@ -169,24 +218,30 @@ impl<'r> ModuleLoader<'r> { stylesheet: bool, resolver: &ModuleResolver, ) -> Result<(), String> { - let code = if stylesheet { + validate(crate::vanilla_extract::Stylesheet { + filename: &module.path, + code: &module.code, + source: &module.code, + edits: &[], + })?; + let unit = if stylesheet { // Extracted the way the bundler extracts it, so the names it // exports are the ones its own CSS uses let output = crate::extract_with_source_map( &module.path, &module.code, self.option.clone(), - false, + true, Some(resolver), ) .map_err(|error| error.to_string())?; + let unit = Unit::retained(&module.path, &output, &module.code)?; self.dependencies.extend(output.dependencies); - output.code + unit } else { - module.code + Unit::written(&module.path, &module.code, &module.code, &[])? }; - let script = crate::vanilla_extract::strip_typescript(&code, &module.path); - let module_script = module_script(&script, &module.path, self, false)?; + let module_script = module_script(&unit, self, false)?; // Live bindings: a read before the binding is initialized fails as it // does in an ES module let getters: Vec = module_script @@ -200,10 +255,16 @@ impl<'r> ModuleLoader<'r> { // Its exports are what `module.exports` holds once it ran; the // default follows bundler interop (`__esModule` marks a compiled // ES module) - self.definitions.push(format!( - "(function () {{\n{name}$.start();\nconst module = {{ exports: {{}} }};\nconst exports = module.exports;\n{}\nconst e = module.exports;\nObject.defineProperty({name}, \"__exports__\", {{ value: e }});\nif (e !== null && (typeof e === \"object\" || typeof e === \"function\")) for (const key of Object.keys(e)) if (key !== \"default\") Object.defineProperty({name}, key, {{ get: () => e[key], enumerable: true }});\nObject.defineProperty({name}, \"default\", {{ value: e !== null && typeof e === \"object\" && e.__esModule ? e.default : e, enumerable: true }});\n}})();\n", - module_script.body, - )); + self.definitions.push(Definition::Module { + head: format!( + "(function () {{\n{name}$.start();\nconst module = {{ exports: {{}} }};\nconst exports = module.exports;\n" + ), + body: module_script.body, + tail: format!( + "\nconst e = module.exports;\nObject.defineProperty({name}, \"__exports__\", {{ value: e }});\nif (e !== null && (typeof e === \"object\" || typeof e === \"function\")) for (const key of Object.keys(e)) if (key !== \"default\") Object.defineProperty({name}, key, {{ get: () => e[key], enumerable: true }});\nObject.defineProperty({name}, \"default\", {{ value: e !== null && typeof e === \"object\" && e.__esModule ? e.default : e, enumerable: true }});\n}})();\n" + ), + origin: module_script.origin, + }); return Ok(()); } let mut spreads = String::new(); @@ -213,11 +274,15 @@ impl<'r> ModuleLoader<'r> { "for (const key of Object.keys({spread})) if (key !== \"default\" && !(key in {name})) Object.defineProperty({name}, key, {{ get: () => {spread}[key], enumerable: true }});" ); } - self.definitions.push(format!( - "(function () {{\n{name}$.start();\nObject.defineProperties({name}, {{ {} }});\n{spreads}{}\n}})();\n", - getters.join(", "), - module_script.body, - )); + self.definitions.push(Definition::Module { + head: format!( + "(function () {{\n{name}$.start();\nObject.defineProperties({name}, {{ {} }});\n{spreads}", + getters.join(", ") + ), + body: module_script.body, + tail: "\n})();\n".to_string(), + origin: module_script.origin, + }); Ok(()) } } @@ -226,6 +291,8 @@ impl<'r> ModuleLoader<'r> { /// keywords dropped pub(crate) struct ModuleScript { pub body: String, + /// What `body` was made of, to tell where its code was written + pub origin: Rc, /// `(exported name, expression)` exports: Vec<(String, String)>, /// Modules re-exported whole @@ -234,20 +301,60 @@ pub(crate) struct ModuleScript { commonjs: bool, } +/// The replacements of a module's code, applied as its statements are copied +struct Rewrites<'s> { + script: &'s str, + /// `(start, end, replacement)`, by start + list: Vec<(u32, u32, String)>, +} + +impl Rewrites<'_> { + /// Copies the text of `span` into `body`, replacements made + fn write(&self, body: &mut Body, span: Span) { + let mut copied = span.start as usize; + for (start, end, replacement) in &self.list { + if *start >= span.start && *end <= span.end { + body.copy(self.script, copied, *start as usize); + body.synthesize(*start as usize, replacement); + copied = *end as usize; + } + } + body.copy(self.script, copied, span.end as usize); + } +} + pub(crate) fn module_script( - script: &str, - filename: &str, + unit: &Rc, loader: &mut ModuleLoader<'_>, entry: bool, ) -> Result { + let (script, filename) = (unit.script(), unit.filename()); + let load_module = |loader: &mut ModuleLoader<'_>, specifier: &str, at: u32| { + loader + .load(specifier, filename, entry) + .map_err(|error| error.describe(specifier, filename, || unit.place(at as usize))) + }; let allocator = Allocator::default(); - let program = Parser::new(&allocator, script, SourceType::mjs()) - .parse() - .program; - let semantic = SemanticBuilder::new() + let parsed = Parser::new(&allocator, script, SourceType::mjs()).parse(); + let program = parsed.program; + let built = SemanticBuilder::new() .with_build_nodes(true) - .build(&program) - .semantic; + .with_check_syntax_error(true) + .build(&program); + if let Some(error) = parsed + .diagnostics + .iter() + .chain(built.diagnostics.iter()) + .next() + { + return Err(format!( + "{}: JS execution error: SyntaxError: {error}. Fix: correct the syntax at this location", + unit.place(error.labels.first().map_or(0, |label| { + usize::try_from(label.offset()).unwrap_or(script.len()) + })) + )); + } + let semantic = built.semantic; let package = loader.option.package.clone(); // Imports of a module still evaluating are read where they are used, as ES @@ -270,7 +377,7 @@ pub(crate) fn module_script( let module = if source == package { PACKAGE_BINDING.to_string() } else { - loader.load(source, filename, entry)? + load_module(loader, source, import.source.span.start)? }; if loader.pending.contains(&module) { let lazy_module = &module; @@ -329,7 +436,7 @@ pub(crate) fn module_script( if let AstKind::CallExpression(call) = semantic.nodes().parent_kind(node) && let [Argument::StringLiteral(specifier)] = call.arguments.as_slice() { - let module = loader.load(specifier.value.as_str(), filename, entry)?; + let module = load_module(loader, specifier.value.as_str(), specifier.span.start)?; replacements.push(( call.span.start, call.span.end, @@ -339,24 +446,16 @@ pub(crate) fn module_script( } } replacements.sort_by_key(|(start, ..)| *start); - let text = |span: oxc_span::Span| { - let mut code = String::new(); - let mut copied = span.start; - for (start, end, replacement) in &replacements { - if *start >= span.start && *end <= span.end { - code.push_str(&script[copied as usize..*start as usize]); - code.push_str(replacement); - copied = *end; - } - } - code.push_str(&script[copied as usize..span.end as usize]); - code + let rewrites = Rewrites { + script, + list: replacements, }; - let mut body = String::with_capacity(script.len()); + let mut body = Body::new(script.len()); let mut exports = Vec::new(); let mut spreads = Vec::new(); for statement in &program.body { + let end = statement.span().end as usize; match statement { Statement::ImportDeclaration(import) => { let Some(module) = modules.get(&import.span.start) else { @@ -365,6 +464,7 @@ pub(crate) fn module_script( if loader.pending.contains(module) { continue; } + let at = import.span.start as usize; let mut named = Vec::new(); for specifier in import.specifiers.iter().flatten() { match specifier { @@ -375,17 +475,23 @@ pub(crate) fn module_script( named.push(format!("\"default\": {}", specifier.local.name)); } ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => { - let _ = writeln!(body, "const {} = {module};", specifier.local.name); + body.synthesize( + at, + &format!("const {} = {module};\n", specifier.local.name), + ); } } } if !named.is_empty() { - let _ = writeln!(body, "const {{ {} }} = {module};", named.join(", ")); + body.synthesize( + at, + &format!("const {{ {} }} = {module};\n", named.join(", ")), + ); } } Statement::ExportDeclaration(export) => { - body.push_str(&text(export.declaration.span())); - body.push('\n'); + rewrites.write(&mut body, export.declaration.span()); + body.synthesize(end, "\n"); for name in declared_names(&export.declaration) { exports.push((name.clone(), name)); } @@ -400,7 +506,11 @@ pub(crate) fn module_script( } } Statement::ExportFromDeclaration(export) => { - let module = loader.load(export.source.value.as_str(), filename, entry)?; + let module = load_module( + loader, + export.source.value.as_str(), + export.source.span.start, + )?; for specifier in &export.specifiers { exports.push(( export_name(&specifier.exported), @@ -416,32 +526,40 @@ pub(crate) fn module_script( ExportDefaultDeclarationKind::ClassDeclaration(class) => class.id.as_ref(), _ => None, }; - let declaration = text(export.declaration.span()); + let declaration = export.declaration.span(); let local = if let Some(id) = id { - body.push_str(&declaration); + rewrites.write(&mut body, declaration); id.name.to_string() } else { - let _ = write!(body, "const __default__ = ({declaration});"); + body.synthesize(declaration.start as usize, "const __default__ = ("); + rewrites.write(&mut body, declaration); + body.synthesize(declaration.end as usize, ");"); "__default__".to_string() }; - body.push('\n'); + body.synthesize(end, "\n"); exports.push(("default".to_string(), local)); } Statement::ExportAllDeclaration(export) => { - let module = loader.load(export.source.value.as_str(), filename, entry)?; + let module = load_module( + loader, + export.source.value.as_str(), + export.source.span.start, + )?; match &export.exported { Some(exported) => exports.push((export_name(exported), module)), None => spreads.push(module), } } statement => { - body.push_str(&text(statement.span())); - body.push('\n'); + rewrites.write(&mut body, statement.span()); + body.synthesize(end, "\n"); } } } + let (body, origin) = body.finish(unit); Ok(ModuleScript { body, + origin, exports, spreads, commonjs, @@ -466,3 +584,6 @@ pub(crate) fn declared_names(declaration: &Declaration<'_>) -> Vec { .collect(), } } + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/coverage_tests.rs b/libs/extractor/src/module_loader/coverage_tests.rs new file mode 100644 index 000000000..eed95613f --- /dev/null +++ b/libs/extractor/src/module_loader/coverage_tests.rs @@ -0,0 +1,65 @@ +use rstest::rstest; + +use super::{ModuleLoader, Unit, module_script}; +use crate::ExtractOption; + +#[test] +fn loaded_module_bodies_execute_before_the_importing_entry() -> Result<(), String> { + // Given + let resolver = |specifier: &str, _: &str| { + (specifier == "./tokens").then(|| crate::ResolvedModule { + path: "tokens.ts".to_string(), + code: "export const color = 'red';".to_string(), + }) + }; + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(Some(&resolver), &option); + let code = "import { color } from './tokens'; const result = color;"; + let unit = Unit::written("entry.ts", code, code, &[])?; + let entry = module_script(&unit, &mut loader, true)?; + let script = loader.script(&entry); + let mut context = boa_engine::Context::default(); + // When + let value = context + .eval(boa_engine::Source::from_bytes(&format!( + "{}\nresult", + script.text + ))) + .map_err(|error| error.to_string())?; + // Then + assert_eq!( + value + .to_string(&mut context) + .map_err(|error| error.to_string())? + .to_std_string_escaped(), + "red" + ); + assert_eq!( + loader.dependencies.into_iter().collect::>(), + ["tokens.ts"] + ); + Ok(()) +} + +#[rstest] +#[case("return 1;", "SyntaxError")] +#[case("const a = 1; const a = 2;", "SyntaxError")] +fn module_script_rejects_generated_parse_and_semantic_errors_with_file_fallback( + #[case] code: &str, + #[case] category: &str, +) -> Result<(), String> { + // Given + let unit = Unit::generated("invalid.ts", code); + let option = ExtractOption::default(); + let mut loader = ModuleLoader::new(None, &option); + // When + let error = module_script(&unit, &mut loader, true) + .err() + .ok_or("invalid generated module accepted")?; + // Then + assert!( + error.starts_with(&format!("invalid.ts:1:1: JS execution error: {category}:")), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/operations.rs b/libs/extractor/src/module_loader/operations.rs new file mode 100644 index 000000000..a8b864b66 --- /dev/null +++ b/libs/extractor/src/module_loader/operations.rs @@ -0,0 +1,200 @@ +//! Execution markers for iterable operations Boa can fail without a call frame. + +use std::{ + cell::RefCell, + hash::{Hash, Hasher}, +}; + +use boa_engine::{Context, JsArgs, JsResult, JsString, NativeFunction}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, SourceType}; + +#[derive(Default)] +struct Active(RefCell>); + +struct Arrays<'s> { + source: &'s str, + helper: &'s str, + changes: Vec<(usize, String)>, + sites: Vec, +} + +impl<'a> Visit<'a> for Arrays<'_> { + fn visit_array_expression(&mut self, array: &oxc_ast::ast::ArrayExpression<'a>) { + let mut endings = Vec::new(); + if array + .elements + .iter() + .any(oxc_ast::ast::ArrayExpressionElement::is_spread) + { + let id = self.sites.len(); + self.sites.push(crate::locate( + super::SCRIPT_PATH, + self.source, + array.span.start as usize, + )); + self.changes.push(( + array.span.start as usize, + format!("({}({id}), {}({id}, ", self.helper, self.helper), + )); + endings.push((array.span.end as usize, "))".to_string())); + for element in &array.elements { + if let oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) = element { + let site = self.sites.len(); + self.sites.push(crate::locate( + super::SCRIPT_PATH, + self.source, + spread.span.start as usize, + )); + let argument = spread.argument.span(); + self.changes + .push((argument.start as usize, format!("{}({site}, ", self.helper))); + endings.push((argument.end as usize, ", true)".to_string())); + } + } + } + walk::walk_array_expression(self, array); + self.changes.extend(endings); + } +} + +pub(crate) struct Operations { + pub code: String, + edits: Vec, + source: String, + helper: String, + sites: Vec, +} + +impl Operations { + pub(crate) fn new(source: &str) -> Self { + let mut hash = rustc_hash::FxHasher::default(); + source.hash(&mut hash); + let mut helper = format!("__devup_operation_site_{:x}__", hash.finish()); + while source.contains(&helper) { + helper.push('_'); + } + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); + let mut arrays = Arrays { + source, + helper: &helper, + changes: Vec::new(), + sites: Vec::new(), + }; + arrays.visit_program(&parsed.program); + arrays.changes.sort_by_key(|(at, _)| *at); + let mut code = String::new(); + let mut edits = Vec::new(); + let mut copied = 0; + for (at, text) in arrays.changes { + code.push_str(&source[copied..at]); + code.push_str(&text); + edits.push((at, at, text.len())); + copied = at; + } + code.push_str(&source[copied..]); + let sites = arrays.sites; + Self { + code, + edits, + source: source.to_string(), + helper, + sites, + } + } + + pub(crate) fn prepare(&self, context: &mut Context) -> JsResult<()> { + context.insert_data(Active::default()); + context.register_global_builtin_callable( + JsString::from(self.helper.as_str()), + 2, + NativeFunction::from_copy_closure_with_captures( + |_, args, sites, context| { + let id = args.get_or_undefined(0).to_u32(context)?; + let index = usize::try_from(id).map_err(|error| { + boa_engine::JsNativeError::range().with_message(error.to_string()) + })?; + if let Some(active) = context.get_data::() { + let mut active = active.0.borrow_mut(); + if args.len() == 1 { + if let Some(site) = sites.get(index) { + active.push((id, site.clone(), site.clone())); + } + } else if args.len() == 3 { + if let Some((_, _, place)) = active.last_mut() + && let Some(site) = sites.get(index) + { + site.clone_into(place); + } + } else if let Some(index) = active + .iter() + .rposition(|(operation, _, _)| *operation == id) + { + active.truncate(index); + } + } + Ok(args.get_or_undefined(1).clone()) + }, + self.sites.clone(), + ), + ) + } + + /// Rebase engine/read frames, adding a currently executing operation only + /// when Boa supplied no script frame. A completed array has no active marker. + pub(crate) fn explain(&self, error: &str, context: &Context) -> String { + let marker = format!("({}:", super::SCRIPT_PATH); + let active = context + .get_data::() + .and_then(|active| active.0.borrow().last().cloned()); + let mut first_frame = None; + let mut explained = error + .lines() + .filter_map(|line| { + let line = if line.contains("at (") { + line.strip_suffix(" (native)").unwrap_or(line) + } else { + line + }; + let Some((prefix, position)) = line.rsplit_once(&marker) else { + return Some(line.to_string()); + }; + let Some((row, column)) = position.trim_end_matches(')').split_once(':') else { + return Some(line.to_string()); + }; + let (Ok(row), Ok(column)) = (row.parse::(), column.parse::()) else { + return Some(line.to_string()); + }; + let offset = crate::source_map::Lines::new(&self.code) + .code_point_offset(row.saturating_sub(1), column.saturating_sub(1)); + let mut inserted = 0; + let generated = self.edits.iter().any(|(at, _, length)| { + let start = at + inserted; + inserted += length; + (start..start + length).contains(&offset) + }); + let original = crate::import_alias_visit::source_offset(&self.edits, offset); + let place = crate::locate(super::SCRIPT_PATH, &self.source, original); + let operation = + generated || active.as_ref().is_some_and(|(_, array, _)| *array == place); + first_frame.get_or_insert(operation); + if operation && line.starts_with(" at ") { + return None; + } + Some(format!("{prefix}({place})")) + }) + .collect::>() + .join("\n"); + if first_frame.unwrap_or(true) + && let Some((_, _, site)) = active + { + let at = explained.find("\n at ").unwrap_or(explained.len()); + explained.insert_str(at, &format!("\n at ({site})")); + } + explained + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/operations/coverage_tests.rs b/libs/extractor/src/module_loader/operations/coverage_tests.rs new file mode 100644 index 000000000..8125573bd --- /dev/null +++ b/libs/extractor/src/module_loader/operations/coverage_tests.rs @@ -0,0 +1,37 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; + +use super::Operations; + +#[rstest] +#[case("TypeError: iterator failed\n at eval (devup-ui-stylesheet:unknown)")] +#[case("TypeError: iterator failed\n at eval (devup-ui-stylesheet:x:y)")] +fn unlocated_engine_frames_survive_operation_rebasing(#[case] error: &str) { + // Given + let operations = Operations::new("[...[]]"); + // When / Then + assert_eq!(operations.explain(error, &Context::default()), error); +} + +#[test] +fn failed_spread_uses_operation_site_when_boa_has_no_script_frame() -> Result<(), String> { + // Given + let source = "[...null]"; + let operations = Operations::new(source); + let mut context = Context::default(); + operations + .prepare(&mut context) + .map_err(|error| error.to_string())?; + // When + let error = context + .eval(Source::from_bytes(&operations.code)) + .err() + .ok_or("invalid spread succeeded")?; + let explained = operations.explain(&error.to_string(), &context); + // Then + assert!( + explained.contains("at (devup-ui-stylesheet:1:2)"), + "{explained}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script.rs b/libs/extractor/src/module_loader/script.rs new file mode 100644 index 000000000..37a32549b --- /dev/null +++ b/libs/extractor/src/module_loader/script.rs @@ -0,0 +1,220 @@ +//! The script a stylesheet is evaluated as, and where each part of it was +//! written + +use std::rc::Rc; + +use crate::import_alias_visit::Edit; +use crate::source_map::{Lines, Trace}; +#[cfg(test)] +use crate::vanilla_extract::strip_typescript; +use crate::vanilla_extract::strip_typescript_marked; + +mod explain; +mod retained; +#[cfg(test)] +mod tests; + +pub(crate) use explain::SCRIPT_PATH; + +/// A module as written, and how its script traces back to it +struct Written { + source: String, + trace: Trace, + retained: Option>, +} + +/// A module turned into a script: its TypeScript stripped +pub(crate) struct Unit { + filename: String, + script: String, + /// `None` for a module the build generated, which no author wrote + written: Option, +} + +impl Unit { + /// The script of `code`, the `source` of `filename` after the layers of + /// `edits`, last made first + pub(crate) fn written( + filename: &str, + code: &str, + source: &str, + edits: &[&[Edit]], + ) -> Result, String> { + super::validate(crate::vanilla_extract::Stylesheet { + filename, + code, + source, + edits, + })?; + let stripped = strip_typescript_marked(code, filename); + Ok(Rc::new(Self { + filename: filename.to_string(), + script: stripped.code.clone(), + written: Some(Written { + source: source.to_string(), + trace: Trace::new(&stripped.marks, edits), + retained: None, + }), + })) + } + + #[cfg(test)] + pub(crate) fn generated(filename: &str, code: &str) -> Rc { + Rc::new(Self { + filename: filename.to_string(), + script: strip_typescript(code, filename), + written: None, + }) + } + + pub(crate) fn filename(&self) -> &str { + &self.filename + } + + pub(crate) fn script(&self) -> &str { + &self.script + } + + /// `filename:line:column` of the byte `offset` of the script as written, + /// `None` where the build generated it + fn locate(&self, offset: usize) -> Option { + let written = self.written.as_ref()?; + if written.retained.as_ref().is_some_and(|ranges| { + !ranges + .iter() + .any(|(start, end)| (*start..*end).contains(&offset)) + }) { + return None; + } + let offset = written.trace.resolve(&self.script, &written.source, offset); + Some(crate::locate(&self.filename, &written.source, offset)) + } + + /// Where the byte `offset` of the script is, down to the file for generated + /// code + pub(crate) fn place(&self, offset: usize) -> String { + self.locate(offset) + .unwrap_or_else(|| format!("{}:1:1", self.filename)) + } +} + +/// A part of a module's body, up to the next one +struct Piece { + start: usize, + script: usize, + /// Whether the text is the script's, so a position in it moves with it + copied: bool, +} + +/// What a module's body was made of +pub(crate) struct Origin { + unit: Rc, + pieces: Vec, +} + +impl Origin { + fn locate(&self, offset: usize) -> Option { + let piece = &self.pieces[self.pieces.partition_point(|piece| piece.start <= offset) - 1]; + let script = if piece.copied { + piece.script + offset - piece.start + } else { + piece.script + }; + self.unit.locate(script) + } +} + +/// The body of a module, built from its script and the code made to go with it +pub(crate) struct Body { + text: String, + pieces: Vec, +} + +impl Body { + pub(crate) fn new(capacity: usize) -> Self { + Self { + text: String::with_capacity(capacity), + pieces: vec![Piece { + start: 0, + script: 0, + copied: false, + }], + } + } + + /// Adds `script[from..to]` + pub(crate) fn copy(&mut self, script: &str, from: usize, to: usize) { + self.pieces.push(Piece { + start: self.text.len(), + script: from, + copied: true, + }); + self.text.push_str(&script[from..to]); + } + + /// Adds `text` made for the code at byte `at` of the script + pub(crate) fn synthesize(&mut self, at: usize, text: &str) { + self.pieces.push(Piece { + start: self.text.len(), + script: at, + copied: false, + }); + self.text.push_str(text); + } + + pub(crate) fn finish(self, unit: &Rc) -> (String, Rc) { + ( + self.text, + Rc::new(Origin { + unit: unit.clone(), + pieces: self.pieces, + }), + ) + } +} + +#[cfg(test)] +mod coverage_tests; + +/// The script an evaluation runs: the loaded modules, then the stylesheet +#[derive(Default)] +pub(crate) struct Script { + pub text: String, + /// `(start, end, origin)` of the bodies of modules in `text`; the rest is + /// code the build generated + parts: Vec<(usize, usize, Rc)>, +} + +impl Script { + pub(crate) fn generated(&mut self, text: &str) { + self.text.push_str(text); + } + + pub(crate) fn body(&mut self, text: &str, origin: &Rc) { + let start = self.text.len(); + self.text.push_str(text); + self.parts.push((start, self.text.len(), origin.clone())); + } + + /// `filename:line:column` of the script as written for a position the + /// engine printed, `None` in code the build generated + fn locate(&self, lines: &Lines<'_>, line: u32, column: u32) -> Option { + let offset = lines.code_point_offset(line.saturating_sub(1), column.saturating_sub(1)); + let (start, _, origin) = self + .parts + .iter() + .find(|(start, end, _)| (*start..*end).contains(&offset))?; + origin.locate(offset - start) + } + + /// The error the engine gave for this script, its positions in the code as + /// written; `fallback` names the file when no position is + pub(crate) fn explain(&self, error: &str, fallback: &str) -> String { + let lines = Lines::new(&self.text); + explain::describe( + error, + |line, column| self.locate(&lines, line, column), + &format!("{fallback}:1:1"), + ) + } +} diff --git a/libs/extractor/src/module_loader/script/coverage_tests.rs b/libs/extractor/src/module_loader/script/coverage_tests.rs new file mode 100644 index 000000000..e514114b3 --- /dev/null +++ b/libs/extractor/src/module_loader/script/coverage_tests.rs @@ -0,0 +1,98 @@ +use rstest::rstest; +use serial_test::serial; + +use super::Unit; +use crate::{ExtractOption, ExtractOutput}; + +#[test] +fn retained_code_rejects_malformed_source_map_instead_of_inventing_a_trace() -> Result<(), String> { + // Given + let output = ExtractOutput { + code: "const width = 4;".to_string(), + styles: Default::default(), + map: Some("{invalid".to_string()), + css_file: None, + dependencies: Vec::new(), + }; + // When + let error = Unit::retained("retained.ts", &output, "const width: number = 4;") + .err() + .ok_or("malformed map accepted")?; + // Then + assert!(error.starts_with("retained.ts:1:1: Cannot read stylesheet source map:")); + Ok(()) +} + +#[test] +fn retained_code_uses_written_trace_when_extraction_leaves_the_source_unchanged() +-> Result<(), String> { + // Given + let code = "const width: number = 4;"; + let output = ExtractOutput { + code: code.to_string(), + styles: Default::default(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; + // When + let unit = Unit::retained("retained.ts", &output, code)?; + // Then + assert_eq!( + unit.place(unit.script().find('4').ok_or("missing initializer")?), + "retained.ts:1:23" + ); + Ok(()) +} + +#[rstest] +#[case(None)] +#[case(Some(r#"{"version":3,"sources":[],"names":[],"mappings":""}"#))] +fn retained_code_rejects_missing_original_source_instead_of_inventing_a_trace( + #[case] map: Option<&str>, +) -> Result<(), String> { + // Given + let output = ExtractOutput { + code: "const width = 4;".to_string(), + styles: Default::default(), + map: map.map(str::to_string), + css_file: None, + dependencies: Vec::new(), + }; + // When + let error = Unit::retained("retained.ts", &output, "const width: number = 4;") + .err() + .ok_or("missing map accepted")?; + // Then + assert!(error.starts_with("retained.ts:1:1: Cannot trace retained stylesheet code")); + Ok(()) +} + +#[test] +#[serial] +fn retained_initializers_keep_original_sites_while_extracted_glue_has_only_file_fallback() +-> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let code = "import { css } from '@devup-ui/react';\nconst width: number = 4;\nexport function value() { return width; }\nexport const rule = css({ w: width });"; + let output = + crate::extract_with_source_map("retained.ts", code, ExtractOption::default(), true, None) + .map_err(|error| error.to_string())?; + // When + let unit = Unit::retained("retained.ts", &output, code)?; + // Then + assert_eq!( + unit.place( + unit.script() + .find("= 4") + .ok_or("missing retained initializer")? + + 2 + ), + "retained.ts:2:23" + ); + assert_eq!( + unit.locate(unit.script().find("rule").ok_or("missing extracted rule")?), + None + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script/explain.rs b/libs/extractor/src/module_loader/script/explain.rs new file mode 100644 index 000000000..14fa221e5 --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain.rs @@ -0,0 +1,166 @@ +//! The errors the script engine gives, told with the code as written + +use crate::module_loader::IMPORT_CYCLE; + +#[cfg(test)] +mod tests; + +/// The name the evaluated script goes by, which the engine prints in its +/// positions +pub(crate) const SCRIPT_PATH: &str = "devup-ui-stylesheet"; + +const FRAME_SEPARATOR: &str = "\n at "; + +/// Where the engine says an error or a call happened +enum Place { + /// `line` and `column`, counted from 1, of the script + Script(u32, u32), + /// A function the build provides + Native, + /// A script no author wrote or no position is known for + Foreign, +} + +struct Frame<'t> { + function: &'t str, + place: Place, +} + +/// What `inner` of `(inner)` says, `None` when it is not a place +fn place(inner: &str) -> Option { + if inner.starts_with(crate::evaluation_sandbox::INTERNAL_SOURCE) { + return Some(Place::Foreign); + } + if inner == "native" { + return Some(Place::Native); + } + if let Some(position) = inner + .strip_prefix(SCRIPT_PATH) + .and_then(|position| position.strip_prefix(':')) + { + return Some( + position + .split_once(':') + .and_then(|(line, column)| { + Some(Place::Script(line.parse().ok()?, column.parse().ok()?)) + }) + .unwrap_or(Place::Foreign), + ); + } + ["unknown at ", "eval at ", "json at "] + .iter() + .any(|prefix| inner.starts_with(prefix)) + .then_some(Place::Foreign) +} + +/// `text` without the place it ends with, and the place +fn split_place(text: &str) -> Option<(&str, Place)> { + let text = if text.starts_with(" (") { + text.strip_suffix(" (native)").unwrap_or(text) + } else { + text + }; + let (before, inner) = text.strip_suffix(')')?.rsplit_once(" (")?; + Some((before, place(inner)?)) +} + +/// The position a syntax error ends with: `message at line 3, col 7`. The +/// engine gives the early errors of a whole script, like a name declared twice, +/// the position 1:1 whichever they are, which is no position. +fn split_position(text: &str) -> Option<(&str, Place)> { + let (before, position) = text.rsplit_once(" at line ")?; + let (line, column) = position.split_once(", col ")?; + Some(( + before, + match (line.parse().ok()?, column.parse().ok()?) { + (1, 1) => Place::Foreign, + (line, column) => Place::Script(line, column), + }, + )) +} + +/// The error without the call stack, the call stack from the innermost call, +/// and where the error itself was raised +fn parse(error: &str) -> (String, Vec>, Option) { + let mut segments: Vec<&str> = error.split(FRAME_SEPARATOR).collect(); + let mut frames = Vec::new(); + while segments.len() > 1 + && let Some((function, place)) = segments.last().and_then(|last| split_place(last)) + { + frames.push(Frame { function, place }); + segments.pop(); + } + frames.reverse(); + let head = segments.join(FRAME_SEPARATOR); + let (head, placed) = + split_place(&head).map_or((head.as_str(), None), |(head, place)| (head, Some(place))); + match split_position(head) { + Some((cause, place)) => (cause.to_string(), frames, Some(place)), + None => (head.to_string(), frames, placed), + } +} + +/// What to change for an error to stop +fn fix(cause: &str) -> String { + if cause.contains(IMPORT_CYCLE) { + return "break the import cycle, or read the imported value only after both modules finished evaluating, for example inside a function called later".to_string(); + } + if let Some(name) = cause + .strip_prefix("ReferenceError: ") + .and_then(|message| message.strip_suffix(" is not defined")) + { + return format!( + "declare or import `{name}` before this code reads it; it is not available when the stylesheet is evaluated at build time" + ); + } + if cause.starts_with("SyntaxError") { + return "correct the syntax at this location".to_string(); + } + if cause.starts_with("TypeError") { + return "check that every value read, called or constructed here exists when the stylesheet is evaluated at build time".to_string(); + } + "remove or guard the code that fails here, or correct the cause above, so the stylesheet evaluates without throwing".to_string() +} + +/// The error `error` the engine gave, led by where the code as written raised +/// it, with its call stack in that code and what to fix. `locate` tells where +/// the code as written is for a position of the script, `None` for code the +/// build generated; `fallback` leads when no position is known +pub(super) fn describe( + error: &str, + locate: impl Fn(u32, u32) -> Option, + fallback: &str, +) -> String { + let (cause, frames, raised) = parse(error); + let in_script = |place: &Place| match place { + Place::Script(line, column) => locate(*line, *column), + Place::Native | Place::Foreign => None, + }; + let mut at = raised + .filter(|_| frames.is_empty()) + .and_then(|place| in_script(&place)); + let mut calls = Vec::new(); + for frame in &frames { + if frame.function.starts_with("__devup_read_site_") + || frame.function.starts_with("__devup_operation_site_") + { + continue; + } + if matches!(frame.place, Place::Native) { + calls.push(format!("{FRAME_SEPARATOR}{} (native)", frame.function)); + } else if let Some(located) = in_script(&frame.place) { + calls.push(format!("{FRAME_SEPARATOR}{} ({located})", frame.function)); + at.get_or_insert(located); + } + } + let cause = cause.trim_end_matches('.'); + format!( + "{}: JS execution error: {cause}. Fix: {}{}", + at.as_deref().unwrap_or(fallback), + fix(cause), + calls.concat() + ) +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/script/explain/coverage_tests.rs b/libs/extractor/src/module_loader/script/explain/coverage_tests.rs new file mode 100644 index 000000000..bf8364b18 --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain/coverage_tests.rs @@ -0,0 +1,25 @@ +use super::describe; + +#[test] +fn internal_guard_and_instrumentation_frames_are_removed_when_reporting_author_calls() +-> Result<(), String> { + // Given + let error = "ReferenceError: read denied\n at guard (:1:4)\n at __devup_read_site_123__ (devup-ui-stylesheet:2:3)\n at __devup_operation_site_456__ (devup-ui-stylesheet:2:4)\n at user (devup-ui-stylesheet:3:7)"; + // When + let result = describe( + error, + |line, column| Some(format!("original.ts:{line}:{column}")), + "fallback.ts:1:1", + ); + // Then + let (head, rest) = result + .split_once(". Fix: ") + .ok_or("missing diagnostic delimiter")?; + assert_eq!( + head, + "original.ts:3:7: JS execution error: ReferenceError: read denied" + ); + let (_, calls) = rest.split_once('\n').ok_or("missing author call")?; + assert_eq!(calls, " at user (original.ts:3:7)"); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/script/explain/tests.rs b/libs/extractor/src/module_loader/script/explain/tests.rs new file mode 100644 index 000000000..527e0f796 --- /dev/null +++ b/libs/extractor/src/module_loader/script/explain/tests.rs @@ -0,0 +1,135 @@ +use super::*; + +/// The code as written has lines 2 to 5; the rest of the script is generated +fn locate(line: u32, column: u32) -> Option { + (2..=5) + .contains(&line) + .then(|| format!("/a.ts:{}:{column}", line - 1)) +} + +fn tell(error: &str) -> String { + describe(error, locate, "/fallback.ts") +} + +/// `told` split into what leads it, the fix and the call stack +fn parts(told: &str) -> (&str, &str, &str) { + let (lead, rest) = told.split_once(". Fix: ").unwrap_or((told, "")); + let (fix, calls) = rest.split_once('\n').unwrap_or((rest, "")); + (lead, fix, calls) +} + +#[test] +fn the_innermost_place_in_written_code_leads() { + let error = "Error: boom (devup-ui-stylesheet:3:7)\n at get (devup-ui-stylesheet:9:3)\n at fail (devup-ui-stylesheet:3:7)\n at
(devup-ui-stylesheet:4:2)"; + let told = tell(error); + let (lead, fix, calls) = parts(&told); + assert_eq!(lead, "/a.ts:2:7: JS execution error: Error: boom"); + assert!(fix.starts_with("remove or guard the code"), "{fix}"); + assert_eq!(calls, " at fail (/a.ts:2:7)\n at
(/a.ts:3:2)"); +} + +#[test] +fn calls_into_generated_code_are_dropped() { + let error = "Error: boom (devup-ui-stylesheet:9:3)\n at get (devup-ui-stylesheet:9:3)\n at
(devup-ui-stylesheet:2:5)"; + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert_eq!(lead, "/a.ts:1:5: JS execution error: Error: boom"); + assert_eq!(calls, " at
(/a.ts:1:5)"); +} + +#[test] +fn what_is_native_is_kept_and_what_has_no_place_is_dropped() { + let error = "Error: boom (native)\n at map (native)\n at e (eval at :1:2)\n at j (json at :1:2)\n at u (unknown at :4:5)\n at p (devup-ui-stylesheet:?:?)\n at
(devup-ui-stylesheet:3:1)"; + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert_eq!(lead, "/a.ts:2:1: JS execution error: Error: boom"); + assert_eq!(calls, " at map (native)\n at
(/a.ts:2:1)"); +} + +#[test] +fn a_syntax_error_is_told_where_the_engine_says() { + let told = tell("SyntaxError: unexpected token '}', primary expression at line 4, col 2"); + let (lead, fix, calls) = parts(&told); + assert_eq!( + lead, + "/a.ts:3:2: JS execution error: SyntaxError: unexpected token '}', primary expression" + ); + assert_eq!(fix, "correct the syntax at this location"); + assert_eq!(calls, ""); +} + +#[test] +fn the_early_errors_of_a_script_have_no_place() { + let told = tell("SyntaxError: lexical name declared multiple times at line 1, col 1 (native)"); + let (lead, _, calls) = parts(&told); + assert_eq!( + lead, + "/fallback.ts: JS execution error: SyntaxError: lexical name declared multiple times" + ); + assert_eq!(calls, ""); +} + +#[test] +fn an_error_without_calls_is_told_where_it_was_raised() { + let told = tell("Error: x (devup-ui-stylesheet:2:3)"); + assert_eq!(parts(&told).0, "/a.ts:1:3: JS execution error: Error: x"); +} + +#[test] +fn an_error_in_generated_code_or_without_a_place_leads_with_the_file() { + for error in [ + "Error: x (devup-ui-stylesheet:9:3)", + "Error: x (unknown at :9:3)", + "Error: x (native)", + "x", + ] { + let told = tell(error); + let (lead, _, calls) = parts(&told); + assert!( + lead.starts_with("/fallback.ts: JS execution error: "), + "{lead}" + ); + assert_eq!(calls, ""); + } +} + +#[test] +fn what_the_message_holds_stays_in_it() { + let told = tell("Error: a\n at b (c)\n at
(devup-ui-stylesheet:3:1)"); + let (lead, _, calls) = parts(&told); + assert_eq!( + lead, + "/a.ts:2:1: JS execution error: Error: a\n at b (c)" + ); + assert_eq!(calls, " at
(/a.ts:2:1)"); + + let told = tell("Error: value (x)"); + assert_eq!( + parts(&told).0, + "/fallback.ts: JS execution error: Error: value (x)" + ); + + let told = tell("Error: done..."); + assert_eq!( + parts(&told).0, + "/fallback.ts: JS execution error: Error: done" + ); +} + +#[test] +fn every_kind_of_error_has_a_fix() { + assert!( + fix(&format!( + "ReferenceError: Cannot access 'a' of 'b' {IMPORT_CYCLE}" + )) + .starts_with("break the import cycle") + ); + assert_eq!( + fix("ReferenceError: window is not defined"), + "declare or import `window` before this code reads it; it is not available when the stylesheet is evaluated at build time" + ); + assert_eq!(fix("SyntaxError: x"), "correct the syntax at this location"); + assert!(fix("TypeError: x is not a function").starts_with("check that every value")); + assert!(fix("RangeError: x").starts_with("remove or guard the code")); + assert!(fix("ReferenceError: x is not initialized").starts_with("remove or guard the code")); +} diff --git a/libs/extractor/src/module_loader/script/retained.rs b/libs/extractor/src/module_loader/script/retained.rs new file mode 100644 index 000000000..a426d99d0 --- /dev/null +++ b/libs/extractor/src/module_loader/script/retained.rs @@ -0,0 +1,106 @@ +use std::rc::Rc; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{Declaration, Statement}; +use oxc_parser::Parser; +use oxc_sourcemap::SourceMap; +use oxc_span::{GetSpan, SourceType}; + +use super::{Unit, Written}; +use crate::source_map::{Trace, marks}; +use crate::vanilla_extract::{Stylesheet, strip_typescript_marked}; + +fn initializers(code: &str) -> Vec<(String, usize, usize)> { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::ts()).parse(); + parsed + .program + .body + .iter() + .flat_map(|statement| { + let declaration = match statement { + Statement::VariableDeclaration(declaration) => Some(declaration.as_ref()), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::VariableDeclaration(declaration) => Some(declaration.as_ref()), + _ => None, + }, + _ => None, + }; + declaration + .into_iter() + .flat_map(|declaration| &declaration.declarations) + .filter_map(|declarator| { + let name = declarator.id.get_identifier_name()?; + let span = declarator.init.as_ref()?.span(); + Some((name.to_string(), span.start as usize, span.end as usize)) + }) + }) + .collect() +} + +impl Unit { + pub(crate) fn retained( + filename: &str, + output: &crate::ExtractOutput, + source: &str, + ) -> Result, String> { + let code = &output.code; + if code == source { + return Self::written(filename, code, source, &[]); + } + crate::module_loader::validate(Stylesheet { + filename, + code, + source: code, + edits: &[], + })?; + let generated = strip_typescript_marked(code, filename); + let unavailable = || { + format!( + "{filename}:1:1: Cannot trace retained stylesheet code. Fix: report this internal source-map failure" + ) + }; + let map = SourceMap::from_json_string(output.map.as_deref().ok_or_else(unavailable)?) + .map_err(|error| format!("{filename}:1:1: Cannot read stylesheet source map: {error}. Fix: report this internal source-map failure"))?; + let parsed = map.get_source_content(0).ok_or_else(unavailable)?; + let originals = initializers(source); + let copied: Vec<_> = initializers(parsed) + .into_iter() + .filter_map(|(name, start, end)| { + let (_, from, _) = originals.iter().find(|(original_name, from, to)| { + *original_name == name && source[*from..*to] == parsed[start..end] + })?; + Some((start, end, *from)) + }) + .collect(); + let trace = Trace::new(&marks(&map, code, parsed), &[]); + let mut mapped = Vec::new(); + let mut ranges = Vec::new(); + let mut tokens = generated.marks.iter().peekable(); + while let Some(&(start, at)) = tokens.next() { + let parsed_at = trace.resolve(code, parsed, at); + if let Some(&(from, _, original)) = copied + .iter() + .find(|(from, to, _)| (*from..*to).contains(&parsed_at)) + { + let end = tokens + .peek() + .map_or(generated.code.len(), |(next, _)| *next); + mapped.push((start, original + parsed_at - from)); + ranges.push((start, end)); + } + } + Ok(Rc::new(Self { + filename: filename.to_string(), + script: generated.code.clone(), + written: Some(Written { + source: source.to_string(), + trace: Trace::new(&mapped, &[]), + retained: Some(ranges), + }), + })) + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/module_loader/script/retained/coverage_tests.rs b/libs/extractor/src/module_loader/script/retained/coverage_tests.rs new file mode 100644 index 000000000..67ed6a840 --- /dev/null +++ b/libs/extractor/src/module_loader/script/retained/coverage_tests.rs @@ -0,0 +1,15 @@ +use super::initializers; + +#[test] +fn initializer_ranges_include_local_constants_but_ignore_exported_functions() { + // Given + let source = "const local = 1; export function helper() {} export const token = 'red';"; + // When + let ranges = initializers(source); + // Then + let values = ranges + .iter() + .map(|(name, start, end)| (name.as_str(), &source[*start..*end])) + .collect::>(); + assert_eq!(values, [("local", "1"), ("token", "'red'")]); +} diff --git a/libs/extractor/src/module_loader/script/tests.rs b/libs/extractor/src/module_loader/script/tests.rs new file mode 100644 index 000000000..3a412dd91 --- /dev/null +++ b/libs/extractor/src/module_loader/script/tests.rs @@ -0,0 +1,94 @@ +use super::*; + +#[test] +fn a_generated_module_is_told_by_its_file() { + let unit = Unit::generated("/m.css.ts", "export const a: number = 1;"); + assert_eq!(unit.filename(), "/m.css.ts"); + assert_eq!(unit.script(), "export const a = 1;\n"); + assert_eq!(unit.locate(7), None); + assert_eq!(unit.place(7), "/m.css.ts:1:1"); +} + +#[test] +fn a_written_module_is_told_down_to_the_token() -> Result<(), String> { + let code = "const label: string = 'x';\nexport const a: number = fail(label);\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let script = unit.script(); + assert_eq!( + unit.place(script.find("fail").unwrap_or_default()), + "/m.ts:2:26" + ); + assert_eq!( + unit.place(script.find("(label").unwrap_or_default()), + "/m.ts:2:30" + ); + assert_eq!(unit.place(0), "/m.ts:1:1"); + Ok(()) +} + +#[test] +fn edits_of_the_source_are_undone() -> Result<(), String> { + let source = "import { style } from '@vanilla-extract/css'; export const a: number = fail();\n"; + let code = "import { style } from '@devup-ui/react'; export const a: number = fail();\n"; + let edits = [(22, 44, 17)]; + let unit = Unit::written("/m.ts", code, source, &[&edits])?; + let script = unit.script(); + assert_eq!( + unit.place(script.find("fail").unwrap_or_default()), + "/m.ts:1:72" + ); + Ok(()) +} + +#[test] +fn a_body_is_told_by_the_script_it_was_made_of() -> Result<(), String> { + let code = "aaaa();\nbbbb();\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let script = unit.script().to_string(); + assert_eq!(script, code); + let mut body = Body::new(script.len()); + body.copy(&script, 0, 2); + body.synthesize(3, "XYZ"); + body.copy(&script, 4, 6); + body.synthesize(8, "\n"); + let (text, origin) = body.finish(&unit); + assert_eq!(text, "aaXYZ()\n"); + assert_eq!(origin.locate(0).as_deref(), Some("/m.ts:1:1")); + assert_eq!(origin.locate(1).as_deref(), Some("/m.ts:1:2")); + assert_eq!(origin.locate(3).as_deref(), Some("/m.ts:1:4")); + assert_eq!(origin.locate(5).as_deref(), Some("/m.ts:1:5")); + assert_eq!(origin.locate(6).as_deref(), Some("/m.ts:1:6")); + assert_eq!(origin.locate(7).as_deref(), Some("/m.ts:2:1")); + Ok(()) +} + +#[test] +fn a_script_is_told_in_its_modules_and_not_in_its_glue() -> Result<(), String> { + let code = "throw 1;\n"; + let unit = Unit::written("/m.ts", code, code, &[])?; + let mut body = Body::new(code.len()); + body.copy(unit.script(), 0, unit.script().len()); + let (text, origin) = body.finish(&unit); + let mut script = Script::default(); + script.generated("(function () {\n"); + script.body(&text, &origin); + script.generated("})();\n"); + assert_eq!(script.text, "(function () {\nthrow 1;\n})();\n"); + + let told = script.explain( + "1 (devup-ui-stylesheet:2:1)\n at
(devup-ui-stylesheet:2:1)\n at
(devup-ui-stylesheet:3:1)", + "/m.ts", + ); + assert!( + told.starts_with("/m.ts:1:1: JS execution error: 1. Fix: "), + "{told}" + ); + assert!(told.ends_with("\n at
(/m.ts:1:1)"), "{told}"); + + let told = script.explain("Error: x (devup-ui-stylesheet:0:0)", "/m.ts"); + assert!( + told.starts_with("/m.ts:1:1: JS execution error: Error: x. Fix: "), + "{told}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/serialization.rs b/libs/extractor/src/module_loader/serialization.rs new file mode 100644 index 000000000..ff7e9ca0d --- /dev/null +++ b/libs/extractor/src/module_loader/serialization.rs @@ -0,0 +1,60 @@ +use boa_engine::{Context, JsObject, JsResult, JsValue}; +use rustc_hash::FxHashMap; + +use super::{array_items, is_plain_object, js_str, json_string, own_keys, string_code, to_text}; + +/// Unsupported values return `None`; reading user properties must still propagate exceptions. +pub(super) fn value_to_code( + value: &JsValue, + context: &mut Context, + names: &FxHashMap, + seen: &mut Vec, +) -> JsResult> { + if let Some(text) = js_str(value) { + return Ok(Some(string_code(&text, names))); + } + if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { + return to_text(value, context).map(Some); + } + let Some(object) = value.as_object() else { + return Ok(None); + }; + if object.is_callable() + || seen + .iter() + .any(|visited| JsObject::equals(visited, &object)) + { + return Ok(None); + } + seen.push(object.clone()); + let mut parts = Vec::new(); + let code = if let Some(items) = array_items(value, context)? { + for item in &items { + let Some(code) = value_to_code(item, context, names, seen)? else { + return Ok(None); + }; + parts.push(code); + } + format!("[{}]", parts.join(", ")) + } else if is_plain_object(&object, context) { + for (key, name) in own_keys(&object, context)? { + let item = object.get(key, context)?; + let Some(code) = value_to_code(&item, context, names, seen)? else { + return Ok(None); + }; + parts.push(format!("{}: {code}", json_string(&name))); + } + if parts.is_empty() { + "{}".to_string() + } else { + format!("{{ {} }}", parts.join(", ")) + } + } else { + return Ok(None); + }; + seen.pop(); + Ok(Some(code)) +} + +#[cfg(test)] +mod serialization_coverage_tests; diff --git a/libs/extractor/src/module_loader/serialization_coverage_tests.rs b/libs/extractor/src/module_loader/serialization_coverage_tests.rs new file mode 100644 index 000000000..24e3b5cd9 --- /dev/null +++ b/libs/extractor/src/module_loader/serialization_coverage_tests.rs @@ -0,0 +1,45 @@ +use boa_engine::{Context, Source}; +use rstest::rstest; +use rustc_hash::FxHashMap; + +use super::value_to_code; + +#[rstest] +#[case("new Map([['color', 'red']])")] +#[case("new Set(['red'])")] +#[case("new (class Color { constructor() { this.value = 'red'; } })()")] +#[case("[() => 'red']")] +fn serialization_declines_unsupported_exports_without_reporting_an_execution_error( + #[case] script: &str, +) -> Result<(), String> { + // Given + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(script)) + .map_err(|error| error.to_string())?; + // When + let code = value_to_code(&value, &mut context, &FxHashMap::default(), &mut Vec::new()) + .map_err(|error| error.to_string())?; + // Then + assert_eq!(code, None); + Ok(()) +} + +#[test] +fn serialization_propagates_a_thrown_getter_instead_of_declining_the_export() -> Result<(), String> +{ + // Given + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes( + "({ get color() { throw new TypeError('getter failed'); } })", + )) + .map_err(|error| error.to_string())?; + // When + let error = value_to_code(&value, &mut context, &FxHashMap::default(), &mut Vec::new()) + .err() + .ok_or("getter exception swallowed")?; + // Then + assert!(error.to_string().starts_with("TypeError: getter failed")); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests.rs b/libs/extractor/src/module_loader/tests.rs new file mode 100644 index 000000000..8416b1d02 --- /dev/null +++ b/libs/extractor/src/module_loader/tests.rs @@ -0,0 +1,323 @@ +use std::collections::HashMap; + +use css::file_map::reset_file_map; +use serial_test::serial; + +use crate::import_alias_visit::{Edit, transform_import_aliases_with_edits}; +use crate::vanilla_extract::{Stylesheet, execute_located}; +use crate::{ExtractOption, ImportAlias, ResolvedModule}; + +mod regressions; +mod sandbox; + +type Files = &'static [(&'static str, &'static str)]; + +const PACKAGE: &str = "@devup-ui/react"; + +fn resolve(files: Files, specifier: &str) -> Option { + files + .iter() + .find(|(path, _)| { + path.trim_end_matches(".ts") + == specifier.trim_start_matches('.').trim_end_matches(".ts") + }) + .map(|(path, code)| ResolvedModule { + path: (*path).to_string(), + code: (*code).to_string(), + }) +} + +/// The error evaluating `code` gives, `files` resolving what it imports +fn error_of(filename: &str, code: &str, files: Files) -> String { + reset_file_map(); + let resolver = move |specifier: &str, _: &str| resolve(files, specifier); + execute_located( + Stylesheet { + filename, + code, + source: code, + edits: &[], + }, + &ExtractOption::default(), + Some(&resolver), + ) + .err() + .unwrap_or_default() +} + +/// `error` split into what leads it, before ` Fix: `, the fix and its call +/// stack +fn parts(error: &str) -> (&str, &str, &str) { + let (lead, rest) = error.split_once(". Fix: ").unwrap_or((error, "")); + let (fix, calls) = rest.split_once('\n').unwrap_or((rest, "")); + (lead, fix, calls) +} + +#[test] +#[serial] +fn an_error_points_at_the_code_as_written() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nconst label: string = '한글😀'; const n: number = 1; throw new Error(label);\n", + &[], + ); + let (lead, fix, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:2:57: JS execution error: Error: 한글😀"); + assert!(fix.starts_with("remove or guard"), "{fix}"); + assert_eq!(calls, " at
(/a.css.ts:2:57)"); +} + +#[test] +#[serial] +fn lines_ending_with_carriage_returns_are_counted_once() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\r\n\r\nthrow new Error('x');\r\n", + &[], + ); + assert!( + error.starts_with("/a.css.ts:3:7: JS execution error: Error: x. Fix: "), + "{error}" + ); +} + +#[test] +#[serial] +fn rewritten_imports_do_not_shift_what_follows() { + let aliases = HashMap::from([( + "@vanilla-extract/css".to_string(), + ImportAlias::NamedToNamed, + )]); + let source = "import { style } from '@vanilla-extract/css'; throw new Error('x');\n"; + let aliased = transform_import_aliases_with_edits(source, "/a.css.ts", PACKAGE, &aliases); + assert_ne!(aliased.code, source); + let edits: Vec<&[Edit]> = vec![aliased.edits.as_slice()]; + reset_file_map(); + let error = execute_located( + Stylesheet { + filename: "/a.css.ts", + code: &aliased.code, + source, + edits: &edits, + }, + &ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:53: JS execution error: Error: x"); + assert_eq!(calls, " at
(/a.css.ts:1:53)"); +} + +#[test] +#[serial] +fn an_imported_module_is_told_by_its_own_file() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nimport { color } from './b';\nexport const token = createVar(color);\n", + &[( + "/b.ts", + "const label: string = 'helper boom';\n\nfunction fail(): string {\n throw new Error(label);\n}\nexport const color: string = fail();\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/b.ts:4:9: JS execution error: Error: helper boom"); + assert_eq!(calls, " at fail (/b.ts:4:9)\n at
(/b.ts:6:34)"); +} + +#[test] +#[serial] +fn code_after_a_replaced_import_keeps_its_place() { + let error = error_of( + "/a.css.ts", + "import { out } from './b';\nexport const x = out;\n", + &[ + ( + "/b.ts", + "import { f } from './c';\nexport const b = 'blue';\nexport const out = f();\n", + ), + ( + "/c.ts", + "import { b } from './b';\nexport const f = () => {\n const v = b;\n throw new Error(v);\n};\n", + ), + ], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/c.ts:4:9: JS execution error: Error: blue"); + assert_eq!(calls, " at f (/c.ts:4:9)\n at
(/b.ts:3:21)"); +} + +#[test] +#[serial] +fn a_default_export_keeps_its_place() { + let error = error_of( + "/a.css.ts", + "export const label: string = 'x';\nexport default (() => {\n throw new Error(label);\n})();\n", + &[], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:3:9: JS execution error: Error: x"); + assert_eq!( + calls, + " at
(/a.css.ts:3:9)\n at
(/a.css.ts:4:3)" + ); +} + +#[test] +#[serial] +fn an_early_read_in_an_import_cycle_points_at_the_read() { + let error = error_of( + "/a.css.ts", + "import { createVar } from '@devup-ui/react';\nimport { color } from './b';\nexport const token = createVar(color);\n", + &[ + ( + "/b.ts", + "import { other } from './c';\nexport const color = other;\n", + ), + ( + "/c.ts", + "import { color } from './b';\nexport const other = color;\n", + ), + ], + ); + let (lead, fix, calls) = parts(&error); + assert_eq!( + lead, + "/c.ts:2:22: JS execution error: ReferenceError: Cannot access 'color' of '/b.ts' before its initialization: it is part of an import cycle" + ); + assert!(fix.starts_with("break the import cycle"), "{fix}"); + assert_eq!(calls, " at
(/c.ts:2:22)"); +} + +#[test] +#[serial] +fn a_throw_of_what_is_not_an_error_and_calls_the_build_gives_are_told() { + let error = error_of("/a.css.ts", "throw 'text';\n", &[]); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:7: JS execution error: \"text\""); + assert_eq!(calls, " at
(/a.css.ts:1:7)"); + + let error = error_of( + "/a.css.ts", + "[1].map(() => { throw new Error('in map'); });\n", + &[], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/a.css.ts:1:23: JS execution error: Error: in map"); + assert_eq!( + calls, + " at
(/a.css.ts:1:23)\n at map (native)\n at
(/a.css.ts:1:8)" + ); +} + +#[test] +#[serial] +fn duplicate_lexical_declarations_are_located_before_execution() { + let error = error_of("/a.css.ts", "let a = 1;\nlet a = 2;\n", &[]); + let (lead, fix, calls) = parts(&error); + assert!( + lead.starts_with("/a.css.ts:1:5: JS execution error: SyntaxError:"), + "{error}" + ); + assert_eq!(fix, "correct the syntax at this location"); + assert_eq!(calls, ""); +} + +#[test] +#[serial] +fn retained_imported_functions_keep_their_original_frame() { + let error = error_of( + "/a.css.ts", + "import { fail } from './d.css';\nexport const x = fail();\n", + &[( + "/d.css.ts", + "import { style } from '@devup-ui/react';\nexport const s = style({ color: 'red' });\nexport const fail = () => { throw new Error('late'); };\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/d.css.ts:3:35: JS execution error: Error: late"); + assert_eq!( + calls, + " at fail (/d.css.ts:3:35)\n at
(/a.css.ts:2:22)" + ); +} + +#[test] +#[serial] +fn an_imported_stylesheet_is_told_by_its_own_file() { + let error = error_of( + "/a.css.ts", + "import { d } from './d.css';\nexport const x = d;\n", + &[( + "/d.css.ts", + "import { style } from '@devup-ui/react';\n\nthrow new Error('nested');\nexport const d = style({});\n", + )], + ); + let (lead, _, calls) = parts(&error); + assert_eq!(lead, "/d.css.ts:3:7: JS execution error: Error: nested"); + assert_eq!(calls, " at
(/d.css.ts:3:7)"); +} + +#[test] +#[serial] +fn an_import_that_cannot_be_loaded_is_told_where_it_is_written() { + reset_file_map(); + let code = "import { b } from './b';\nexport const x = b;\n"; + let without_resolver = execute_located( + Stylesheet { + filename: "/a.css.ts", + code, + source: code, + edits: &[], + }, + &ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + assert!( + without_resolver + .starts_with("/a.css.ts:1:19: Cannot load './b' without a module resolver. Fix: "), + "{without_resolver}" + ); + + for (code, expected) in [ + ( + "import { b } from './missing';\nexport const x = b;\n", + "/a.css.ts:1:19: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "export { x } from './missing';\n", + "/a.css.ts:1:19: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "export * from './missing';\n", + "/a.css.ts:1:15: Cannot resolve './missing' from '/a.css.ts'. Fix: ", + ), + ( + "import { b } from './m';\nexport const x = b;\n", + "/m.ts:1:19: Cannot resolve './nowhere' from '/m.ts'. Fix: ", + ), + ] { + let error = error_of( + "/a.css.ts", + code, + &[( + "/m.ts", + "import { z } from './nowhere';\nexport const b = z;\n", + )], + ); + assert!(error.starts_with(expected), "{error}"); + } + + let required = error_of( + "/a.css.js", + "const { color } = require('./missing');\nexports.card = color;\n", + &[], + ); + assert!( + required.starts_with("/a.css.js:1:27: Cannot resolve './missing' from '/a.css.js'. Fix: "), + "{required}" + ); +} diff --git a/libs/extractor/src/module_loader/tests/regressions.rs b/libs/extractor/src/module_loader/tests/regressions.rs new file mode 100644 index 000000000..7317821e1 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/regressions.rs @@ -0,0 +1,188 @@ +use serial_test::serial; + +use super::{error_of, parts}; + +#[rstest::rstest] +#[case("globalStyle('body', { get width() { throw new Error('serialize'); } })")] +#[case("globalFontFace('font', [{ get src() { throw new Error('serialize'); } }])")] +#[case("styleVariants({ a: { toJSON() { throw new Error('serialize'); } } })")] +#[case("style({ toJSON() { throw new Error('serialize'); } })")] +#[case("keyframes({ toJSON() { throw new Error('serialize'); } })")] +#[case("fontFace({ toJSON() { throw new Error('serialize'); } })")] +#[case("({ get width() { throw new Error('serialize'); } })")] +#[case("[1, { get width() { throw new Error('serialize'); } }]")] +#[serial] +fn serialization_exceptions_keep_the_original_throw_location( + #[case] expression: &str, +) -> Result<(), String> { + let source = format!( + "import {{ style, globalStyle, globalFontFace, styleVariants, keyframes, fontFace }} from '@devup-ui/react';\nexport const result = {expression};\n" + ); + let expected = crate::locate( + "/a.css.ts", + &source, + source.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: Error: serialize. Fix: " + )), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn fatal_entry_syntax_is_rejected_before_stripping() { + let source = "import { style } from '@devup-ui/react';\nexport const broken = ;\n"; + + let error = error_of("/a.css.ts", source, &[]); + + assert!( + error.starts_with("/a.css.ts:2:23: JS execution error: SyntaxError:"), + "{error}" + ); + assert!(error.contains("Fix: correct the syntax"), "{error}"); +} + +#[rstest::rstest] +#[case(&[("/b.ts", "export const broken = ;\n")], "./b")] +#[case(&[("/b.css.ts", "export const broken = ;\n")], "./b.css")] +#[serial] +fn fatal_import_syntax_is_located_in_the_imported_file( + #[case] files: super::Files, + #[case] specifier: &str, +) { + let filename = files[0].0; + let entry = format!("import {{ broken }} from '{specifier}';\nexport const x = broken;\n"); + + let error = error_of("/a.css.ts", &entry, files); + + assert!( + error.starts_with(&format!( + "{filename}:1:23: JS execution error: SyntaxError:" + )), + "{error}" + ); +} + +#[test] +#[serial] +fn alias_edits_are_applied_to_parser_diagnostic_labels() -> Result<(), String> { + use crate::import_alias_visit::transform_import_aliases_with_edits; + use crate::vanilla_extract::{Stylesheet, execute_located}; + let source = "import { style } from '@vanilla-extract/css'; export const broken = ;"; + let aliases = std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + )]); + let aliased = + transform_import_aliases_with_edits(source, "/a.css.ts", super::PACKAGE, &aliases); + let expected = crate::locate( + "/a.css.ts", + source, + source.rfind(';').ok_or("missing semicolon fixture")?, + ); + + let error = execute_located( + Stylesheet { + filename: "/a.css.ts", + code: &aliased.code, + source, + edits: &[&aliased.edits], + }, + &crate::ExtractOption::default(), + None, + ) + .err() + .ok_or("expected invalid syntax to fail")?; + + assert!( + error.starts_with(&format!("{expected}: JS execution error: SyntaxError:")), + "{error}" + ); + Ok(()) +} + +#[test] +#[serial] +fn typed_retained_nested_functions_keep_original_coordinates() -> Result<(), String> { + const IMPORTED: &str = "import { style } from '@devup-ui/react';\nexport const s = style({});\nexport const invoke = (): string => {\n const inner = (): string => { throw new Error('nested retained'); };\n return inner();\n};"; + let source = "import { invoke } from './b.css';\nexport const x = invoke();"; + let imported = IMPORTED; + let expected = crate::locate( + "/b.css.ts", + imported, + imported.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of("/a.css.ts", source, &[("/b.css.ts", IMPORTED)]); + + let (lead, _, calls) = parts(&error); + assert_eq!( + lead, + format!("{expected}: JS execution error: Error: nested retained") + ); + assert!( + calls.starts_with(&format!(" at inner ({expected})")), + "{error}" + ); + assert!(calls.contains("at invoke (/b.css.ts:5:"), "{error}"); + assert!(calls.ends_with("at
(/a.css.ts:2:24)"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn retained_function_frames_survive_style_extraction_inside_the_function() -> Result<(), String> { + const IMPORTED: &str = "import { style, css } from '@devup-ui/react';\nexport const s = style({});\nexport const fail = () => { const c = css({ color: 'red' }); throw new Error('compiled retained'); };"; + let expected = crate::locate( + "/b.css.ts", + IMPORTED, + IMPORTED.find("new Error").ok_or("missing throw fixture")?, + ); + + let error = error_of( + "/a.css.ts", + "import { fail } from './b.css';\nexport const x = fail();", + &[("/b.css.ts", IMPORTED)], + ); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: Error: compiled retained. Fix:" + )), + "{error}" + ); + Ok(()) +} + +#[rstest::rstest] +#[case("style")] +#[case("fontFace")] +#[case("keyframes")] +#[serial] +fn cyclic_json_values_report_the_serialization_exception(#[case] api: &str) -> Result<(), String> { + let source = format!( + "import {{ {api} }} from '@devup-ui/react';\nconst o = {{}}; o.self = o;\nexport const result = {api}(o);" + ); + let expected = crate::locate( + "/a.css.ts", + &source, + source.find("(o)").ok_or("missing call fixture")?, + ); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: TypeError: cyclic object value. Fix:" + )), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/sandbox.rs b/libs/extractor/src/module_loader/tests/sandbox.rs new file mode 100644 index 000000000..9a76c008d --- /dev/null +++ b/libs/extractor/src/module_loader/tests/sandbox.rs @@ -0,0 +1,124 @@ +use serial_test::serial; + +use super::{error_of, parts}; + +#[rstest::rstest] +#[case("new Date(0)", "Date")] +#[case("Math.random()", "Math.random")] +#[case("globalThis['window']", "window")] +#[case( + "(() => { try { Date.now(); } catch (error) { delete error.message; throw error; } })()", + "Date" +)] +#[case( + "(() => { try { Date.now(); } catch (error) { error.message = 'forged'; } return 'red'; })()", + "Date" +)] +#[serial] +fn forbidden_reads_keep_the_actual_original_site_and_immutable_cause( + #[case] expression: &str, + #[case] name: &str, +) -> Result<(), String> { + let source = + format!("import {{ style }} from '@devup-ui/react';\nexport const card = {expression};"); + let token = match name { + "Math.random" => "Math", + "window" => "globalThis", + _ => name, + }; + let offset = source.find(token).ok_or("missing read fixture")?; + let expected = crate::locate("/a.css.ts", &source, offset); + + let error = error_of("/a.css.ts", &source, &[]); + + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: ReferenceError: `{name}` cannot be read at build time:" + )), + "{error}" + ); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(!error.contains("forged"), "{error}"); + for internal in [ + "devup-ui-stylesheet", + "", + "__devup_read_site_", + ] { + assert!(!error.contains(internal), "{error}"); + } + Ok(()) +} + +#[test] +#[serial] +fn a_retained_stylesheet_function_reports_its_own_forbidden_read() { + const IMPORTED: &str = "import { style } from '@devup-ui/react';\nexport const s = style({});\nexport const read = (): string => {\n return Date.now();\n};"; + + let error = error_of( + "/a.css.ts", + "import { read } from './b.css';\nexport const result = read();", + &[("/b.css.ts", IMPORTED)], + ); + + assert!( + error.starts_with("/b.css.ts:4:10: JS execution error: ReferenceError: `Date`"), + "{error}" + ); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[rstest::rstest] +#[case("[...null]")] +#[case("[...[...null]]")] +#[case("[...[], ...null]")] +#[serial] +fn an_iterable_operation_without_an_engine_frame_is_located_exactly(#[case] expression: &str) { + let source = format!( + "import {{ style }} from '@devup-ui/react';\nconst unused = {expression};\nexport const card = style({{ color: 'red' }});" + ); + + let error = error_of("/a.css.ts", &source, &[]); + + let (lead, _, _) = parts(&error); + let column = match expression { + "[...[...null]]" => 21, + "[...[], ...null]" => 24, + _ => 17, + }; + assert_eq!( + lead, + format!( + "/a.css.ts:2:{column}: JS execution error: TypeError: cannot convert 'null' or 'undefined' to object" + ), + "{error}" + ); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[test] +#[serial] +fn iterable_markers_preserve_receiver_lexical_values_and_evaluation_order() -> Result<(), String> { + let source = "const events = [];\nconst iterable = { value: 'red', *[Symbol.iterator]() { events.push(this.value); yield this.value; } };\nexport const values = (() => { const local = 'blue'; return [events.push('before'), ...iterable, local, events.push('after')]; })();\nexport const order = events;"; + + let (collected, _) = crate::vanilla_extract::execute_stylesheet( + source, + "/a.css.ts", + &crate::ExtractOption::default(), + None, + )?; + + assert_eq!( + collected.constant_exports, + [ + ( + "values".to_string(), + "[1, \"red\", \"blue\", 3]".to_string() + ), + ( + "order".to_string(), + "[\"before\", \"red\", \"after\"]".to_string() + ) + ] + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/validate.rs b/libs/extractor/src/module_loader/validate.rs new file mode 100644 index 000000000..12f3d47bf --- /dev/null +++ b/libs/extractor/src/module_loader/validate.rs @@ -0,0 +1,37 @@ +//! Reject invalid source before stripping can discard the failing program. + +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; + +use crate::import_alias_visit::source_offset; +use crate::vanilla_extract::Stylesheet; + +pub(crate) fn validate(input: Stylesheet<'_>) -> Result<(), String> { + let allocator = Allocator::default(); + let source_type = SourceType::from_path(input.filename).unwrap_or_else(|_| SourceType::ts()); + let parsed = Parser::new(&allocator, input.code, source_type).parse(); + let semantic = SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&parsed.program); + if let Some(error) = parsed + .diagnostics + .iter() + .chain(semantic.diagnostics.iter()) + .next() + { + let offset = error.labels.first().map_or(0, |label| { + usize::try_from(label.offset()).unwrap_or(input.code.len()) + }); + let offset = input + .edits + .iter() + .fold(offset, |at, edits| source_offset(edits, at)); + return Err(format!( + "{}: JS execution error: SyntaxError: {error}. Fix: correct the syntax at this location", + crate::locate(input.filename, input.source, offset) + )); + } + Ok(()) +} diff --git a/libs/extractor/src/source_map.rs b/libs/extractor/src/source_map.rs index e71cd1fcc..78b544043 100644 --- a/libs/extractor/src/source_map.rs +++ b/libs/extractor/src/source_map.rs @@ -7,14 +7,18 @@ use oxc_sourcemap::{SourceMap, Token}; use crate::import_alias_visit::{Edit, source_offset}; +mod trace; + +pub(crate) use trace::{Trace, marks}; + /// Where the lines of a text start, split as the codegen splits them -struct Lines<'t> { +pub(crate) struct Lines<'t> { text: &'t str, starts: Vec, } impl<'t> Lines<'t> { - fn new(text: &'t str) -> Self { + pub(crate) fn new(text: &'t str) -> Self { let mut starts = vec![0]; let mut characters = text.char_indices().peekable(); while let Some((index, character)) = characters.next() { @@ -28,13 +32,16 @@ impl<'t> Lines<'t> { Self { text, starts } } - /// The byte offset of `column`, in UTF-16 units, on `line` - fn offset(&self, line: u32, column: u32) -> usize { - let start = self - .starts + fn line_start(&self, line: u32) -> usize { + self.starts .get(line as usize) .copied() - .unwrap_or(self.text.len()); + .unwrap_or(self.text.len()) + } + + /// The byte offset of `column`, in UTF-16 units, on `line` + pub(crate) fn offset(&self, line: u32, column: u32) -> usize { + let start = self.line_start(line); let mut units = 0; for (index, character) in self.text[start..].char_indices() { if units >= column as usize { @@ -45,6 +52,16 @@ impl<'t> Lines<'t> { self.text.len() } + /// The byte offset of the code point `column` on `line`, the way the + /// script engine counts columns + pub(crate) fn code_point_offset(&self, line: u32, column: u32) -> usize { + let start = self.line_start(line); + self.text[start..] + .char_indices() + .nth(column as usize) + .map_or(self.text.len(), |(index, _)| start + index) + } + /// The line and UTF-16 column of the byte `offset` fn position(&self, offset: usize) -> (u32, u32) { let line = self.starts.partition_point(|start| *start <= offset) - 1; diff --git a/libs/extractor/src/source_map/trace.rs b/libs/extractor/src/source_map/trace.rs new file mode 100644 index 000000000..589f1ea44 --- /dev/null +++ b/libs/extractor/src/source_map/trace.rs @@ -0,0 +1,64 @@ +//! Offsets of generated text, traced back to the text it was made from + +use oxc_sourcemap::SourceMap; + +use super::Lines; +use crate::import_alias_visit::{Edit, source_offset}; + +#[cfg(test)] +mod tests; + +/// `(offset in generated, offset in parsed)` for every token of `map`, the map +/// of `generated` made from `parsed`; in ascending order +pub(crate) fn marks(map: &SourceMap<'_>, generated: &str, parsed: &str) -> Vec<(usize, usize)> { + let (generated_lines, parsed_lines) = (Lines::new(generated), Lines::new(parsed)); + map.get_tokens() + .map(|token| { + ( + generated_lines.offset(token.get_dst_line(), token.get_dst_col()), + parsed_lines.offset(token.get_src_line(), token.get_src_col()), + ) + }) + .collect() +} + +/// Where the text of a stage came from in the source as written +pub(crate) struct Trace { + /// `(offset in generated, offset in source)`, ascending, the start of the + /// generated text first + marks: Vec<(usize, usize)>, +} + +impl Trace { + /// `marks` tie the generated text to the code it was parsed from; each layer + /// of `edits`, last made first, maps that code back to the source + pub(crate) fn new(marks: &[(usize, usize)], edits: &[&[Edit]]) -> Self { + let back = |offset| { + edits + .iter() + .fold(offset, |offset, edits| source_offset(edits, offset)) + }; + Self { + marks: std::iter::once((0, back(0))) + .chain( + marks + .iter() + .map(|&(generated, parsed)| (generated, back(parsed))), + ) + .collect(), + } + } + + /// The offset in `source` of the byte `offset` of `generated`: the one of + /// the closest mark before it, moved on by the text both share after it + pub(crate) fn resolve(&self, generated: &str, source: &str, offset: usize) -> usize { + let at = self.marks.partition_point(|(from, _)| *from <= offset); + let (from, to) = self.marks[at - 1]; + let shared = generated.as_bytes().get(from..offset).unwrap_or_default(); + if source.as_bytes().get(to..to + shared.len()) == Some(shared) { + to + shared.len() + } else { + to + } + } +} diff --git a/libs/extractor/src/source_map/trace/tests.rs b/libs/extractor/src/source_map/trace/tests.rs new file mode 100644 index 000000000..b68565ea7 --- /dev/null +++ b/libs/extractor/src/source_map/trace/tests.rs @@ -0,0 +1,51 @@ +use std::path::PathBuf; + +use oxc_allocator::Allocator; +use oxc_codegen::{Codegen, CodegenOptions}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::*; + +#[test] +fn marks_tie_the_generated_tokens_to_the_parsed_ones() { + let source = "let a = 1;\nlet b = a;\n"; + let allocator = Allocator::default(); + let program = Parser::new(&allocator, source, SourceType::mjs()) + .parse() + .program; + let output = Codegen::new() + .with_options(CodegenOptions { + source_map_path: Some(PathBuf::from("a.js")), + ..CodegenOptions::default() + }) + .build(&program); + assert_eq!(output.code, "let a = 1;\nlet b = a;\n"); + let marks = output + .map + .map(|map| marks(&map, &output.code, source)) + .unwrap_or_default(); + assert!(marks.contains(&(15, 19)), "{marks:?}"); + assert!(marks.contains(&(19, 23)), "{marks:?}"); + assert!(marks.is_sorted(), "{marks:?}"); +} + +#[test] +fn a_position_follows_the_closest_mark_and_the_text_after_it() { + let trace = Trace::new(&[(11, 13)], &[]); + let generated = "let a = 1; throw 2;"; + let source = "let a = 1;\n throw 2;"; + assert_eq!(trace.resolve(generated, source, 11), 13); + assert_eq!(trace.resolve(generated, source, 14), 16); + assert_eq!(trace.resolve(generated, source, 3), 3); + assert_eq!(trace.resolve("x 'a'", "x \"a\"", 3), 0); +} + +#[test] +fn the_layers_of_edits_are_undone_last_made_first() { + let first = [(1, 3, 3)]; + let last = [(5, 7, 1)]; + let trace = Trace::new(&[(5, 5)], &[&last, &first]); + assert_eq!(trace.resolve("aXYZdG", "abcdef", 5), 4); + assert_eq!(trace.resolve("aXYZdG", "abcdef", 0), 0); +} diff --git a/libs/extractor/src/stylesheet_policy.rs b/libs/extractor/src/stylesheet_policy.rs new file mode 100644 index 000000000..6b504c899 --- /dev/null +++ b/libs/extractor/src/stylesheet_policy.rs @@ -0,0 +1,274 @@ +//! Which stylesheets (`.css.ts`, `.css.js`) the build extracts as they are +//! written, and which it runs. +//! +//! A stylesheet the build runs has its effects at build time, so extracting it +//! without running it keeps its meaning only when it has none but the ones the +//! extraction compiles: it only declares constants of plain data, creates +//! styles with Devup UI's own `css`, `globalCss`, `keyframes` and `styled`, +//! and exports them. The proof reads the module and nothing runs: any other +//! statement, a call of anything else, an accessor, a mutation or a binding +//! the module does not give the build as plain data is a stylesheet to run. + +use oxc_ast::ast::{ + Argument, BindingPattern, CallExpression, Declaration, ExportDefaultDeclarationKind, + ExportSpecifier, Expression, IdentifierReference, ImportDeclaration, + ImportDeclarationSpecifier, ModuleExportName, Statement, VariableDeclaration, + VariableDeclarationKind, +}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +use crate::util_type::UtilType; +use crate::utils::unwrap_syntax_only; + +#[cfg(test)] +#[path = "stylesheet_policy_tests.rs"] +mod tests; + +#[path = "stylesheet_policy_values.rs"] +mod values; +use values::Value; + +#[path = "stylesheet_policy_dispatch.rs"] +mod dispatch; +pub(crate) use dispatch::plan; + +/// What the build does with a stylesheet +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Plan { + /// Extract it as written: it has no effect the extraction does not compile + Plain, + /// Run it, and fail when running it fails + Run, +} + +/// The members of `Math` the build folds, which the extraction computes the +/// way every engine does +const MATH_FOLDED: [&str; 10] = [ + "abs", "ceil", "floor", "trunc", "sqrt", "sign", "round", "max", "min", "pow", +]; + +const MATH_CONSTANTS: [&str; 8] = [ + "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", +]; + +/// What the module proves, one statement at a time +struct Proof<'s> { + scoping: &'s Scoping, + /// Where the package's style APIs are bound, by the API they bind + apis: FxHashMap, + namespaces: FxHashSet, + /// Bindings of plain data or of what the styles create, declared so far: + /// reading one declared later reads it before it is initialized + values: FxHashMap, +} + +impl Proof<'_> { + fn symbol(&self, identifier: &IdentifierReference<'_>) -> Option { + self.scoping + .get_reference(identifier.reference_id.get()?) + .symbol_id() + } + + /// Whether `expression` names the global `name`, which nothing declares + fn is_global(&self, expression: &Expression<'_>, name: &str) -> bool { + matches!(expression, Expression::Identifier(identifier) + if identifier.name == name && self.symbol(identifier).is_none()) + } + + fn api<'a>(&'a self, expression: &'a Expression<'_>) -> Option<&'a str> { + match unwrap_syntax_only(expression) { + Expression::Identifier(identifier) => { + self.apis.get(&self.symbol(identifier)?).map(String::as_str) + } + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(identifier) = &member.object else { + return None; + }; + let name = member.property.name.as_str(); + (self.namespaces.contains(&self.symbol(identifier)?) + && (name == "styled" || UtilType::from_str_opt(name).is_some())) + .then_some(name) + } + _ => None, + } + } + + /// `styled`, `styled.div`, `styled(Link)`, `styled.div.attrs({})` + fn styled(&self, expression: &Expression<'_>) -> bool { + if self.api(expression) == Some("styled") { + return true; + } + match unwrap_syntax_only(expression) { + Expression::StaticMemberExpression(member) => self.styled(&member.object), + Expression::CallExpression(call) => { + self.styled(&call.callee) && self.arguments(&call.arguments) + } + callee => self.api(callee) == Some("styled"), + } + } + + /// Whether `callee` is a style API the build compiles + fn creates(&self, callee: &Expression<'_>) -> bool { + let callee = unwrap_syntax_only(callee); + self.api(callee).is_some() || self.styled(callee) + } + + fn arguments(&self, arguments: &[Argument<'_>]) -> bool { + arguments.iter().all(|argument| match argument { + Argument::SpreadElement(spread) => self + .value(&spread.argument) + .is_some_and(|value| value.iterable()), + argument => self.pure(argument.to_expression()), + }) + } + + /// A style API called, or its template written, over plain data + fn creation(&self, expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::CallExpression(call) => { + self.creates(&call.callee) && self.arguments(&call.arguments) + } + Expression::TaggedTemplateExpression(tagged) => { + self.creates(&tagged.tag) + && tagged + .quasi + .expressions + .iter() + .all(|expression| self.pure(expression)) + } + _ => false, + } + } + + /// A `Math` function over plain data, which the build folds + fn folds(&self, call: &CallExpression<'_>) -> bool { + matches!(unwrap_syntax_only(&call.callee), Expression::StaticMemberExpression(member) + if self.is_global(&member.object, "Math") + && MATH_FOLDED.contains(&member.property.name.as_str())) + && self.arguments(&call.arguments) + } + + /// Whether reading `expression` has no effect and gives the same on every + /// build: plain data, what the module declared before it, and what the + /// build folds. A function, an accessor, a call of anything else or a + /// global is not. + fn pure(&self, expression: &Expression<'_>) -> bool { + self.value(expression).is_some() + } + + /// Binds what `import` gives, when it gives only what the build knows: the + /// package's style APIs, types, and constants of plain data + fn import( + &mut self, + import: &ImportDeclaration<'_>, + packages: [&str; 2], + known: &dyn Fn(&str) -> Option, + ) -> bool { + if import.import_kind.is_type() { + return true; + } + let specifiers = import.specifiers.as_deref().map_or(&[][..], |s| s); + let of_package = packages.contains(&import.source.value.as_str()); + !specifiers.is_empty() + && specifiers.iter().all(|specifier| match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) + if specifier.import_kind.is_type() => + { + true + } + ImportDeclarationSpecifier::ImportSpecifier(specifier) if of_package => { + let name = specifier.imported.name(); + let name = name.as_str(); + (name == "styled" || UtilType::from_str_opt(name).is_some()) + && specifier.local.symbol_id.get().is_some_and(|symbol| { + self.apis.insert(symbol, name.to_string()); + true + }) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) if of_package => { + specifier.local.symbol_id.get().is_some_and(|symbol| { + self.namespaces.insert(symbol); + true + }) + } + specifier => { + !of_package + && known(specifier.local().name.as_str()).is_some() + && specifier.local().symbol_id.get().is_some_and(|symbol| { + if let Some(value) = known(specifier.local().name.as_str()) { + self.values.insert(symbol, value); + } + true + }) + } + }) + } + + /// Declares the constants of `declaration`, each of what the build reads + fn constants(&mut self, declaration: &VariableDeclaration<'_>) -> bool { + if declaration.declare { + return true; + } + declaration.kind == VariableDeclarationKind::Const + && declaration.declarations.iter().all(|declarator| { + let (BindingPattern::BindingIdentifier(identifier), Some(init)) = + (&declarator.id, &declarator.init) + else { + return false; + }; + match (self.value(init), identifier.symbol_id.get()) { + (Some(value), Some(symbol)) => { + self.values.insert(symbol, value); + true + } + _ => false, + } + }) + } + + fn declaration(&mut self, declaration: &Declaration<'_>) -> bool { + match declaration { + Declaration::VariableDeclaration(declaration) => self.constants(declaration), + Declaration::TSTypeAliasDeclaration(_) | Declaration::TSInterfaceDeclaration(_) => true, + _ => false, + } + } + + fn export(&self, specifier: &ExportSpecifier<'_>) -> bool { + specifier.export_kind.is_type() + || matches!(&specifier.local, ModuleExportName::IdentifierReference(identifier) + if self.symbol(identifier).is_some_and(|symbol| self.values.contains_key(&symbol))) + } + + /// Whether running `statement` has no effect but the ones the build compiles + fn statement(&mut self, statement: &Statement<'_>) -> bool { + match statement { + Statement::EmptyStatement(_) + | Statement::ImportDeclaration(_) + | Statement::TSTypeAliasDeclaration(_) + | Statement::TSInterfaceDeclaration(_) => true, + Statement::ExpressionStatement(statement) => self.creation(&statement.expression), + Statement::VariableDeclaration(declaration) => self.constants(declaration), + Statement::ExportDeclaration(export) => self.declaration(&export.declaration), + Statement::ExportNamedDeclaration(export) => { + export.export_kind.is_type() + || export + .specifiers + .iter() + .all(|specifier| self.export(specifier)) + } + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => true, + ExportDefaultDeclarationKind::FunctionDeclaration(_) + | ExportDefaultDeclarationKind::ClassDeclaration(_) => false, + declaration => self.pure(declaration.to_expression()), + }, + _ => false, + } + } +} + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/stylesheet_policy/coverage_tests.rs b/libs/extractor/src/stylesheet_policy/coverage_tests.rs new file mode 100644 index 000000000..f03ba4dca --- /dev/null +++ b/libs/extractor/src/stylesheet_policy/coverage_tests.rs @@ -0,0 +1,50 @@ +use rstest::rstest; + +use super::{Plan, plan}; +use crate::ExtractOption; + +#[rstest] +#[case("css(...[{ color: 'red' }])", Plan::Plain)] +#[case("css(...'ab')", Plan::Plain)] +#[case("css(...null)", Plan::Run)] +#[case("css(...{ color: 'red' })", Plan::Run)] +#[case("css({ color: [...'ab'][0] })", Plan::Plain)] +#[case("css({ color: [...['red']][0] })", Plan::Plain)] +#[case("css({ color: [, 'red'][0] })", Plan::Plain)] +#[case("css({ color: ({ ...['red'] })[0] })", Plan::Plain)] +#[case("css({ color: ({ ...'red' })[0] })", Plan::Plain)] +#[case("css({ color: ({ ...'😀' })[0] })", Plan::Plain)] +#[case("css({ ...null, ...true, ...1, color: 'red' })", Plan::Plain)] +#[case("css({ ...(true ? {} : []) })", Plan::Run)] +#[case("css({ color: {}.constructor })", Plan::Run)] +#[case("css({ color: {}.__proto__ })", Plan::Run)] +#[case("css({ color: {}.toString })", Plan::Run)] +#[case("css({ color: {}.valueOf })", Plan::Run)] +#[case("css({ color: {}.missing })", Plan::Plain)] +#[case("css({ w: [1].length, h: 'ab'.length })", Plan::Plain)] +#[case("css({ color: 'ab'[0] })", Plan::Plain)] +#[case("css({ color: 'ab'[9] })", Plan::Plain)] +#[case("css({ color: '😀'[0] })", Plan::Plain)] +#[case("css({ color: `${{}}` })", Plan::Run)] +#[case("css({ color: true && 'red' })", Plan::Plain)] +#[case("css({ color: 'red' || 'red' })", Plan::Plain)] +#[case("css({ color: (1, 'red') })", Plan::Plain)] +#[case("css({ color: Math.max(...[1, 2]) })", Plan::Run)] +fn dispatch_runs_only_when_plain_data_cannot_prove_nonthrowing_evaluation( + #[case] expression: &str, + #[case] expected: Plan, +) { + // Given + let code = + format!("import {{ css }} from '@devup-ui/react'; export const rule = {expression};"); + // When + let actual = plan( + &code, + "boundary.css.ts", + &ExtractOption::default(), + None, + &|_| false, + ); + // Then + assert_eq!(actual, expected, "{expression}"); +} diff --git a/libs/extractor/src/stylesheet_policy_dispatch.rs b/libs/extractor/src/stylesheet_policy_dispatch.rs new file mode 100644 index 000000000..dd63540c2 --- /dev/null +++ b/libs/extractor/src/stylesheet_policy_dispatch.rs @@ -0,0 +1,72 @@ +//! Parsing and dispatch of the stylesheet's nonthrowing proof. + +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::{Plan, Proof, Value, values}; +use crate::imported_constants::ChangeCheck; +use crate::{ExtractOption, ModuleResolver}; + +/// Decides whether extraction preserves every effect before anything runs. +pub(crate) fn plan( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, + is_static: &dyn Fn(&str) -> bool, +) -> Plan { + let allocator = Allocator::default(); + let Ok(source_type) = SourceType::from_path(filename) else { + return Plan::Run; + }; + let parsed = Parser::new(&allocator, code, source_type).parse(); + if parsed.fatal_error || !parsed.diagnostics.is_empty() { + return Plan::Run; + } + let built = SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&parsed.program); + if !built.diagnostics.is_empty() { + return Plan::Run; + } + let scoping = built.semantic.into_scoping(); + let changes = ChangeCheck::new(&parsed.program, filename, option, resolver); + let known = |name: &str| { + if is_static(name) { + return Some(Value::Scalar); + } + if changes.is_changed(name) { + return None; + } + values::literal(&changes.known(name)?) + }; + let compat = format!("{}/compat", option.package); + let mut proof = Proof { + scoping: &scoping, + apis: FxHashMap::default(), + namespaces: FxHashSet::default(), + values: FxHashMap::default(), + }; + // Imports are bound before any statement runs. + let imports = parsed.program.body.iter().all(|statement| match statement { + Statement::ImportDeclaration(import) => { + proof.import(import, [option.package.as_str(), compat.as_str()], &known) + } + _ => true, + }); + if imports + && parsed + .program + .body + .iter() + .all(|statement| proof.statement(statement)) + { + Plan::Plain + } else { + Plan::Run + } +} diff --git a/libs/extractor/src/stylesheet_policy_import_tests.rs b/libs/extractor/src/stylesheet_policy_import_tests.rs new file mode 100644 index 000000000..c580607b3 --- /dev/null +++ b/libs/extractor/src/stylesheet_policy_import_tests.rs @@ -0,0 +1,54 @@ +use super::{Plan, plan}; +use crate::{ExtractOption, ResolvedModule}; + +#[test] +fn an_import_is_data_when_the_build_knows_its_value_whole() { + let resolver = |_: &str, _: &str| { + Some(ResolvedModule { + path: "/src/tokens.ts".to_string(), + code: "export const RED = 'red';\nexport const LIST = [1, 2];\nexport const mutated = { a: 1 };\nmutated.a = 2;\nexport const computed = Date.now();\nexport function twice(n) { return n * 2; }".to_string(), + }) + }; + let option = ExtractOption::default(); + let plan_of = |code: &str, is_static: &dyn Fn(&str) -> bool| { + plan( + &format!("import {{ css }} from '@devup-ui/react';\n{code}"), + "/src/a.css.ts", + &option, + Some(&resolver), + is_static, + ) + }; + let no = |_: &str| false; + for code in [ + "import { RED } from './tokens';\nexport const a = css({ color: RED });", + "import { RED, LIST } from './tokens';\nexport const a = css({ color: RED, w: LIST[1] });", + "import { RED as COLOR } from './tokens';\nexport const a = css({ color: COLOR });", + ] { + assert_eq!(plan_of(code, &no), Plan::Plain, "{code}"); + } + for code in [ + "import { mutated } from './tokens';\nexport const a = css({ ...mutated });", + "import { computed } from './tokens';\nexport const a = css({ w: computed });", + "import { twice } from './tokens';\nexport const a = css({ w: twice(2) });", + "import { missing } from './tokens';\nexport const a = css({ w: missing });", + "import * as tokens from './tokens';\nexport const a = css({ w: tokens.RED });", + ] { + assert_eq!(plan_of(code, &no), Plan::Run, "{code}"); + } + let classes = |name: &str| name == "card"; + assert_eq!( + plan_of( + "import { card } from './tokens';\nexport const a = css(card, { p: 1 });", + &classes + ), + Plan::Plain + ); + assert_eq!( + plan_of( + "import { card, other } from './tokens';\nexport const a = css(card, other);", + &classes + ), + Plan::Run + ); +} diff --git a/libs/extractor/src/stylesheet_policy_tests.rs b/libs/extractor/src/stylesheet_policy_tests.rs new file mode 100644 index 000000000..c33ed13be --- /dev/null +++ b/libs/extractor/src/stylesheet_policy_tests.rs @@ -0,0 +1,254 @@ +use rstest::rstest; + +use super::{Plan, plan}; +use crate::ExtractOption; + +#[path = "stylesheet_policy_import_tests.rs"] +mod imports; + +const HEAD: &str = "import { css, globalCss, keyframes, styled } from '@devup-ui/react';\n"; + +fn plan_of(code: &str) -> Plan { + plan(code, "a.css.ts", &ExtractOption::default(), None, &|_| { + false + }) +} + +fn plan_with_head(code: &str) -> Plan { + plan_of(&format!("{HEAD}{code}")) +} + +#[rstest] +#[case( + "import type { DevupProps } from '@devup-ui/react';\nimport { css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" +)] +#[case( + "import { type DevupProps, css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" +)] +#[case("import { css } from '@devup-ui/react/compat';\nexport const a = css({ color: 'red' });")] +#[case( + "'use client';\nimport { css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" +)] +#[case("")] +#[case("import * as ui from '@devup-ui/react';\nexport const a = ui.css({ color: 'red' });")] +#[case( + "import * as ui from '@devup-ui/react';\nexport const Card = ui.styled.div({ color: 'red' });" +)] +#[case( + "import { css } from '@devup-ui/react';\ndeclare const injected: number;\ntype A = 1;\nexport type { A };\nexport { type A as B };\nexport default interface Props {}\n;\nexport const a = css({ color: 'red' });" +)] +fn a_stylesheet_of_devup_styles_alone_is_extracted_as_written(#[case] code: &str) { + assert_eq!(plan_of(code), Plan::Plain, "{code}"); +} + +#[rstest] +#[case("export const a = css({ color: 'red', p: 4, _hover: { color: ['blue', null, 'green'] } });")] +#[case("const base = { color: 'red', p: 4 };\nexport const a = css({ ...base, m: 1 });")] +#[case( + "const sizes = [1, 2];\nconst base = { w: sizes[1] * 2 };\nexport const a = css({ ...base, ...{ h: Math.max(1, 2), top: -Math.PI }, bg: `${base.w}px` });" +)] +#[case( + "const size = 4;\nconst other = size > 2 ? 'a' : 'b';\nexport const a = css({ w: size, c: other, m: undefined, [`x${size}`]: 1 });" +)] +#[case( + "export const a = css({ color: 'red' });\nexport const b = css(a, { m: -1 });\nexport default b;" +)] +#[case( + "export const a = css({ color: 'red' });\nexport { a as renamed };\nexport type Props = { a: string };\nexport interface Other {}" +)] +#[case( + "globalCss({ body: { m: 0 } });\nexport const k = keyframes({ from: { opacity: 0 }, to: { opacity: 1 } });" +)] +#[case( + "export const Card = styled('div', { bg: 'white' });\nexport const Link = styled.a.attrs({ href: '#' })({ color: 'red' });\nexport const Big = styled(Card)({ p: 8 });" +)] +#[case("const rules = { color: 'red' };\nexport const a = css`color: ${rules.color};`;")] +#[case( + "type Tokens = { a: string };\nconst tokens: Tokens = { a: 'red' } as const;\nexport const a = css({ color: tokens.a as string, w: Math.round(2.5)! });" +)] +fn constants_references_and_spreads_the_visitor_reads_stay_plain(#[case] code: &str) { + assert_eq!(plan_with_head(code), Plan::Plain, "{code}"); +} + +#[rstest] +#[case("export const a = css({ color: 'red' });\nthrow new Error('fixture boom');")] +#[case("throw new Error('fixture boom');\nexport const red = css({ color: 'red' });")] +#[case("try { css({ color: 'red' }); } catch {}")] +#[case("const t = Date.now();\nexport const a = css({ w: t });")] +#[case("export const a = css({ w: Math.random() });")] +#[case("export const a = css({ w: Math.sin(1) });")] +#[case("export const a = css({ w: process.env.WIDTH });")] +#[case("export const a = css({ w: typeof window });")] +#[case("export const a = css({ w: window.innerWidth });")] +#[case("export const a = css({ w: isDark ? 1 : 2 });")] +#[case("export const a = css({ w: new Date().getFullYear() });")] +#[case("export const a = css({ get w() { return 1; } });")] +#[case("export const a = css({ set w(value) {} });")] +#[case("export const a = css({ w() { return 1; } });")] +#[case("export const a = css({ w: () => 1 });")] +#[case("export const a = css({ w: function () { return 1; } });")] +#[case("export const a = css({ [window.key]: 1 });")] +#[case("export const a = css({ __proto__: { w: 1 } });")] +#[case("const o = { get a() { return 1; } };\nexport const a = css({ ...o });")] +#[case("const o = { a: 1 };\no.a = 2;\nexport const a = css({ ...o });")] +#[case("const o = { a: 1 };\nObject.assign(o, { a: 2 });\nexport const a = css({ ...o });")] +#[case("let width = 1;\nexport const a = css({ w: width });")] +#[case("var width = 1;\nexport const a = css({ w: width });")] +#[case("const items = [];\nitems.push(1);\nexport const a = css({ w: items[0] });")] +#[case("export const a = css({ w: 2 ** 3 });")] +#[case("export const a = css({ w: delete globalThis.x });")] +#[case("export const a = css({ w: 'w' in {} });")] +#[case("export const a = css({ w: x?.y });")] +#[case("export const a = css({ w: String(1) });")] +#[case("export const a = css({ w: Math.max });")] +#[case("export const a = css({ w: later });\nconst later = 1;")] +#[case("export const a = css({ w: a });")] +#[case("const a = css({ w: a2 }), a2 = 1;")] +#[case("export const [a, b] = [1, 2];")] +#[case("export const { a } = { a: 1 };")] +#[case("export const a;")] +#[case("export function f() {}\nexport const a = css({ w: 1 });")] +#[case("function f() {}\nexport const a = css({ w: 1 });")] +#[case("export class A {}")] +#[case("export default function () {}")] +#[case("enum Size { Small = 1 }\nexport const a = css({ w: Size.Small });")] +#[case("export const a =
;")] +#[case("export * from './other';")] +#[case("export { other } from './other';")] +#[case("export const a = other`color: red;`;")] +#[case("css({ color: 'red' }).length;")] +#[case("1;")] +#[case("{ const x = 1; }")] +#[case("if (true) { css({ color: 'red' }); }")] +#[case("for (const x of []) css({ w: x });")] +#[case("export const a = css({ w: await Promise.resolve(1) });")] +#[case("export const a = css({ w: 1 };")] +#[case("export const a = css({ w: 1 });\nexport const a = css({ w: 2 });")] +#[case("export const a = css({ w: this.x });")] +#[case("const unused = null.color; export const a = css({ color: 'red' });")] +#[case("const unused = [...null]; export const a = css({ color: 'red' });")] +#[case("const unused = ({}).missing.color; export const a = css({ color: 'red' });")] +#[case( + "const unused = { valueOf: null, toString: null } + 1; export const a = css({ color: 'red' });" +)] +#[case("const a = 1;\nconst a = 2;\nexport const b = css({ w: a });")] +fn any_other_effect_makes_it_a_stylesheet_to_run(#[case] code: &str) { + assert_eq!(plan_with_head(code), Plan::Run, "{code}"); +} + +#[rstest] +#[case("import { style } from '@devup-ui/react';\nexport const a = style({ color: 'red' });")] +#[case( + "import { css, style } from '@devup-ui/react';\nexport const a = css({ color: 'red' });\nexport const b = style({ color: 'blue' });" +)] +#[case( + "import { css } from '@devup-ui/react';\nimport { Box } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" +)] +#[case("import css from '@devup-ui/react';\nexport const a = css({ color: 'red' });")] +#[case("import { css } from '@devup-ui/react-other';\nexport const a = css({ color: 'red' });")] +#[case("import { css } from 'other';\nexport const a = css({ color: 'red' });")] +#[case( + "import { css } from '@devup-ui/react';\nimport './side-effect';\nexport const a = css({ color: 'red' });" +)] +#[case( + "import { css } from '@devup-ui/react';\nimport {} from './side-effect';\nexport const a = css({ color: 'red' });" +)] +#[case( + "import { css } from '@devup-ui/react';\nimport { tokens } from './tokens';\nexport const a = css({ color: tokens.red });" +)] +#[case( + "import { css } from '@devup-ui/react';\nimport Math from './math';\nexport const a = css({ w: Math.max(1, 2) });" +)] +#[case("import '@devup-ui/react';\nconst a = 1;")] +fn imports_that_run_or_bind_what_is_not_data_make_it_a_stylesheet_to_run(#[case] code: &str) { + assert_eq!(plan_of(code), Plan::Run, "{code}"); +} + +#[rstest] +#[case( + "function f(css) { return css({ color: 'red' }); }\nexport const a = css({ color: 'blue' });" +)] +#[case("export const f = (css) => css({ color: 'red' });")] +#[case("{ const css = (rules) => rules; css({ color: 'red' }); }")] +#[case("const rules = css;\nexport const a = rules({ color: 'red' });")] +#[case("export const a = ((css) => css({ color: 'red' }))((rules) => rules);")] +#[case("const Math = { max: 5 };\nexport const a = css({ w: Math.max(1, 2) });")] +#[case("const css2 = { w: 1 };\nexport const a = css2({ color: 'red' });")] +#[case("const styled2 = 1;\nexport const a = styled2.div({ color: 'red' });")] +#[case( + "import * as ui from '@devup-ui/react';\nexport const a = ((ui) => ui.css({ color: 'red' }))({ css: 1 });" +)] +#[case("import * as ui from '@devup-ui/react';\nexport const a = ui.missing({ color: 'red' });")] +#[case("import * as ui from '@devup-ui/react';\nexport const a = ui.css;")] +fn a_local_named_like_a_style_api_or_math_is_not_one(#[case] code: &str) { + assert_eq!(plan_with_head(code), Plan::Run, "{code}"); +} + +#[test] +fn a_style_api_the_package_does_not_give_is_not_compiled() { + assert_eq!( + plan_of( + "import { css, Box } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" + ), + Plan::Run + ); + assert_eq!( + plan_of( + "import { css as rules } from '@devup-ui/react';\nexport const a = rules({ color: 'red' });" + ), + Plan::Plain + ); + assert_eq!( + plan_of( + "import { css } from '@devup-ui/react';\nconst rules = { w: css };\nexport const a = css(rules);" + ), + Plan::Run + ); + assert_eq!( + plan_of( + "import { css } from '@devup-ui/react';\nexport const a = css.global({ color: 'red' });" + ), + Plan::Run + ); +} + +#[test] +fn a_file_that_is_not_javascript_is_a_stylesheet_to_run() { + assert_eq!( + plan( + "export const a = 1;", + "a.css", + &ExtractOption::default(), + None, + &|_| false + ), + Plan::Run + ); +} + +#[test] +fn the_fixtures_that_pin_the_fallback_keep_their_plans() { + assert_eq!( + plan_of( + "import type { DevupProps } from '@devup-ui/react';\nimport { css } from '@devup-ui/react';\nexport const a = css({ color: 'red' });" + ), + Plan::Plain + ); + assert_eq!( + plan_of( + "import { css } from '@devup-ui/react';\nthrow new Error('fixture boom');\nexport const red = css({ color: 'red' });" + ), + Plan::Run + ); +} + +#[rstest] +#[case("null")] +#[case("true")] +#[case("Math.PI")] +fn template_proof_checks_later_interpolations_when_early_value_is_not_exact(#[case] first: &str) { + let code = format!( + "const unused = `${{{first}}}${{null.color}}`; export const a = css({{ color: 'red' }});" + ); + assert_eq!(plan_with_head(&code), Plan::Run); +} diff --git a/libs/extractor/src/stylesheet_policy_values.rs b/libs/extractor/src/stylesheet_policy_values.rs new file mode 100644 index 000000000..0c4743efe --- /dev/null +++ b/libs/extractor/src/stylesheet_policy_values.rs @@ -0,0 +1,258 @@ +//! Nonthrowing value shapes used by the stylesheet dispatch proof. + +use oxc_ast::ast::{ArrayExpressionElement, Expression, ObjectPropertyKind, PropertyKind}; +use oxc_syntax::operator::{BinaryOperator, UnaryOperator}; +use rustc_hash::FxHashMap; + +use super::{MATH_CONSTANTS, Proof}; +use crate::utils::unwrap_syntax_only; + +#[derive(Clone, PartialEq)] +pub(super) enum Value { + Scalar, + Text(String), + Number(f64), + Nullish, + Object(FxHashMap), + Array(Vec), + /// The expression is nonthrowing but its resulting shape is not known. + Unknown, +} + +impl Value { + const fn primitive(&self) -> bool { + matches!( + self, + Self::Scalar | Self::Text(_) | Self::Number(_) | Self::Nullish + ) + } + + pub(super) const fn iterable(&self) -> bool { + matches!(self, Self::Array(_) | Self::Text(_)) + } + + fn key(&self) -> Option { + match self { + Self::Text(text) => Some(text.clone()), + Self::Number(number) => Some(crate::utils::js_number_string(*number)), + _ => None, + } + } + + fn member(self, key: &str) -> Option { + match self { + Self::Object(object) => match object.get(key) { + Some(value) => Some(value.clone()), + None if key == "constructor" + || key == "__proto__" + || key == "toString" + || key == "valueOf" => + { + None + } + None => Some(Self::Nullish), + }, + Self::Array(array) => { + if key == "length" { + return Some(Self::Scalar); + } + let index = key.parse::().ok()?; + Some(array.get(index).cloned().unwrap_or(Self::Nullish)) + } + Self::Text(text) => { + if key == "length" { + return Some(Self::Scalar); + } + let index = key.parse::().ok()?; + Some( + text.encode_utf16() + .nth(index) + .map_or(Self::Nullish, |unit| { + char::from_u32(u32::from(unit)) + .map_or(Self::Scalar, |c| Self::Text(c.to_string())) + }), + ) + } + Self::Scalar | Self::Number(_) | Self::Nullish | Self::Unknown => None, + } + } +} + +impl Proof<'_> { + pub(super) fn value(&self, expression: &Expression<'_>) -> Option { + let expression = unwrap_syntax_only(expression); + match expression { + Expression::BooleanLiteral(_) => Some(Value::Scalar), + Expression::NullLiteral(_) => Some(Value::Nullish), + Expression::NumericLiteral(number) => Some(Value::Number(number.value)), + Expression::StringLiteral(text) => Some(Value::Text(text.value.to_string())), + Expression::Identifier(identifier) => match self.symbol(identifier) { + Some(symbol) => self.values.get(&symbol).cloned(), + None => match identifier.name.as_str() { + "undefined" => Some(Value::Nullish), + "NaN" | "Infinity" => Some(Value::Scalar), + _ => None, + }, + }, + Expression::ArrayExpression(array) => { + let mut values = Vec::new(); + for element in &array.elements { + match element { + ArrayExpressionElement::Elision(_) => values.push(Value::Nullish), + ArrayExpressionElement::SpreadElement(spread) => { + match self.value(&spread.argument)? { + Value::Array(items) => values.extend(items), + Value::Text(text) => { + values.extend(text.chars().map(|c| Value::Text(c.to_string()))); + } + _ => return None, + } + } + element => values.push(self.value(element.to_expression())?), + } + } + Some(Value::Array(values)) + } + Expression::ObjectExpression(object) => { + let mut values = FxHashMap::default(); + for property in &object.properties { + match property { + ObjectPropertyKind::SpreadProperty(spread) => match self + .value(&spread.argument)? + { + Value::Object(properties) => values.extend(properties), + Value::Array(items) => values.extend( + items + .into_iter() + .enumerate() + .map(|(index, value)| (index.to_string(), value)), + ), + Value::Text(text) => values.extend( + text.encode_utf16().enumerate().map(|(index, unit)| { + ( + index.to_string(), + char::from_u32(u32::from(unit)) + .map_or(Value::Scalar, |c| Value::Text(c.to_string())), + ) + }), + ), + Value::Nullish | Value::Scalar | Value::Number(_) => {} + Value::Unknown => return None, + }, + ObjectPropertyKind::ObjectProperty(property) => { + if property.method || property.kind != PropertyKind::Init { + return None; + } + let key = if property.computed { + self.value(property.key.as_expression()?)?.key()? + } else { + property.key.static_name()?.to_string() + }; + if key == "__proto__" { + return None; + } + values.insert(key, self.value(&property.value)?); + } + } + } + Some(Value::Object(values)) + } + Expression::StaticMemberExpression(member) => { + if self.is_global(&member.object, "Math") + && MATH_CONSTANTS.contains(&member.property.name.as_str()) + { + return Some(Value::Scalar); + } + self.value(&member.object)? + .member(member.property.name.as_str()) + } + Expression::ComputedMemberExpression(member) => self + .value(&member.object)? + .member(&self.value(&member.expression)?.key()?), + Expression::TemplateLiteral(template) => { + let mut text = String::new(); + let mut exact = true; + for (index, quasi) in template.quasis.iter().enumerate() { + text.push_str(quasi.value.cooked.as_ref()?.as_str()); + if let Some(expression) = template.expressions.get(index) { + let value = self.value(expression)?; + if !value.primitive() { + return None; + } + match value.key() { + Some(key) => text.push_str(&key), + None => exact = false, + } + } + } + Some(if exact { + Value::Text(text) + } else { + Value::Scalar + }) + } + Expression::UnaryExpression(unary) => { + if unary.operator == UnaryOperator::Delete + || !self.value(&unary.argument)?.primitive() + { + return None; + } + Some(Value::Scalar) + } + Expression::BinaryExpression(binary) => { + if matches!( + binary.operator, + BinaryOperator::Exponential | BinaryOperator::In | BinaryOperator::Instanceof + ) || !self.value(&binary.left)?.primitive() + || !self.value(&binary.right)?.primitive() + { + return None; + } + Some(Value::Scalar) + } + Expression::ConditionalExpression(conditional) => { + self.value(&conditional.test)?; + let left = self.value(&conditional.consequent)?; + let right = self.value(&conditional.alternate)?; + Some(if left == right { left } else { Value::Unknown }) + } + Expression::LogicalExpression(logical) => { + let left = self.value(&logical.left)?; + let right = self.value(&logical.right)?; + Some(if left == right { left } else { Value::Unknown }) + } + Expression::SequenceExpression(sequence) => { + let mut value = Value::Nullish; + for expression in &sequence.expressions { + value = self.value(expression)?; + } + Some(value) + } + Expression::CallExpression(call) if self.folds(call) => { + for argument in &call.arguments { + if argument.is_spread() || !self.value(argument.to_expression())?.primitive() { + return None; + } + } + Some(Value::Scalar) + } + _ if self.creation(expression) => Some(Value::Scalar), + _ => None, + } + } +} + +pub(super) fn literal(code: &str) -> Option { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::default()) + .parse_expression() + .ok()?; + let scoping = oxc_semantic::Scoping::default(); + let proof = Proof { + scoping: &scoping, + apis: FxHashMap::default(), + namespaces: rustc_hash::FxHashSet::default(), + values: FxHashMap::default(), + }; + proof.value(&parsed) +} diff --git a/libs/extractor/src/stylesheet_regression_tests.rs b/libs/extractor/src/stylesheet_regression_tests.rs new file mode 100644 index 000000000..5b0c1a972 --- /dev/null +++ b/libs/extractor/src/stylesheet_regression_tests.rs @@ -0,0 +1,96 @@ +use crate::{ExtractOption, extract_without_source_map, vanilla_extract::execute_stylesheet}; +use serial_test::serial; + +#[rstest::rstest] +#[case("style({ get width() { throw new Error('style getter boom'); } })")] +#[case("keyframes({ from: { get opacity() { throw new Error('style getter boom'); } } })")] +#[case("fontFace({ get src() { throw new Error('style getter boom'); } })")] +#[serial] +fn serialization_errors_are_not_replaced_with_empty_styles(#[case] call: &str) { + let source = format!( + "import {{ style, keyframes, fontFace }} from '@devup-ui/react';\nexport const card = {call};" + ); + + let error = execute_stylesheet( + &source, + "/src/getter.css.ts", + &ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + + assert!(error.starts_with("/src/getter.css.ts:2:"), "{error}"); + assert!(error.contains("style getter boom"), "{error}"); +} + +#[test] +#[serial] +fn style_names_cannot_overwrite_pending_placeholder_entries() -> Result<(), String> { + let source = "import { style } from '@devup-ui/react';\nexport const __style_1__ = style({ color: 'red' });\nexport const second = style({ color: 'blue' });"; + + let (collected, _) = execute_stylesheet( + source, + "/src/placeholders.css.ts", + &ExtractOption::default(), + None, + )?; + + assert_eq!(collected.styles.len(), 2); + assert_eq!(collected.styles["__style_1__"].json, "{\"color\":\"red\"}"); + assert_eq!(collected.styles["second"].json, "{\"color\":\"blue\"}"); + Ok(()) +} + +#[rstest::rstest] +#[case("null.color")] +#[case("[...null]")] +#[case("({}).missing.color")] +#[serial] +fn static_dispatch_cannot_ignore_throwing_non_style_initializers(#[case] expression: &str) { + let source = format!( + "import {{ css }} from '@devup-ui/react';\nconst unused = {expression};\nexport const red = css({{ color: 'red' }});" + ); + + let error = extract_without_source_map( + "/src/throwing-data.css.ts", + &source, + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/throwing-data.css.ts:2:"), "{error}"); + assert!(error.contains("TypeError"), "{error}"); +} + +#[rstest::rstest] +#[case("Boolean(Intl)", "Intl")] +#[case("typeof Intl === 'object'", "Intl")] +#[case("performance.memory", "performance")] +#[serial] +fn environment_reads_cannot_be_replaced_with_synthetic_build_values( + #[case] expression: &str, + #[case] global: &str, +) { + let source = format!( + "import {{ style }} from '@devup-ui/react';\nexport const card = style({{ width: ({expression}) ? '10px' : '20px' }});" + ); + + let error = extract_without_source_map( + "/src/environment-read.css.ts", + &source, + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!( + error.starts_with("/src/environment-read.css.ts:2:"), + "{error}" + ); + assert!(error.contains(global), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 5c80e370a..231cc6b38 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -3,7 +3,11 @@ //! This module uses `boa_engine` to execute vanilla-extract style files //! and extract style definitions for processing by the existing extract logic. -use crate::module_loader::{Evaluating, ModuleLoader, PACKAGE_BINDING, module_script}; +use crate::import_alias_visit::Edit; +use crate::module_loader::{ + Evaluating, ModuleLoader, PACKAGE_BINDING, SCRIPT_PATH, Script, Unit, module_script, +}; +use crate::source_map; use crate::utils::keeps_bare_number; use boa_engine::{ Context, JsArgs, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string, @@ -12,7 +16,7 @@ use boa_engine::{ }; use css::file_map::get_file_num_by_filename; use oxc_allocator::Allocator; -use oxc_codegen::Codegen; +use oxc_codegen::{Codegen, CodegenOptions}; use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; use oxc_span::{GetSpan, SourceType}; @@ -21,9 +25,13 @@ use rustc_hash::{FxHashMap, FxHashSet}; use smallvec::SmallVec; use std::cell::RefCell; use std::collections::BTreeSet; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::rc::Rc; +#[path = "module_loader/serialization.rs"] +mod serialization; +use serialization::value_to_code; + /// A `style()` or `keyframes()` call #[derive(Debug, Clone, Default)] pub struct StyleEntry { @@ -161,20 +169,25 @@ pub(crate) fn json_string(text: &str) -> String { } /// `JSON.stringify(value, replacer)`, `None` when it produces no string -fn stringify(value: &JsValue, replacer: JsValue, context: &mut Context) -> Option { +fn stringify( + value: &JsValue, + replacer: JsValue, + context: &mut Context, +) -> JsResult> { let json = context.intrinsics().objects().json(); - let stringify = json.get(js_string!("stringify"), context).ok()?; - stringify - .as_callable()? - .call(&JsValue::undefined(), &[value.clone(), replacer], context) - .ok()? + let stringify = json.get(js_string!("stringify"), context)?; + let callable = stringify.as_callable().ok_or_else(|| { + boa_engine::JsNativeError::typ().with_message("JSON.stringify is not callable") + })?; + Ok(callable + .call(&JsValue::undefined(), &[value.clone(), replacer], context)? .as_string() - .map(|s| s.to_std_string_escaped()) + .map(|s| s.to_std_string_escaped())) } /// Serialize `value` as it is -fn js_value_to_json(value: &JsValue, context: &mut Context) -> String { - stringify(value, JsValue::undefined(), context).unwrap_or_else(|| "{}".to_string()) +fn js_value_to_json(value: &JsValue, context: &mut Context) -> JsResult { + Ok(stringify(value, JsValue::undefined(), context)?.unwrap_or_else(|| "{}".to_string())) } /// `JSON.stringify` replacer adding `px` to numbers the way vanilla-extract does, @@ -202,11 +215,11 @@ fn pixelify(_this: &JsValue, args: &[JsValue], context: &mut Context) -> JsResul } /// Serialize a style rule object with vanilla-extract's number units. -fn style_to_json(value: &JsValue, context: &mut Context) -> String { +fn style_to_json(value: &JsValue, context: &mut Context) -> JsResult { let realm = context.realm().clone(); let replacer = FunctionObjectBuilder::new(&realm, NativeFunction::from_fn_ptr(pixelify)).build(); - stringify(value, replacer.into(), context).unwrap_or_else(|| "{}".to_string()) + Ok(stringify(value, replacer.into(), context)?.unwrap_or_else(|| "{}".to_string())) } /// What a stylesheet read besides its own source @@ -231,24 +244,54 @@ fn execute_vanilla_extract( execute_stylesheet(code, filename, &option, None).map(|(collected, _)| collected) } +/// A stylesheet to evaluate, and how its code came from what its author wrote +#[derive(Clone, Copy)] +pub(crate) struct Stylesheet<'a> { + pub filename: &'a str, + /// The code that is evaluated + pub code: &'a str, + /// The file as written + pub source: &'a str, + /// The layers of edits that made `code` of `source`, last made first + pub edits: &'a [&'a [Edit]], +} + /// Execute vanilla-extract style file and collect styles, loading what it /// imports through `resolver` +#[cfg(test)] pub fn execute_stylesheet( code: &str, filename: &str, option: &crate::ExtractOption, resolver: Option<&crate::ModuleResolver>, ) -> Result<(CollectedStyles, StylesheetImports), String> { + let stylesheet = Stylesheet { + filename, + code, + source: code, + edits: &[], + }; + execute_located(stylesheet, option, resolver) +} + +/// [`execute_stylesheet`] that tells every error where the code as written is +pub(crate) fn execute_located( + stylesheet: Stylesheet<'_>, + option: &crate::ExtractOption, + resolver: Option<&crate::ModuleResolver>, +) -> Result<(CollectedStyles, StylesheetImports), String> { + let Stylesheet { + filename, + code, + source, + edits, + } = stylesheet; let _evaluating = Evaluating::enter(filename); let mut loader = ModuleLoader::new(resolver, option); - let script = module_script( - &strip_typescript(code, filename), - filename, - &mut loader, - true, - )?; + let unit = Unit::written(filename, code, source, edits)?; + let entry = module_script(&unit, &mut loader, true)?; let file_num = get_file_num_by_filename(filename); - let run = format!("{}{}", loader.prelude(), script.body); + let run = loader.script(&entry); let imports = StylesheetImports { dependencies: loader.dependencies, kept_imports: loader.kept_imports, @@ -257,7 +300,9 @@ pub fn execute_stylesheet( if imported && let Some(collected) = IMPORTED_RUNS.with_borrow(|runs| { runs.get(filename) - .filter(|(num, source, text, _)| *num == file_num && source == code && *text == run) + .filter(|(num, cached, text, _)| { + *num == file_num && cached == code && *text == run.text + }) .map(|(.., collected)| collected.clone()) }) { @@ -268,37 +313,101 @@ pub fn execute_stylesheet( ..Collector::default() })); let mut context = Context::default(); - context - .runtime_limits_mut() - .set_loop_iteration_limit(crate::module_loader::LOOP_ITERATION_LIMIT); - register_vanilla_extract_apis(&mut context, &collector)?; - // A script of its own, so the stylesheet's lines keep their numbers - context - .eval(Source::from_bytes(crate::module_loader::CONSOLE)) - .map_err(|e| format!("JS execution error: {e}"))?; - - context - .eval(Source::from_bytes(run.as_bytes())) - .map_err(|e| format!("JS execution error: {e}"))?; + let sandbox = crate::evaluation_sandbox::Sandbox::new(&mut context) + .map_err(|error| run.explain(&error.to_string(), filename))?; + let operations = crate::module_loader::operations::Operations::new(&run.text); + operations + .prepare(&mut context) + .map_err(|error| run.explain(&error.to_string(), filename))?; + let instrumented = crate::evaluation_sandbox::instrument(&operations.code, SCRIPT_PATH); + sandbox + .prepare(&mut context, &instrumented) + .map_err(|error| run.explain(&instrumented.explain(&error.to_string()), filename))?; + register_vanilla_extract_apis(&mut context, &collector) + .map_err(|e| Script::default().explain(&e, filename))?; + sandbox + .run_source( + &mut context, + Source::from_bytes(instrumented.code.as_bytes()).with_path(Path::new(SCRIPT_PATH)), + ) + .map_err(|failure| { + sandbox_error( + failure, + &run, + |error| operations.explain(&instrumented.explain(error), &context), + filename, + ) + })?; let mut collected = std::mem::take(&mut collector.borrow_mut().styles); - name_entries( + let named = name_entries( &mut collected, &top_level_bindings(code), &mut context, file_num, ); + sandbox + .check(&named.as_ref().err().into_iter().collect::>()) + .map_err(|failure| { + sandbox_error( + failure, + &run, + |error| operations.explain(&instrumented.explain(error), &context), + filename, + ) + })?; + named.map_err(|error| { + run.explain( + &operations.explain(&instrumented.explain(&error.to_string()), &context), + filename, + ) + })?; if imported { IMPORTED_RUNS.with_borrow_mut(|runs| { runs.insert( filename.to_string(), - (file_num, code.to_string(), run, collected.clone()), + (file_num, code.to_string(), run.text, collected.clone()), ); }); } Ok((collected, imports)) } +/// Formats sandbox failures through the same original-source trace as execution errors. +fn sandbox_error( + failure: crate::evaluation_sandbox::Failure, + run: &Script, + rebase: impl Fn(&str) -> String, + filename: &str, +) -> String { + match failure { + crate::evaluation_sandbox::Failure::Js(error) => { + run.explain(&rebase(&error.to_string()), filename) + } + crate::evaluation_sandbox::Failure::Forbidden(violations) => violations + .iter() + .map(|violation| { + let recorded = rebase(&violation.error().to_string()); + let frames = recorded.find("\n at ").map_or("", |at| &recorded[at..]); + let immutable = format!( + "ReferenceError: `{}` cannot be read at build time: {}{frames}", + violation.name(), + violation.requirement() + ); + let explained = run.explain(&immutable, filename); + let (cause, repair) = explained.split_once(". Fix: ").unwrap_or((&explained, "")); + let calls = repair.find("\n at ").map_or("", |at| &repair[at..]); + format!( + "{cause}. Fix: use a literal or a CSS variable for `{}`: {}{calls}", + violation.name(), + violation.requirement() + ) + }) + .collect::>() + .join("\n"), + } +} + thread_local! { /// What running each stylesheet other evaluations import last collected, /// with its file number, source and the script run: every module importing @@ -306,7 +415,7 @@ thread_local! { static IMPORTED_RUNS: RefCell> = RefCell::default(); /// The TypeScript each file last had stripped, with its source - static STRIPPED: RefCell> = RefCell::default(); + static STRIPPED: RefCell)>> = RefCell::default(); } /// A name a top-level variable declaration of the stylesheet binds @@ -332,6 +441,9 @@ fn top_level_bindings(code: &str) -> Vec { oxc_ast::ast::Statement::VariableDeclaration(declaration) => (declaration, false), _ => continue, }; + if declaration.declare { + continue; + } for declarator in &declaration.declarations { let init = declarator .init @@ -367,16 +479,15 @@ fn name_entries( bindings: &[Binding], context: &mut Context, file_num: usize, -) { +) -> JsResult<()> { let values: Vec<(&Binding, JsValue)> = bindings .iter() - .filter_map(|binding| { + .map(|binding| { context .eval(Source::from_bytes(binding.name.as_bytes())) - .ok() .map(|value| (binding, value)) }) - .collect(); + .collect::>()?; let mut names: FxHashMap = FxHashMap::default(); for (binding, value) in &values { @@ -416,7 +527,7 @@ fn name_entries( let names_entry = js_str(value).is_some_and(|id| names.get(&id) == Some(&binding.name)); if binding.exported && !names_entry - && let Some(code) = value_to_code(value, context, &names, &mut Vec::new()) + && let Some(code) = value_to_code(value, context, &names, &mut Vec::new())? .or_else(|| binding.init.clone()) { collected @@ -425,8 +536,12 @@ fn name_entries( } } - for (id, name) in &names { - let reference = if let Some(mut entry) = collected.styles.remove(id) { + let mut styles = std::mem::take(&mut collected.styles); + let mut keyframes = std::mem::take(&mut collected.keyframes); + let mut ordered: Vec<_> = names.iter().collect(); + ordered.sort_by_key(|(id, _)| placeholder_index(id)); + for (id, name) in ordered { + let reference = if let Some(mut entry) = styles.remove(id) { for base in &mut entry.bases { if let Some(base_name) = names.get(base.as_str()) { base_name.clone_into(base); @@ -438,13 +553,14 @@ fn name_entries( class_name: format!("f{file_num}_{name}"), } } else { - if let Some(entry) = collected.keyframes.remove(id) { + if let Some(entry) = keyframes.remove(id) { collected.keyframes.insert(name.clone(), entry); } Reference::Keyframes(name.clone()) }; collected.references.insert(id.clone(), reference); } + Ok(()) } fn is_plain_object(object: &JsObject, context: &Context) -> bool { @@ -456,56 +572,6 @@ fn is_plain_object(object: &JsObject, context: &Context) -> bool { }) } -/// JavaScript for a data `value`, placeholders turned into the names of their -/// styles; `None` for functions, class instances, symbols and cycles -fn value_to_code( - value: &JsValue, - context: &mut Context, - names: &FxHashMap, - seen: &mut Vec, -) -> Option { - if let Some(text) = js_str(value) { - return Some(string_code(&text, names)); - } - if value.is_null_or_undefined() || value.is_number() || value.as_boolean().is_some() { - return to_text(value, context).ok(); - } - let object = value.as_object()?; - if object.is_callable() - || seen - .iter() - .any(|visited| JsObject::equals(visited, &object)) - { - return None; - } - seen.push(object.clone()); - let mut parts = Vec::new(); - let code = if let Some(items) = array_items(value, context).ok()? { - for item in &items { - parts.push(value_to_code(item, context, names, seen)?); - } - format!("[{}]", parts.join(", ")) - } else if is_plain_object(&object, context) { - for (key, name) in own_keys(&object, context).ok()? { - let item = object.get(key, context).ok()?; - parts.push(format!( - "{}: {}", - json_string(&name), - value_to_code(&item, context, names, seen)? - )); - } - if parts.is_empty() { - "{}".to_string() - } else { - format!("{{ {} }}", parts.join(", ")) - } - } else { - return None; - }; - seen.pop(); - Some(code) -} - /// A string literal, or a template literal when it interpolates placeholders fn string_code(text: &str, names: &FxHashMap) -> String { if let Some(name) = names.get(text) { @@ -554,10 +620,23 @@ fn replace_placeholders( }) } -/// Convert TypeScript to JavaScript using Oxc Transformer -/// `code` without its TypeScript, stripped once for as long as the file holds -/// it: the modules evaluations import are stripped once, not per evaluation +/// TypeScript turned into JavaScript, and where each token of it came from +pub(crate) struct Stripped { + pub code: String, + /// `(offset in code, offset in the TypeScript)` for every token + pub marks: Vec<(usize, usize)>, +} + +/// `code` without its TypeScript +#[cfg(test)] pub(crate) fn strip_typescript(code: &str, filename: &str) -> String { + strip_typescript_marked(code, filename).code.clone() +} + +/// `code` without its TypeScript, with where its tokens came from, stripped +/// once for as long as the file holds it: the modules evaluations import are +/// stripped once, not per evaluation +pub(crate) fn strip_typescript_marked(code: &str, filename: &str) -> Rc { if let Some(stripped) = STRIPPED.with_borrow(|stripped| { stripped .get(filename) @@ -566,14 +645,15 @@ pub(crate) fn strip_typescript(code: &str, filename: &str) -> String { }) { return stripped; } - let stripped = strip(code, filename); + let stripped = Rc::new(strip(code, filename)); STRIPPED.with_borrow_mut(|entries| { entries.insert(filename.to_string(), (code.to_string(), stripped.clone())); }); stripped } -fn strip(code: &str, filename: &str) -> String { +/// Convert TypeScript to JavaScript using Oxc Transformer +fn strip(code: &str, filename: &str) -> Stripped { let allocator = Allocator::default(); let source_type = SourceType::from_path(filename).unwrap_or_else(|_| SourceType::ts()); let mut program = Parser::new(&allocator, code, source_type).parse().program; @@ -585,7 +665,20 @@ fn strip(code: &str, filename: &str) -> String { let options = TransformOptions::default(); let path = Path::new("input.css.ts"); let _ = Transformer::new(&allocator, path, &options).build_with_scoping(scoping, &mut program); - Codegen::new().build(&program).code + let generated = Codegen::new() + .with_options(CodegenOptions { + source_map_path: Some(PathBuf::from(filename)), + ..CodegenOptions::default() + }) + .build(&program); + let marks = generated + .map + .map(|map| source_map::marks(&map, &generated.code, code)) + .unwrap_or_default(); + Stripped { + code: generated.code, + marks, + } } type Api = fn(&StyleCollector, &[JsValue], &mut Context) -> JsResult; @@ -610,20 +703,8 @@ fn register_vanilla_extract_apis( ("style", api(collector, style), 1), ("globalStyle", api(collector, global_style), 2), ("styleVariants", api(collector, style_variants), 1), - ( - "keyframes", - api(collector, |collector, args, context| { - Ok(keyframes(collector, args, context)) - }), - 1, - ), - ( - "fontFace", - api(collector, |collector, args, context| { - Ok(font_face(collector, args, context)) - }), - 1, - ), + ("keyframes", api(collector, keyframes), 1), + ("fontFace", api(collector, font_face), 1), ("globalFontFace", api(collector, global_font_face), 2), ("createVar", api(collector, create_var), 0), ("fallbackVar", NativeFunction::from_fn_ptr(fallback_var), 2), @@ -709,7 +790,7 @@ fn compose( } } } else if value.is_object() { - let json = style_to_json(value, context); + let json = style_to_json(value, context)?; let inner = inner_json(&json); if !inner.is_empty() { rules.push(inner.to_string()); @@ -724,7 +805,7 @@ fn global_style( context: &mut Context, ) -> JsResult { let selector = to_text(args.get_or_undefined(0), context)?; - let json = style_to_json(args.get_or_undefined(1), context); + let json = style_to_json(args.get_or_undefined(1), context)?; collector .borrow_mut() .styles @@ -760,8 +841,12 @@ fn style_variants( Ok(variants.into()) } -fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { - let json = style_to_json(args.get_or_undefined(0), context); +fn keyframes( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { + let json = style_to_json(args.get_or_undefined(0), context)?; let mut collector = collector.borrow_mut(); let id = collector.placeholder(); collector.styles.keyframes.insert( @@ -771,17 +856,21 @@ fn keyframes(collector: &StyleCollector, args: &[JsValue], context: &mut Context ..StyleEntry::default() }, ); - js_string!(id).into() + Ok(js_string!(id).into()) } /// `fontFace(rule, debugId?)`: a generated family for one rule or a list of them -fn font_face(collector: &StyleCollector, args: &[JsValue], context: &mut Context) -> JsValue { +fn font_face( + collector: &StyleCollector, + args: &[JsValue], + context: &mut Context, +) -> JsResult { let family = collector .borrow_mut() .identifier(js_str(args.get_or_undefined(1)), "font"); - let faces = font_face_rules(&family, args.get_or_undefined(0), context); + let faces = font_face_rules(&family, args.get_or_undefined(0), context)?; collector.borrow_mut().styles.font_faces.extend(faces); - js_string!(family).into() + Ok(js_string!(family).into()) } fn global_font_face( @@ -790,32 +879,32 @@ fn global_font_face( context: &mut Context, ) -> JsResult { let family = to_text(args.get_or_undefined(0), context)?; - let faces = font_face_rules(&family, args.get_or_undefined(1), context); + let faces = font_face_rules(&family, args.get_or_undefined(1), context)?; collector.borrow_mut().styles.font_faces.extend(faces); Ok(JsValue::undefined()) } -fn font_face_rule(family_json: &str, rule: &JsValue, context: &mut Context) -> String { - let json = js_value_to_json(rule, context); - match inner_json(&json) { +fn font_face_rule(family_json: &str, rule: &JsValue, context: &mut Context) -> JsResult { + let json = js_value_to_json(rule, context)?; + Ok(match inner_json(&json) { "" => format!("{{\"fontFamily\":{family_json}}}"), inner => format!("{{{inner},\"fontFamily\":{family_json}}}"), - } + }) } /// `@font-face` rule objects for `rule`, or each rule of a list, naming `family` -fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> Vec { +fn font_face_rules(family: &str, rule: &JsValue, context: &mut Context) -> JsResult> { let family_json = json_string(family); - match array_items(rule, context).ok().flatten() { + Ok(match array_items(rule, context)? { Some(rules) => { let mut faces = Vec::with_capacity(rules.len()); for rule in &rules { - faces.push(font_face_rule(&family_json, rule, context)); + faces.push(font_face_rule(&family_json, rule, context)?); } faces } - None => vec![font_face_rule(&family_json, rule, context)], - } + None => vec![font_face_rule(&family_json, rule, context)?], + }) } /// `createVar(debugId?)` / `createVar(declaration, debugId?)`: a `var()` of a @@ -1470,6 +1559,19 @@ mod tests { ); } + #[test] + fn test_stripped_typescript_keeps_where_its_tokens_came_from() { + let code = "export const a: number = fail();\n"; + let stripped = strip_typescript_marked(code, "strip-marks.ts"); + assert_eq!(stripped.code, "export const a = fail();\n"); + assert!(Rc::ptr_eq( + &stripped, + &strip_typescript_marked(code, "strip-marks.ts") + )); + assert!(stripped.marks.contains(&(17, 25)), "{:?}", stripped.marks); + assert_eq!(strip_typescript(code, "strip-marks.ts"), stripped.code); + } + fn import_error(code: &str, files: &'static [(&'static str, &'static str)]) -> String { reset_file_map(); let resolver = |specifier: &str, _: &str| { @@ -1497,19 +1599,24 @@ mod tests { #[test] #[serial] fn test_stylesheet_import_errors() { - assert_eq!( - execute_stylesheet( - "import { b } from './b'\nexport const x = b", - "/a.css.ts", - &crate::ExtractOption::default(), - None - ) - .err(), - Some("Cannot load './b' without a module resolver".to_string()) + let without_resolver = execute_stylesheet( + "import { b } from './b'\nexport const x = b", + "/a.css.ts", + &crate::ExtractOption::default(), + None, + ) + .err() + .unwrap_or_default(); + assert!( + without_resolver + .starts_with("/a.css.ts:1:19: Cannot load './b' without a module resolver. Fix: "), + "{without_resolver}" ); - assert_eq!( - import_error("import { b } from './missing'\nexport const x = b", &[]), - "Cannot resolve './missing' from '/a.css.ts'" + let unresolved = import_error("import { b } from './missing'\nexport const x = b", &[]); + assert!( + unresolved + .starts_with("/a.css.ts:1:19: Cannot resolve './missing' from '/a.css.ts'. Fix: "), + "{unresolved}" ); let cycle_error = |code, files| { let error = import_error(code, files); @@ -1635,7 +1742,6 @@ export const list = [box, 2] export const nested = { a: { b: [box] } } export const helper = (x) => x export const fns = [() => 1] -export const date = new Date(0) export const cyclic = (() => { const o = {}; o.self = o; return o })() export const bare = Object.create(null) export const { destructured } = { destructured: () => 1 } @@ -1656,7 +1762,6 @@ export const list = [box, 2] export const nested = { "a": { "b": [box] } } export const helper = (x) => x export const fns = [() => 1] -export const date = new Date(0) export const cyclic = (() => { const o = {}; o.self = o; return o })() export const bare = {} export const symbol = Symbol('x')"# @@ -1717,8 +1822,6 @@ export const c = style([b, { padding: 3 }]) const e = style({}) export const f = style([e, a, '__style_99__']) export const g = style(5) -const o = {}; o.self = o -export const h = style(o) export const hover = style({ selectors: { [`${a}:hover &`]: { color: 'blue' } } })" ), r#"import { css } from '@devup-ui/react' @@ -1728,7 +1831,6 @@ export const c = css({"color":"red"}, {"margin":"2px"}, {"padding":"3px"}) + " f const e = css({}) export const f = css({"color":"red"}) + " f0_a" export const g = css({}) -export const h = css({}) export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# ); } @@ -1741,12 +1843,10 @@ export const hover = css({"selectors":{".f0_a:hover &":{"color":"blue"}}})"# "export const body = fontFace({ src: 'local(a)' }, 'Body Font') export const icons = fontFace([{ src: 'local(b)' }, { src: 'local(c)', fontWeight: 700 }]) fontFace({}) -const o = {}; o.self = o -fontFace(o) globalFontFace('Inter', { src: 'local(Inter)' })" ), r#"import { globalCss } from '@devup-ui/react' -globalCss({ fontFaces: [{"src":"local(a)","fontFamily":"Body_Font-0-0"}, {"src":"local(b)","fontFamily":"font-0-1"}, {"src":"local(c)","fontWeight":700,"fontFamily":"font-0-1"}, {"fontFamily":"font-0-2"}, {"fontFamily":"font-0-3"}, {"src":"local(Inter)","fontFamily":"Inter"}] }) +globalCss({ fontFaces: [{"src":"local(a)","fontFamily":"Body_Font-0-0"}, {"src":"local(b)","fontFamily":"font-0-1"}, {"src":"local(c)","fontWeight":700,"fontFamily":"font-0-1"}, {"fontFamily":"font-0-2"}, {"src":"local(Inter)","fontFamily":"Inter"}] }) export const body = "Body_Font-0-0" export const icons = "font-0-1""# ); @@ -1888,10 +1988,13 @@ export const notObject = ["theme-0-9", {}]"# )) .unwrap(); assert_eq!( - style_to_json(&rule, &mut context), + style_to_json(&rule, &mut context).unwrap(), r#"{"fontSize":"16px","top":0,"lineHeight":1.5,"--gap":4,"var(--x)":2,"fallback":[1,2],":hover":{"width":"3px"},"vars":{"--a":1,"--b":2}}"# ); - assert_eq!(style_to_json(&JsValue::undefined(), &mut context), "{}"); + assert_eq!( + style_to_json(&JsValue::undefined(), &mut context).unwrap(), + "{}" + ); } #[test] @@ -1922,3 +2025,6 @@ const fade = keyframes({"from":{"opacity":0}})"# assert_eq!(inner_json("[1]"), ""); } } + +#[cfg(test)] +mod coverage_tests; diff --git a/libs/extractor/src/vanilla_extract/coverage_tests.rs b/libs/extractor/src/vanilla_extract/coverage_tests.rs new file mode 100644 index 000000000..b44950501 --- /dev/null +++ b/libs/extractor/src/vanilla_extract/coverage_tests.rs @@ -0,0 +1,71 @@ +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; +use serial_test::serial; + +use super::{Stylesheet, execute_located, stringify}; +use crate::ExtractOption; + +#[test] +fn stringify_fails_with_a_type_error_when_user_code_replaces_json_stringify() -> Result<(), String> +{ + // Given + let mut context = Context::default(); + context + .eval(Source::from_bytes("JSON.stringify = 7")) + .map_err(|error| error.to_string())?; + // When + let error = stringify(&JsValue::from(1), JsValue::undefined(), &mut context) + .err() + .ok_or("noncallable stringify accepted")?; + // Then + assert_eq!( + error.to_string(), + "TypeError: JSON.stringify is not callable (native)" + ); + Ok(()) +} + +#[rstest] +#[case("Date.now()", "Date")] +#[case("Math.random()", "Math.random")] +#[serial] +fn exported_getters_cannot_hide_forbidden_reads_until_after_stylesheet_execution( + #[case] read: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let code = format!( + "import {{ createVar }} from '@devup-ui/react';\nexport const token = {{ get value() {{ return {read}; }} }};" + ); + // When + let error = execute_located( + Stylesheet { + filename: "getter.css.ts", + code: &code, + source: &code, + edits: &[], + }, + &ExtractOption::default(), + None, + ) + .err() + .ok_or("forbidden export getter accepted")?; + // Then + let expected = crate::locate( + "getter.css.ts", + &code, + code.find(read).ok_or("missing fixture read")?, + ); + assert!( + error.starts_with(&format!( + "{expected}: JS execution error: ReferenceError: `{name}`" + )), + "{error}" + ); + assert!( + error.contains(&format!("at ({expected})")), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/tests/vanilla_extract.rs b/libs/extractor/tests/vanilla_extract.rs index a3c938b86..408d0c9fe 100644 --- a/libs/extractor/tests/vanilla_extract.rs +++ b/libs/extractor/tests/vanilla_extract.rs @@ -29,8 +29,6 @@ fn font_faces_extract_through_the_library() { let output = extract_stylesheet( r"const body = fontFace({ src: 'local(a)' }, 'Body') const icons = fontFace([{ src: 'local(b)' }, {}]) -const cyclic = {}; cyclic.self = cyclic -fontFace(cyclic) globalFontFace('Inter', { src: 'local(Inter)' }) export const text = style({ fontFamily: body, content: icons })", ); @@ -57,7 +55,26 @@ fn an_unreadable_var_declaration_is_reported() { .err() .map(|error| error.to_string()) .unwrap_or_default(); - assert!(error.starts_with("JS execution error"), "{error}"); + assert!( + error.starts_with( + "broken.css.ts:2:27: JS execution error: TypeError: can't convert symbol to string" + ), + "{error}" + ); +} + +#[test] +#[serial] +fn cyclic_font_faces_report_the_serialization_error() { + let source = "import { fontFace } from '@devup-ui/react';\nconst cyclic = {}; cyclic.self = cyclic;\nfontFace(cyclic);"; + let error = extract("cyclic.css.ts", source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + assert!( + error.starts_with("cyclic.css.ts:3:9: JS execution error: TypeError: cyclic object value"), + "{error}" + ); } #[test] From eceb1ce3af1f2cbad426c70e99ab13a2f4c28040 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 03:24:41 +0900 Subject: [PATCH 03/21] fix(wasm): preserve located module resolver failure causes Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bindings/devup-ui-wasm/src/lib.rs | 104 ++++++++--- bindings/devup-ui-wasm/src/resolver_state.rs | 130 +++++++++++++ .../devup-ui-wasm/src/resolver_state_tests.rs | 174 ++++++++++++++++++ 3 files changed, 378 insertions(+), 30 deletions(-) create mode 100644 bindings/devup-ui-wasm/src/resolver_state.rs create mode 100644 bindings/devup-ui-wasm/src/resolver_state_tests.rs diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index cd6e1a31b..26c6b4fc1 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -14,6 +14,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Display; use std::sync::{LazyLock, Mutex}; use wasm_bindgen::prelude::*; +mod resolver_state; +use resolver_state::ResolverState; static GLOBAL_STYLE_SHEET: LazyLock> = LazyLock::new(|| Mutex::new(StyleSheet::default())); @@ -357,6 +359,7 @@ pub fn code_extract_internal( import_aliases, SourceMapMode::Generate, None, + None, ) } @@ -382,6 +385,7 @@ pub fn code_extract_without_source_map_internal( import_aliases, SourceMapMode::Skip, None, + None, ) } @@ -410,6 +414,7 @@ pub fn code_extract_with_modules_internal( import_aliases, SourceMapMode::Generate, Some(resolver), + None, ) } @@ -425,6 +430,7 @@ fn code_extract_internal_impl( import_aliases: HashMap, source_map: SourceMapMode, resolver: Option<&ModuleResolver>, + resolver_state: Option<&RefCell>, ) -> Result { let option = ExtractOption { package: package.to_string(), @@ -445,6 +451,9 @@ fn code_extract_internal_impl( (None, SourceMapMode::Skip) => extract_without_source_map(filename, code, option), }; + if let Some(state) = resolver_state { + state.borrow().check()?; + } match extracted { Ok(output) => Ok(Output::new( output.code, @@ -473,29 +482,69 @@ pub fn set_module_resolver(resolver: Option) { #[cfg(not(tarpaulin_include))] fn call_module_resolver( resolver: &js_sys::Function, - specifier: &str, - importer: &str, + request: resolver_state::ResolverRequest, + state: &RefCell, ) -> Option { - let module = resolver - .call2(&JsValue::NULL, &specifier.into(), &importer.into()) - .ok()?; - let field = |name: &str| { - js_sys::Reflect::get(&module, &name.into()) - .ok()? - .as_string() + let specifier = request.specifier(); + let importer = request.importer(); + let message = |error: JsValue| { + if let Some(message) = error.as_string() { + return message; + } + match js_sys::Reflect::get(&error, &"message".into()) { + Ok(value) => value.as_string().unwrap_or_else(|| format!("{error:?}")), + Err(_) => format!("{error:?}"), + } }; - Some(ResolvedModule { - path: field("path")?, - code: field("code")?, - }) + let resolved = (|| -> Result, String> { + let module = resolver + .call2(&JsValue::NULL, &specifier.into(), &importer.into()) + .map_err(message)?; + if module.is_null() || module.is_undefined() { + return Ok(None); + } + if !module.is_object() || js_sys::Array::is_array(&module) { + return Err("malformed module resolver result: expected an object {path:string,code:string} or null/undefined".to_string()); + } + let field = |name: &str| -> Result { + js_sys::Reflect::get(&module, &name.into()) + .map_err(message)? + .as_string() + .ok_or_else(|| { + format!("malformed module resolver result: `{name}` must be a string") + }) + }; + let path = field("path")?; + if path.is_empty() { + return Err( + "malformed module resolver result: `path` must be a non-empty string".to_string(), + ); + } + Ok(Some(ResolvedModule { + path, + code: field("code")?, + })) + })(); + match resolved { + Ok(Some(module)) => { + state.borrow_mut().cache(&module, &request); + Some(module) + } + Ok(None) => None, + Err(cause) => { + state.borrow_mut().record(&request, &cause); + None + } + } } /// The resolver set by `setModuleResolver`, if any #[cfg(not(tarpaulin_include))] -fn resolver_from_js() -> Option> { +fn resolver_from_js(state: std::rc::Rc>) -> Option> { let resolver = MODULE_RESOLVER.with_borrow(Clone::clone)?; Some(Box::new(move |specifier: &str, importer: &str| { - call_module_resolver(&resolver, specifier, importer) + let request = state.borrow().request(specifier, importer)?; + call_module_resolver(&resolver, request, &state) })) } @@ -514,9 +563,8 @@ fn code_extract_js( source_map: SourceMapMode, ) -> Result { let import_aliases = import_aliases_from_js(import_aliases)?; - let resolver = MODULE_RESOLVER.with_borrow(Clone::clone).map(|resolver| { - move |specifier: &str, importer: &str| call_module_resolver(&resolver, specifier, importer) - }); + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); code_extract_internal_impl( filename, code, @@ -527,9 +575,8 @@ fn code_extract_js( import_main_css_in_css, import_aliases, source_map, - resolver - .as_ref() - .map(|resolver| resolver as &ModuleResolver), + resolver.as_deref(), + Some(&state), ) .map_err(js_error) } @@ -678,21 +725,18 @@ pub fn get_theme_interface( #[wasm_bindgen(js_name = "hasDevupUI")] #[cfg(not(tarpaulin_include))] -#[must_use] pub fn has_devup_ui_wasm( filename: &str, code: &str, package: &str, import_aliases: JsValue, -) -> bool { +) -> Result { let aliases = import_aliases_from_js(import_aliases).unwrap_or_default(); - has_devup_ui_with( - filename, - code, - package, - &aliases, - resolver_from_js().as_deref(), - ) + let state = std::rc::Rc::new(RefCell::new(ResolverState::new(filename, code))); + let resolver = resolver_from_js(std::rc::Rc::clone(&state)); + let result = has_devup_ui_with(filename, code, package, &aliases, resolver.as_deref()); + state.borrow().check().map_err(js_error)?; + Ok(result) } #[cfg(test)] diff --git a/bindings/devup-ui-wasm/src/resolver_state.rs b/bindings/devup-ui-wasm/src/resolver_state.rs new file mode 100644 index 000000000..d7c318b08 --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state.rs @@ -0,0 +1,130 @@ +use std::collections::{BTreeSet, HashMap}; + +use extractor::ResolvedModule; +use extractor::module_reference::{ModuleReferenceLocation, original_module_reference_locations}; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +struct OriginSite { + filename: String, + location: ModuleReferenceLocation, +} + +#[derive(Clone)] +struct OriginSites { + first: OriginSite, + rest: Vec, +} + +struct ModuleSource { + code: String, + ingress: Option, +} + +/// A callback request with at least one original reference site. +pub(crate) struct ResolverRequest { + specifier: String, + importer: String, + origins: OriginSites, +} + +impl ResolverRequest { + pub(crate) fn specifier(&self) -> &str { + &self.specifier + } + + pub(crate) fn importer(&self) -> &str { + &self.importer + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +struct ResolverFailure { + site: OriginSite, + specifier: String, + importer: String, + cause: String, +} + +/// Resolver diagnostics and original module sources owned by one extraction. +pub(crate) struct ResolverState { + sources: HashMap, + failures: BTreeSet, +} + +impl ResolverState { + pub(crate) fn new(filename: &str, source: &str) -> Self { + Self { + sources: HashMap::from([( + filename.to_string(), + ModuleSource { + code: source.to_string(), + ingress: None, + }, + )]), + failures: BTreeSet::new(), + } + } + + pub(crate) fn cache(&mut self, module: &ResolvedModule, request: &ResolverRequest) { + self.sources.insert( + module.path.clone(), + ModuleSource { + code: module.code.clone(), + ingress: Some(request.origins.clone()), + }, + ); + } + + /// Bind original references before calling JS; recursive exploratory reads + /// without a semantic reference retain the real import that loaded the module. + pub(crate) fn request(&self, specifier: &str, importer: &str) -> Option { + let source = self.sources.get(importer)?; + let mut sites = original_module_reference_locations(importer, &source.code, specifier) + .into_iter() + .map(|location| OriginSite { + filename: importer.to_string(), + location, + }); + let origins = match sites.next() { + Some(first) => OriginSites { + first, + rest: sites.collect(), + }, + None => source.ingress.clone()?, + }; + Some(ResolverRequest { + specifier: specifier.to_string(), + importer: importer.to_string(), + origins, + }) + } + + pub(crate) fn record(&mut self, request: &ResolverRequest, cause: &str) { + for site in std::iter::once(&request.origins.first).chain(&request.origins.rest) { + self.failures.insert(ResolverFailure { + site: site.clone(), + specifier: request.specifier.clone(), + importer: request.importer.clone(), + cause: cause.to_string(), + }); + } + } + + pub(crate) fn check(&self) -> Result<(), String> { + if self.failures.is_empty() { + Ok(()) + } else { + Err(self.failures.iter().map(|failure| { + let OriginSite { filename, location } = &failure.site; + format!( + "{filename}:{}:{}: module resolver failed for `{}` from `{}`: {}\nFix: repair the module resolver; return {{path:string,code:string}} with a non-empty path, or null/undefined for an unresolved module.", + location.line, location.column, failure.specifier, failure.importer, failure.cause, + ) + }).collect::>().join("\n")) + } + } +} + +#[cfg(test)] +#[path = "resolver_state_tests.rs"] +mod tests; diff --git a/bindings/devup-ui-wasm/src/resolver_state_tests.rs b/bindings/devup-ui-wasm/src/resolver_state_tests.rs new file mode 100644 index 000000000..317e3bbf6 --- /dev/null +++ b/bindings/devup-ui-wasm/src/resolver_state_tests.rs @@ -0,0 +1,174 @@ +use super::*; +use crate::{SourceMapMode, code_extract_internal_impl, export_sheet_internal}; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn diagnostic_when_resolver_fails_in_cached_imported_typescript() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.tsx", "import './barrel';"); + let ingress = state + .request("./barrel", "entry.tsx") + .ok_or("missing ingress reference")?; + state.cache( + &ResolvedModule { + path: "barrel.ts".to_string(), + code: "type Color = string;\nexport {\n color\n} from './color';".to_string(), + }, + &ingress, + ); + let request = state + .request("./color", "barrel.ts") + .ok_or("missing imported reference")?; + // When + state.record(&request, "permission denied: /original/path"); + // Then + let error = state.check().err().unwrap_or_default(); + assert!(error.starts_with("barrel.ts:4:8:")); + assert!(error.contains("permission denied: /original/path")); + assert!(error.contains("Fix: repair the module resolver")); + Ok(()) +} + +#[test] +fn diagnostic_when_specifier_has_multiple_original_sites() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';\nexport * from './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + // When + state.record(&request, "broken resolver"); + // Then + let error = state.check().err().unwrap_or_default(); + let diagnostics = error + .lines() + .filter(|line| line.starts_with("entry.ts:")) + .collect::>(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics[0].starts_with("entry.ts:1:8: module resolver failed")); + assert!(diagnostics[1].starts_with("entry.ts:2:15: module resolver failed")); + assert!( + diagnostics + .iter() + .all(|line| line.ends_with("broken resolver")) + ); + Ok(()) +} + +#[test] +fn diagnostic_when_repeated_resolution_fails_is_deduplicated() -> Result<(), String> { + // Given + let mut state = ResolverState::new("entry.ts", "import './color';"); + let request = state + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + state.record(&request, "broken resolver"); + let first = state.check(); + // When + state.record(&request, "broken resolver"); + // Then + assert_eq!(state.check(), first); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn publication_when_exploratory_resolution_fails_is_rejected() -> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './color';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + recorded + .borrow_mut() + .record(&request, "exploratory resolver failure"); + None + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23:")); + assert!(error.contains("exploratory resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} + +#[test] +fn clean_extraction_state_when_another_extraction_failed() -> Result<(), String> { + // Given + let mut failed = ResolverState::new("entry.ts", "import './color';"); + let request = failed + .request("./color", "entry.ts") + .ok_or("missing original reference")?; + failed.record(&request, "broken resolver"); + // When + let clean = ResolverState::new("entry.ts", "import './color';"); + // Then + assert_eq!(clean.check(), Ok(())); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn diagnostic_when_imported_constant_exploration_uses_shadowed_require_retains_ingress() +-> Result<(), String> { + // Given + let source = "import { Box } from '@devup-ui/react';\nimport { color } from './helper';\nconst x = ;"; + let state = Rc::new(RefCell::new(ResolverState::new("entry.tsx", source))); + let recorded = Rc::clone(&state); + let resolver = move |specifier: &str, importer: &str| { + let request = recorded.borrow().request(specifier, importer)?; + match (request.importer(), request.specifier()) { + ("entry.tsx", "./helper") => { + let module = ResolvedModule { + path: "helper.ts".to_string(), + code: "const require = () => ({color: 'red'}); const {color} = require('./shadowed'); exports.color = color;".to_string(), + }; + recorded.borrow_mut().cache(&module, &request); + Some(module) + } + ("helper.ts", "./shadowed") => { + recorded + .borrow_mut() + .record(&request, "shadowed resolver failure"); + None + } + other => panic!("unexpected module reference: {other:?}"), + } + }; + let before = export_sheet_internal()?; + // When + let result = code_extract_internal_impl( + "entry.tsx", + source, + "@devup-ui/react", + "df".to_string(), + true, + false, + false, + HashMap::new(), + SourceMapMode::Generate, + Some(&resolver), + Some(&state), + ); + // Then + let error = result.err().unwrap_or_default(); + assert!(error.starts_with("entry.tsx:2:23: module resolver failed for `./shadowed` from `helper.ts`: shadowed resolver failure")); + assert_eq!(export_sheet_internal()?, before); + Ok(()) +} From dca611f61689b282d8955087451af65897db221d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 06:31:29 +0900 Subject: [PATCH 04/21] fix(extractor): prove module effects before exact stylesheet evaluation Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 50 ++++++ libs/extractor/src/stylesheet_policy.rs | 19 +- .../src/stylesheet_policy/coverage_tests.rs | 44 +++++ .../src/stylesheet_policy_dispatch.rs | 167 ++++++++++++++---- .../src/stylesheet_policy_import_tests.rs | 69 +++++++- .../src/stylesheet_policy_regression_tests.rs | 129 ++++++++++++++ libs/extractor/src/stylesheet_policy_tests.rs | 22 ++- .../extractor/src/stylesheet_policy_values.rs | 43 +++-- 8 files changed, 469 insertions(+), 74 deletions(-) create mode 100644 libs/extractor/src/stylesheet_policy_regression_tests.rs diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index d0dc8f5b9..77cb40a59 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -365,6 +365,56 @@ fn extract_source( } let mut dependencies = std::collections::BTreeSet::new(); + if utils::is_vanilla_extract_file(filename) + && !values_run + && stylesheet_policy::imports_plain(&transformed_code, filename, &option, resolver) + && let Some((computed, value_edits, read)) = build_time_values::evaluate_located( + &transformed_code, + filename, + &option, + resolver, + &imported_constants::Unknown::default(), + ) + .map_err(|errors| { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + errors + .into_iter() + .map(|(offset, message)| { + let offset = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .fold(offset, |offset, edits| { + import_alias_visit::source_offset(edits, offset) + }); + format!( + "{}: {message}. Fix: use a literal or a CSS variable", + locate(filename, source, offset) + ) + }) + .collect::>() + .join("\n") + })? + { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let layers: Vec<&[import_alias_visit::Edit]> = + [value_edits.as_slice(), alias_edits.as_slice()] + .into_iter() + .chain(earlier_edits.iter().copied()) + .collect(); + let mut output = extract_source( + filename, + &computed, + Some((source, &layers)), + true, + option, + source_map, + resolver, + )?; + let mut files: std::collections::BTreeSet = + output.dependencies.into_iter().collect(); + files.extend(read); + output.dependencies = files.into_iter().collect(); + return Ok(output); + } // Step 3: Handle vanilla-extract style files (.css.ts, .css.js) // `processed_code` is Some only when vanilla-extract generation succeeded; // otherwise the untouched `transformed_code` is parsed directly (no copy). diff --git a/libs/extractor/src/stylesheet_policy.rs b/libs/extractor/src/stylesheet_policy.rs index 6b504c899..025c17f9b 100644 --- a/libs/extractor/src/stylesheet_policy.rs +++ b/libs/extractor/src/stylesheet_policy.rs @@ -32,7 +32,7 @@ use values::Value; #[path = "stylesheet_policy_dispatch.rs"] mod dispatch; -pub(crate) use dispatch::plan; +pub(crate) use dispatch::{imports_plain, plan}; /// What the build does with a stylesheet #[derive(Debug, PartialEq, Eq)] @@ -155,7 +155,7 @@ impl Proof<'_> { /// build folds. A function, an accessor, a call of anything else or a /// global is not. fn pure(&self, expression: &Expression<'_>) -> bool { - self.value(expression).is_some() + self.value(expression).is_some_and(|value| value.data()) } /// Binds what `import` gives, when it gives only what the build knows: the @@ -231,11 +231,19 @@ impl Proof<'_> { fn declaration(&mut self, declaration: &Declaration<'_>) -> bool { match declaration { Declaration::VariableDeclaration(declaration) => self.constants(declaration), + Declaration::FunctionDeclaration(function) => self.function(function), Declaration::TSTypeAliasDeclaration(_) | Declaration::TSInterfaceDeclaration(_) => true, _ => false, } } + fn function(&mut self, function: &oxc_ast::ast::Function<'_>) -> bool { + if let Some(symbol) = function.id.as_ref().and_then(|id| id.symbol_id.get()) { + self.values.insert(symbol, Value::Function); + } + true + } + fn export(&self, specifier: &ExportSpecifier<'_>) -> bool { specifier.export_kind.is_type() || matches!(&specifier.local, ModuleExportName::IdentifierReference(identifier) @@ -251,6 +259,7 @@ impl Proof<'_> { | Statement::TSInterfaceDeclaration(_) => true, Statement::ExpressionStatement(statement) => self.creation(&statement.expression), Statement::VariableDeclaration(declaration) => self.constants(declaration), + Statement::FunctionDeclaration(function) => self.function(function), Statement::ExportDeclaration(export) => self.declaration(&export.declaration), Statement::ExportNamedDeclaration(export) => { export.export_kind.is_type() @@ -261,8 +270,10 @@ impl Proof<'_> { } Statement::ExportDefaultDeclaration(export) => match &export.declaration { ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) => true, - ExportDefaultDeclarationKind::FunctionDeclaration(_) - | ExportDefaultDeclarationKind::ClassDeclaration(_) => false, + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + self.function(function) + } + ExportDefaultDeclarationKind::ClassDeclaration(_) => false, declaration => self.pure(declaration.to_expression()), }, _ => false, diff --git a/libs/extractor/src/stylesheet_policy/coverage_tests.rs b/libs/extractor/src/stylesheet_policy/coverage_tests.rs index f03ba4dca..85a5a247c 100644 --- a/libs/extractor/src/stylesheet_policy/coverage_tests.rs +++ b/libs/extractor/src/stylesheet_policy/coverage_tests.rs @@ -30,6 +30,9 @@ use crate::ExtractOption; #[case("css({ color: 'red' || 'red' })", Plan::Plain)] #[case("css({ color: (1, 'red') })", Plan::Plain)] #[case("css({ color: Math.max(...[1, 2]) })", Plan::Run)] +#[case("css({ w: (() => 1) + 1 })", Plan::Run)] +#[case("css({ w: (() => 1).prototype })", Plan::Run)] +#[case("css({ w: { callback: () => 1 } })", Plan::Run)] fn dispatch_runs_only_when_plain_data_cannot_prove_nonthrowing_evaluation( #[case] expression: &str, #[case] expected: Plan, @@ -48,3 +51,44 @@ fn dispatch_runs_only_when_plain_data_cannot_prove_nonthrowing_evaluation( // Then assert_eq!(actual, expected, "{expression}"); } + +#[rstest] +#[case("true ? f : 1", Plan::Run)] +#[case("true ? 1 : f", Plan::Run)] +#[case("true && f", Plan::Run)] +#[case("f || 1", Plan::Run)] +#[case("null ?? f", Plan::Run)] +#[case("true ? { nested: [f] } : { nested: [1] }", Plan::Run)] +#[case("true ? [{ callback: f }] : [1]", Plan::Run)] +#[case("true && { nested: [f] }", Plan::Run)] +#[case("[{ callback: f }] || 1", Plan::Run)] +#[case("true ? f : f", Plan::Run)] +#[case("f && f", Plan::Run)] +#[case("true ? { nested: [f] } : { nested: [f] }", Plan::Run)] +#[case("true ? 1 : 2", Plan::Plain)] +#[case("true && 1", Plan::Plain)] +#[case("1 || 2", Plan::Plain)] +#[case("null ?? 2", Plan::Plain)] +#[case("true ? { nested: [1] } : { nested: [2] }", Plan::Plain)] +#[case("true ? [{ width: 1 }] : [2]", Plan::Plain)] +#[case("true && { nested: [1] }", Plan::Plain)] +#[case("[{ width: 1 }] || 2", Plan::Plain)] +fn joined_style_values_require_data_in_every_branch( + #[case] expression: &str, + #[case] expected: Plan, +) { + // Given + let code = format!( + "import {{ css }} from '@devup-ui/react'; const f = () => 1; export const rule = css({{ w: {expression} }});" + ); + // When + let actual = plan( + &code, + "join.css.ts", + &ExtractOption::default(), + None, + &|_| false, + ); + // Then + assert_eq!(actual, expected, "{expression}"); +} diff --git a/libs/extractor/src/stylesheet_policy_dispatch.rs b/libs/extractor/src/stylesheet_policy_dispatch.rs index dd63540c2..f61f3a334 100644 --- a/libs/extractor/src/stylesheet_policy_dispatch.rs +++ b/libs/extractor/src/stylesheet_policy_dispatch.rs @@ -1,13 +1,13 @@ //! Parsing and dispatch of the stylesheet's nonthrowing proof. use oxc_allocator::Allocator; -use oxc_ast::ast::Statement; +use oxc_ast::ast::{Program, Statement}; use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; -use super::{Plan, Proof, Value, values}; +use super::{Plan, Proof, Value}; use crate::imported_constants::ChangeCheck; use crate::{ExtractOption, ModuleResolver}; @@ -19,54 +19,143 @@ pub(crate) fn plan( resolver: Option<&ModuleResolver>, is_static: &dyn Fn(&str) -> bool, ) -> Plan { + Dispatch::new(option, resolver).plan(code, filename, is_static) +} + +/// Proves the imported modules before value computation can inline their exports. +pub(crate) fn imports_plain( + code: &str, + filename: &str, + option: &ExtractOption, + resolver: Option<&ModuleResolver>, +) -> bool { let allocator = Allocator::default(); let Ok(source_type) = SourceType::from_path(filename) else { - return Plan::Run; + return false; }; let parsed = Parser::new(&allocator, code, source_type).parse(); - if parsed.fatal_error || !parsed.diagnostics.is_empty() { - return Plan::Run; + !parsed.fatal_error + && parsed.diagnostics.is_empty() + && Dispatch::new(option, resolver).imports(&parsed.program, filename) +} + +struct Dispatch<'s> { + option: &'s ExtractOption, + resolver: Option<&'s ModuleResolver>, + visiting: FxHashSet, + proven: FxHashMap, +} + +impl<'s> Dispatch<'s> { + fn new(option: &'s ExtractOption, resolver: Option<&'s ModuleResolver>) -> Self { + Self { + option, + resolver, + visiting: FxHashSet::default(), + proven: FxHashMap::default(), + } } - let built = SemanticBuilder::new() - .with_check_syntax_error(true) - .build(&parsed.program); - if !built.diagnostics.is_empty() { - return Plan::Run; + + fn imports(&mut self, program: &Program<'_>, filename: &str) -> bool { + program.body.iter().all(|statement| { + let Statement::ImportDeclaration(import) = statement else { + return true; + }; + let specifier = import.source.value.as_str(); + if import.import_kind.is_type() + || specifier == self.option.package + || specifier == format!("{}/compat", self.option.package) + { + return true; + } + let Some(module) = self + .resolver + .and_then(|resolve| resolve(specifier, filename)) + else { + return false; + }; + if let Some(proven) = self.proven.get(&module.path) { + return *proven; + } + if !self.visiting.insert(module.path.clone()) { + return false; + } + let proven = self.plan(&module.code, &module.path, &|_| false) == Plan::Plain; + self.visiting.remove(&module.path); + self.proven.insert(module.path, proven); + proven + }) } - let scoping = built.semantic.into_scoping(); - let changes = ChangeCheck::new(&parsed.program, filename, option, resolver); - let known = |name: &str| { - if is_static(name) { - return Some(Value::Scalar); + + fn plan(&mut self, code: &str, filename: &str, is_static: &dyn Fn(&str) -> bool) -> Plan { + let allocator = Allocator::default(); + let Ok(source_type) = SourceType::from_path(filename) else { + return Plan::Run; + }; + let parsed = Parser::new(&allocator, code, source_type).parse(); + if parsed.fatal_error || !parsed.diagnostics.is_empty() { + return Plan::Run; } - if changes.is_changed(name) { - return None; + let built = SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&parsed.program); + if !built.diagnostics.is_empty() { + return Plan::Run; } - values::literal(&changes.known(name)?) - }; - let compat = format!("{}/compat", option.package); - let mut proof = Proof { + let scoping = built.semantic.into_scoping(); + if !self.imports(&parsed.program, filename) { + return Plan::Run; + } + let option = self.option; + let changes = ChangeCheck::new(&parsed.program, filename, option, self.resolver); + let known = |name: &str| { + if is_static(name) { + return Some(Value::Scalar); + } + if changes.is_changed(name) { + return None; + } + literal(&changes.known(name)?) + }; + let compat = format!("{}/compat", option.package); + let mut proof = Proof { + scoping: &scoping, + apis: FxHashMap::default(), + namespaces: FxHashSet::default(), + values: FxHashMap::default(), + }; + // Imports are bound before any statement runs. + let imports = parsed.program.body.iter().all(|statement| match statement { + Statement::ImportDeclaration(import) => { + proof.import(import, [option.package.as_str(), compat.as_str()], &known) + } + _ => true, + }); + if imports + && parsed + .program + .body + .iter() + .all(|statement| proof.statement(statement)) + { + Plan::Plain + } else { + Plan::Run + } + } +} + +fn literal(code: &str) -> Option { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::default()) + .parse_expression() + .ok()?; + let scoping = oxc_semantic::Scoping::default(); + let proof = Proof { scoping: &scoping, apis: FxHashMap::default(), namespaces: FxHashSet::default(), values: FxHashMap::default(), }; - // Imports are bound before any statement runs. - let imports = parsed.program.body.iter().all(|statement| match statement { - Statement::ImportDeclaration(import) => { - proof.import(import, [option.package.as_str(), compat.as_str()], &known) - } - _ => true, - }); - if imports - && parsed - .program - .body - .iter() - .all(|statement| proof.statement(statement)) - { - Plan::Plain - } else { - Plan::Run - } + proof.value(&parsed) } diff --git a/libs/extractor/src/stylesheet_policy_import_tests.rs b/libs/extractor/src/stylesheet_policy_import_tests.rs index c580607b3..afe4e078e 100644 --- a/libs/extractor/src/stylesheet_policy_import_tests.rs +++ b/libs/extractor/src/stylesheet_policy_import_tests.rs @@ -3,10 +3,15 @@ use crate::{ExtractOption, ResolvedModule}; #[test] fn an_import_is_data_when_the_build_knows_its_value_whole() { - let resolver = |_: &str, _: &str| { + let resolver = |specifier: &str, _: &str| { Some(ResolvedModule { - path: "/src/tokens.ts".to_string(), - code: "export const RED = 'red';\nexport const LIST = [1, 2];\nexport const mutated = { a: 1 };\nmutated.a = 2;\nexport const computed = Date.now();\nexport function twice(n) { return n * 2; }".to_string(), + path: format!("/src/{}.ts", specifier.trim_start_matches("./")), + code: match specifier { + "./mutated" => "export const mutated = { a: 1 }; mutated.a = 2;", + "./computed" => "export const computed = Date.now();", + _ => "export const RED = 'red';\nexport const LIST = [1, 2];\nexport function twice(n) { return n * 2; }", + } + .to_string(), }) }; let option = ExtractOption::default(); @@ -28,8 +33,8 @@ fn an_import_is_data_when_the_build_knows_its_value_whole() { assert_eq!(plan_of(code, &no), Plan::Plain, "{code}"); } for code in [ - "import { mutated } from './tokens';\nexport const a = css({ ...mutated });", - "import { computed } from './tokens';\nexport const a = css({ w: computed });", + "import { mutated } from './mutated';\nexport const a = css({ ...mutated });", + "import { computed } from './computed';\nexport const a = css({ w: computed });", "import { twice } from './tokens';\nexport const a = css({ w: twice(2) });", "import { missing } from './tokens';\nexport const a = css({ w: missing });", "import * as tokens from './tokens';\nexport const a = css({ w: tokens.RED });", @@ -52,3 +57,57 @@ fn an_import_is_data_when_the_build_knows_its_value_whole() { Plan::Run ); } + +#[rstest::rstest] +#[case("export const RED = 'red'; throw new Error('helper boom');")] +#[case("export const RED = 'red'; try { Date.now(); } catch {}")] +#[case("export const RED = 'red'; unknownRuntime();")] +fn known_exports_and_static_callbacks_cannot_prove_module_effects(#[case] helper: &str) { + // Given + let helper = helper.to_string(); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }) + }; + let source = "import { css } from '@devup-ui/react'; import { RED } from './helper'; export const card = css({ color: RED });"; + // When + let actual = plan( + source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + &|_| true, + ); + // Then + assert_eq!(actual, Plan::Run); +} + +#[test] +fn a_transitive_effect_keeps_literal_imports_on_the_evaluated_path() { + // Given + let resolver = |specifier: &str, _: &str| { + Some(match specifier { + "./helper" => ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import { color } from './effect'; export const RED = color;".to_string(), + }, + _ => ResolvedModule { + path: "/src/effect.ts".to_string(), + code: "export const color = 'red'; throw new Error('nested boom');".to_string(), + }, + }) + }; + let source = "import { css } from '@devup-ui/react'; import { RED } from './helper'; export const card = css({ color: RED });"; + // When + let actual = plan( + source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + &|_| false, + ); + // Then + assert_eq!(actual, Plan::Run); +} diff --git a/libs/extractor/src/stylesheet_policy_regression_tests.rs b/libs/extractor/src/stylesheet_policy_regression_tests.rs new file mode 100644 index 000000000..c8c65f10e --- /dev/null +++ b/libs/extractor/src/stylesheet_policy_regression_tests.rs @@ -0,0 +1,129 @@ +use rstest::rstest; +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule, extract, extract_with_modules}; + +#[rstest] +#[case("throw new Error('helper boom');", "helper boom")] +#[case("try { Date.now(); } catch {}", "Date")] +#[case("const effect = unknownRuntime();", "unknownRuntime")] +#[serial] +fn imported_module_effects_are_fatal_when_export_is_literal( + #[case] effect: &str, + #[case] message: &str, +) { + // Given + let helper = format!("export const color = 'red';\n{effect}"); + let resolver = move |_: &str, _: &str| { + Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }) + }; + let source = "import { css } from '@devup-ui/react';\nimport { color } from './helper';\nexport const card = css({ color });"; + // When + let Err(error) = extract_with_modules( + "/src/card.css.ts", + source, + ExtractOption::default(), + false, + &resolver, + ) else { + panic!("helper effects must fail"); + }; + let error = error.to_string(); + // Then + assert!(error.contains("/src/helper.ts:2:"), "{error}"); + assert!(error.contains(message), "{error}"); + assert!(!error.contains("not a callable"), "{error}"); +} + +#[rstest] +#[case("function unused() {}", "{ color: 'red' }", "color", "red")] +#[case( + "function unused() { return Date.now(); }", + "{ color: 'red' }", + "color", + "red" +)] +#[case("const unused = () => Date.now();", "{ color: 'red' }", "color", "red")] +#[case("const twice = (n) => n * 2;", "{ w: twice(3) }", "width", "24px")] +#[serial] +fn deterministic_css_stylesheets_match_regular_extraction( + #[case] declaration: &str, + #[case] rules: &str, + #[case] property: &str, + #[case] value: &str, +) -> Result<(), Box> { + // Given + let source = format!( + "import {{ css }} from '@devup-ui/react'; {declaration} export const card = css({rules});" + ); + // When + let output = extract("/src/card.css.ts", &source, ExtractOption::default())?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + crate::ExtractStyleValue::Static(style) if style.property == property && style.value == value + )), "{:?}", output.styles); + Ok(()) +} + +#[rstest] +#[case("css", "import { css } from '@devup-ui/react';")] +#[case("ui.css", "import * as ui from '@devup-ui/react';")] +#[serial] +fn pure_imported_literals_compile_when_module_is_nonthrowing( + #[case] api: &str, + #[case] import: &str, +) -> Result<(), Box> { + // Given + let resolver = |specifier: &str, _: &str| { + (specifier == "./helper").then(|| ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "export const color = 'red'; function unused() { return Date.now(); }" + .to_string(), + }) + }; + let source = format!( + "{import} import {{ color }} from './helper'; export const card = {api}({{ color }});" + ); + // When + let output = extract_with_modules( + "/src/card.css.ts", + &source, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + crate::ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + )), "{:?}", output.styles); + assert_eq!(output.dependencies, vec!["/src/helper.ts"]); + Ok(()) +} + +#[rstest] +#[case("function forbidden() { return Date.now(); }", "forbidden()")] +#[case( + "const forbidden = () => { try { return Date.now(); } catch { return 1; } };", + "forbidden()" +)] +#[serial] +fn called_forbidden_functions_fail_when_their_declarations_are_inert( + #[case] declaration: &str, + #[case] call: &str, +) { + // Given + let source = format!( + "import {{ css }} from '@devup-ui/react'; {declaration} export const card = css({{ w: {call} }});" + ); + // When + let Err(error) = extract("/src/card.css.ts", &source, ExtractOption::default()) else { + panic!("clock read must fail"); + }; + let error = error.to_string(); + // Then + assert!(error.contains("/src/card.css.ts:1:"), "{error}"); + assert!(error.contains("Date"), "{error}"); +} diff --git a/libs/extractor/src/stylesheet_policy_tests.rs b/libs/extractor/src/stylesheet_policy_tests.rs index c33ed13be..24d6dcaff 100644 --- a/libs/extractor/src/stylesheet_policy_tests.rs +++ b/libs/extractor/src/stylesheet_policy_tests.rs @@ -6,6 +6,9 @@ use crate::ExtractOption; #[path = "stylesheet_policy_import_tests.rs"] mod imports; +#[path = "stylesheet_policy_regression_tests.rs"] +mod regressions; + const HEAD: &str = "import { css, globalCss, keyframes, styled } from '@devup-ui/react';\n"; fn plan_of(code: &str) -> Plan { @@ -63,6 +66,9 @@ fn a_stylesheet_of_devup_styles_alone_is_extracted_as_written(#[case] code: &str "export const Card = styled('div', { bg: 'white' });\nexport const Link = styled.a.attrs({ href: '#' })({ color: 'red' });\nexport const Big = styled(Card)({ p: 8 });" )] #[case("const rules = { color: 'red' };\nexport const a = css`color: ${rules.color};`;")] +#[case("export function f() {}\nexport const a = css({ w: 1 });")] +#[case("function f() {}\nexport const a = css({ w: 1 });")] +#[case("export default function () {}")] #[case( "type Tokens = { a: string };\nconst tokens: Tokens = { a: 'red' } as const;\nexport const a = css({ color: tokens.a as string, w: Math.round(2.5)! });" )] @@ -107,10 +113,7 @@ fn constants_references_and_spreads_the_visitor_reads_stay_plain(#[case] code: & #[case("export const [a, b] = [1, 2];")] #[case("export const { a } = { a: 1 };")] #[case("export const a;")] -#[case("export function f() {}\nexport const a = css({ w: 1 });")] -#[case("function f() {}\nexport const a = css({ w: 1 });")] #[case("export class A {}")] -#[case("export default function () {}")] #[case("enum Size { Small = 1 }\nexport const a = css({ w: Size.Small });")] #[case("export const a =
;")] #[case("export * from './other';")] @@ -165,10 +168,6 @@ fn imports_that_run_or_bind_what_is_not_data_make_it_a_stylesheet_to_run(#[case] } #[rstest] -#[case( - "function f(css) { return css({ color: 'red' }); }\nexport const a = css({ color: 'blue' });" -)] -#[case("export const f = (css) => css({ color: 'red' });")] #[case("{ const css = (rules) => rules; css({ color: 'red' }); }")] #[case("const rules = css;\nexport const a = rules({ color: 'red' });")] #[case("export const a = ((css) => css({ color: 'red' }))((rules) => rules);")] @@ -184,6 +183,15 @@ fn a_local_named_like_a_style_api_or_math_is_not_one(#[case] code: &str) { assert_eq!(plan_with_head(code), Plan::Run, "{code}"); } +#[rstest] +#[case( + "function f(css) { return css({ color: 'red' }); } export const a = css({ color: 'blue' });" +)] +#[case("export const f = (css) => css({ color: 'red' });")] +fn inert_functions_do_not_call_shadowed_style_apis(#[case] code: &str) { + assert_eq!(plan_with_head(code), Plan::Plain, "{code}"); +} + #[test] fn a_style_api_the_package_does_not_give_is_not_compiled() { assert_eq!( diff --git a/libs/extractor/src/stylesheet_policy_values.rs b/libs/extractor/src/stylesheet_policy_values.rs index 0c4743efe..767428b26 100644 --- a/libs/extractor/src/stylesheet_policy_values.rs +++ b/libs/extractor/src/stylesheet_policy_values.rs @@ -13,6 +13,8 @@ pub(super) enum Value { Text(String), Number(f64), Nullish, + /// Safe to declare, but neither plain data nor a proof that calling is safe. + Function, Object(FxHashMap), Array(Vec), /// The expression is nonthrowing but its resulting shape is not known. @@ -20,6 +22,21 @@ pub(super) enum Value { } impl Value { + fn join(self, other: Self) -> Option { + if self == other { + return Some(self); + } + (self.data() && other.data()).then_some(Self::Unknown) + } + + pub(super) fn data(&self) -> bool { + match self { + Self::Function => false, + Self::Object(object) => object.values().all(Self::data), + Self::Array(array) => array.iter().all(Self::data), + Self::Scalar | Self::Text(_) | Self::Number(_) | Self::Nullish | Self::Unknown => true, + } + } const fn primitive(&self) -> bool { matches!( self, @@ -73,7 +90,7 @@ impl Value { }), ) } - Self::Scalar | Self::Number(_) | Self::Nullish | Self::Unknown => None, + Self::Scalar | Self::Number(_) | Self::Nullish | Self::Unknown | Self::Function => None, } } } @@ -86,6 +103,9 @@ impl Proof<'_> { Expression::NullLiteral(_) => Some(Value::Nullish), Expression::NumericLiteral(number) => Some(Value::Number(number.value)), Expression::StringLiteral(text) => Some(Value::Text(text.value.to_string())), + Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_) => { + Some(Value::Function) + } Expression::Identifier(identifier) => match self.symbol(identifier) { Some(symbol) => self.values.get(&symbol).cloned(), None => match identifier.name.as_str() { @@ -137,7 +157,7 @@ impl Proof<'_> { }), ), Value::Nullish | Value::Scalar | Value::Number(_) => {} - Value::Unknown => return None, + Value::Unknown | Value::Function => return None, }, ObjectPropertyKind::ObjectProperty(property) => { if property.method || property.kind != PropertyKind::Init { @@ -214,12 +234,12 @@ impl Proof<'_> { self.value(&conditional.test)?; let left = self.value(&conditional.consequent)?; let right = self.value(&conditional.alternate)?; - Some(if left == right { left } else { Value::Unknown }) + left.join(right) } Expression::LogicalExpression(logical) => { let left = self.value(&logical.left)?; let right = self.value(&logical.right)?; - Some(if left == right { left } else { Value::Unknown }) + left.join(right) } Expression::SequenceExpression(sequence) => { let mut value = Value::Nullish; @@ -241,18 +261,3 @@ impl Proof<'_> { } } } - -pub(super) fn literal(code: &str) -> Option { - let allocator = oxc_allocator::Allocator::default(); - let parsed = oxc_parser::Parser::new(&allocator, code, oxc_span::SourceType::default()) - .parse_expression() - .ok()?; - let scoping = oxc_semantic::Scoping::default(); - let proof = Proof { - scoping: &scoping, - apis: FxHashMap::default(), - namespaces: rustc_hash::FxHashSet::default(), - values: FxHashMap::default(), - }; - proof.value(&parsed) -} From 06c66b961c671dff26ca57e3309abd38d848dfe2 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 06:41:09 +0900 Subject: [PATCH 05/21] fix(extractor): reject recoverable source and semantic parse errors Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/diagnostics_tests.rs | 44 +++++++++++++++++++ libs/extractor/src/lib.rs | 41 +++++++++++------ ...tests__emotion_css_prop_in_every_form.snap | 4 +- ...extract_style_props_with_class_name-4.snap | 4 +- ...tor__tests__inline_imported_constants.snap | 4 +- ...tractor__tests__wrong_style_variables.snap | 4 +- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/libs/extractor/src/diagnostics_tests.rs b/libs/extractor/src/diagnostics_tests.rs index f164a42ae..c698747e4 100644 --- a/libs/extractor/src/diagnostics_tests.rs +++ b/libs/extractor/src/diagnostics_tests.rs @@ -44,3 +44,47 @@ fn locations_follow_javascript_line_terminators(#[case] separator: &str) { assert_eq!(location, "/src/unicode.ts:2:4"); } + +#[rstest::rstest] +#[case("return 1;")] +#[case("const invalid = /[z-a]/;")] +#[case("const repeated = 1; const repeated = 2;")] +#[case("const invalid = 01;")] +#[serial] +fn recoverable_and_semantic_errors_do_not_publish_css(#[case] invalid: &str) { + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{invalid}\nexport const card = css({{ color: 'red' }});" + ); + + let error = extract_without_source_map("/src/syntax.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!( + error.starts_with("/src/syntax.tsx:2:"), + "{invalid}: {error}" + ); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} + +#[rstest::rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[case("
")] +#[serial] +fn empty_jsx_attribute_expressions_are_syntax_errors_not_absent_values(#[case] element: &str) { + let source = format!("import {{ Box }} from '@devup-ui/react';\nexport const App = {element};"); + + let error = extract_without_source_map("/src/empty.tsx", &source, ExtractOption::default()) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!(error.starts_with("/src/empty.tsx:2:"), "{error}"); + assert!(error.contains("Cannot parse source:"), "{error}"); + assert!(error.contains("non-empty 'expression'"), "{error}"); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 77cb40a59..ddec14e21 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -39,7 +39,7 @@ use oxc_allocator::{Allocator, CloneIn}; use oxc_ast::ast::Expression; use oxc_ast_visit::VisitMut; use oxc_codegen::{Codegen, CodegenOptions}; -use oxc_parser::{Parser, ParserReturn}; +use oxc_parser::{ParseOptions, Parser, ParserReturn}; use oxc_span::SourceType; use rustc_hash::FxHashMap; use rustc_hash::FxHashSet; @@ -514,14 +514,28 @@ fn extract_source( fatal_error, // Parser encountered an error it couldn't recover from diagnostics, .. - } = Parser::new(&allocator, code_to_parse, source_type).parse(); - if fatal_error { + } = Parser::new(&allocator, code_to_parse, source_type) + .with_options(ParseOptions { + parse_regular_expression: true, + ..ParseOptions::default() + }) + .parse(); + let semantic_diagnostics = if diagnostics.is_empty() { + oxc_semantic::SemanticBuilder::new() + .with_check_syntax_error(true) + .build(&program) + .diagnostics + } else { + Default::default() + }; + if fatal_error || !diagnostics.is_empty() || !semantic_diagnostics.is_empty() { let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) .chain(earlier_edits.iter().copied()) .collect(); let errors = diagnostics .iter() + .chain(semantic_diagnostics.iter()) .take(1) .map(|error| { ( @@ -530,7 +544,8 @@ fn extract_source( .first() .map_or(0, oxc_span::LabeledSpan::offset), format!( - "Cannot parse source: {error}. Fix: correct the syntax at this location" + "Cannot parse source: {}. Fix: correct the syntax at this location", + error.to_string().trim_end_matches('.') ), ) }) @@ -1602,7 +1617,7 @@ mod tests { extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -1851,7 +1866,7 @@ mod tests { extract( "test.tsx", r"import {Box as C} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -7106,7 +7121,7 @@ export { extract( "test.jsx", r#"import {Box, css} from '@devup-ui/core' - + "#, ExtractOption { package: "@devup-ui/core".to_string(), @@ -7849,7 +7864,7 @@ export { c as Lib };"#, extract( "test.jsx", r"import {Box} from '@devup-ui/core' - + ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9609,7 +9624,7 @@ keyframes({ extract( "test.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -9642,7 +9657,7 @@ keyframes({ extract( "test1.tsx", r"import {Box} from '@devup-ui/core' - /> + /> ", ExtractOption { package: "@devup-ui/core".to_string(), @@ -19327,7 +19342,7 @@ import orange from './tokens'; import * as tokens from './tokens'; import named from './named'; import { handler } from './handler'; -import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from './cjs-tokens'; +import cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC as CJS_DYNAMIC } from './cjs-tokens'; import cjsObject, { OBJ, OVERRIDDEN, LATER } from './cjs-object'; import twelve from './cjs-value'; import twice from './cjs-twice'; @@ -19343,7 +19358,7 @@ export const f = { PRIMARY, unused, SIZE: tokens.SIZE }; export const g =
; export const h = ; export const i = Devup['css']({ color: SIZE }); -export const l = ; +export const l = ; export const j = ; export const k = styled('div')({ color: SIZE });", ExtractOption { @@ -21465,7 +21480,7 @@ export const App = () => <>
;" + "export const App = ({ c }) => <>
;" ), ( "test.tsx", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap index 7774be8cd..42f9333bf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_in_every_form.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ danger }) =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ active }) =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst style = css`color: red;`;\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"/** @jsxImportSource @emotion/react */\\nexport const App = () =>
;\"),\n(\"test.ts\",\n\"import { jsx } from '@emotion/react';\\nexport const App = () => jsx('div', { css: { color: 'red' } });\"),\n(\"test.ts\",\n\"import { jsx as _jsx } from '@emotion/react/jsx-runtime';\\nexport const App = () => _jsx('div', { css: { color: 'red' } });\"),\n(\"test.ts\",\n\"import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });\"),\n(\"test.ts\",\n\"import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);\"),\n(\"test.tsx\",\n\"/** @jsx jsx */\\nimport { jsx } from '@emotion/react';\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;\"),\n(\"test.tsx\",\n\"export const App = () =>
{ return { color: colors.text }; }} />;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst override = css({ color: 'blue' });\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst override = css({ color: 'blue' });\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst base = css({ color: 'blue', margin: 0 });\\nexport const App = ({ cls }) => <>
;\"),\n(\"test.tsx\",\n\"export const App = ({ c, w }) =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ c }) => <>
;\"),\n(\"test.tsx\",\n\"const base = { color: 'red', padding: 4 };\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"const SPACE = 4;\\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;\"),\n(\"test.ts\",\n\"import { jsx as _jsx } from '@emotion/react/jsx-runtime';\\nconst base = { padding: 2 };\\nconst SPACE = 3;\\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];\"),])" +expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ danger }) =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ active }) =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst style = css`color: red;`;\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"/** @jsxImportSource @emotion/react */\\nexport const App = () =>
;\"),\n(\"test.ts\",\n\"import { jsx } from '@emotion/react';\\nexport const App = () => jsx('div', { css: { color: 'red' } });\"),\n(\"test.ts\",\n\"import { jsx as _jsx } from '@emotion/react/jsx-runtime';\\nexport const App = () => _jsx('div', { css: { color: 'red' } });\"),\n(\"test.ts\",\n\"import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });\"),\n(\"test.ts\",\n\"import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);\"),\n(\"test.tsx\",\n\"/** @jsx jsx */\\nimport { jsx } from '@emotion/react';\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;\"),\n(\"test.tsx\",\n\"export const App = () =>
{ return { color: colors.text }; }} />;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst override = css({ color: 'blue' });\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst override = css({ color: 'blue' });\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"import { css } from '@emotion/react';\\nconst base = css({ color: 'blue', margin: 0 });\\nexport const App = ({ cls }) => <>
;\"),\n(\"test.tsx\",\n\"export const App = ({ c, w }) =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ c }) => <>
;\"),\n(\"test.tsx\",\n\"const base = { color: 'red', padding: 4 };\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"const SPACE = 4;\\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\\nexport const App = () =>
;\"),\n(\"test.tsx\",\n\"export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;\"),\n(\"test.ts\",\n\"import { jsx as _jsx } from '@emotion/react/jsx-runtime';\\nconst base = { padding: 2 };\\nconst SPACE = 3;\\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];\"),])" --- [ ( @@ -380,7 +380,7 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"export const App = () =>
<>
;", + "export const App = ({ c }) => <>
;", ToBTreeSet { styles: { Static( diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-4.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-4.snap index e65ea4f51..5f5019c47 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box as C} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Box as C} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap index 5649ea29f..9ba030c70 100644 --- a/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__inline_imported_constants.snap @@ -406,7 +406,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "lighting-color", level: 0, - identifier: "DYNAMIC", + identifier: "CJS_DYNAMIC", selector: None, style_order: None, }, @@ -475,5 +475,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = \"at\";\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"at\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY, SIZE, NEG, TEMPLATE, colors, ALIAS, MUTABLE, DYNAMIC, renamed, alsoMutable, reBase, notThere, gone, value, ns, NEG_STRING, TEMPLATE_EXPRESSION, SPACED, NOT_OBJECT, helper, looped } from \"./tokens\";\nimport orange from \"./tokens\";\nimport * as tokens from \"./tokens\";\nimport named from \"./named\";\nimport { handler } from \"./handler\";\nimport cjs, { COMPILED, FROM_REQUIRE, DESTRUCTURED, MUTATED, DYNAMIC as CJS_DYNAMIC } from \"./cjs-tokens\";\nimport cjsObject, { OBJ, OVERRIDDEN, LATER } from \"./cjs-object\";\nimport twelve from \"./cjs-value\";\nimport twice from \"./cjs-twice\";\nimport marker, { named as markerNamed } from \"./cjs-marker\";\nimport { unused } from \"./unused\";\nimport missing from \"./missing\";\nexport const a =
;\nexport const b = \"a\";\nexport const c =
;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = (PRIMARY) =>
;\nexport const f = {\n\tPRIMARY,\n\tunused,\n\tSIZE: tokens.SIZE\n};\nexport const g =
;\nexport const h = ;\nexport const i = \"at\";\nexport const l =
;\nexport const j =
;\nexport const k = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\"at\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__wrong_style_variables.snap b/libs/extractor/src/snapshots/extractor__tests__wrong_style_variables.snap index d0a75ea31..44f25b4c6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__wrong_style_variables.snap +++ b/libs/extractor/src/snapshots/extractor__tests__wrong_style_variables.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Box} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: "
;\n", + code: "
;\n", } From 400cf89fc562af2287cfbce1d918571b333d3a70 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 06:51:22 +0900 Subject: [PATCH 06/21] fix(extractor): reject synthetic reflection during static evaluation Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/ci_coverage_tests.rs | 22 ++ libs/extractor/src/evaluation_sandbox.rs | 24 ++ .../src/evaluation_sandbox_guards.rs | 126 ++++--- .../evaluation_sandbox_reflection_tests.rs | 320 ++++++++++++++++++ libs/extractor/src/lib.rs | 2 + .../src/stylesheet_regression_tests.rs | 23 ++ 6 files changed, 471 insertions(+), 46 deletions(-) create mode 100644 libs/extractor/src/ci_coverage_tests.rs create mode 100644 libs/extractor/src/evaluation_sandbox_reflection_tests.rs diff --git a/libs/extractor/src/ci_coverage_tests.rs b/libs/extractor/src/ci_coverage_tests.rs new file mode 100644 index 000000000..af26900c4 --- /dev/null +++ b/libs/extractor/src/ci_coverage_tests.rs @@ -0,0 +1,22 @@ +use crate::{ExtractOption, extract}; + +#[test] +#[serial_test::serial] +fn computed_style_guard_error_names_original_read_location() +-> Result<(), Box> { + // Given + let code = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\nexport const a = css({ w: compare('a', 'b') });"; + // When + let error = extract("computed.tsx", code, ExtractOption::default()) + .err() + .ok_or("forbidden style unexpectedly extracted")?; + // Then + let error = error.to_string(); + assert!(error.starts_with("computed.tsx:2:17:"), "{error}"); + assert!(error.contains("String.prototype.localeCompare"), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox.rs b/libs/extractor/src/evaluation_sandbox.rs index e32c2cdf9..49dba49a3 100644 --- a/libs/extractor/src/evaluation_sandbox.rs +++ b/libs/extractor/src/evaluation_sandbox.rs @@ -37,6 +37,7 @@ enum Kind { Random, Environment, Locale, + Source, } impl Kind { @@ -46,6 +47,7 @@ impl Kind { Self::Random => "the chance it gives differs on every build", Self::Environment => "only the page or process running the code knows it", Self::Locale => "its result depends on the locale and the engine's Unicode data", + Self::Source => "its text changes when the build transforms the function", } } @@ -55,6 +57,7 @@ impl Kind { "Date" | "performance" => Self::Clock, "crypto" | "Math" => Self::Random, "Intl" | "Temporal" => Self::Locale, + "Function" => Self::Source, _ if name.contains(".prototype.") => Self::Locale, _ => Self::Environment, } @@ -184,6 +187,23 @@ fn guard_globals(context: &mut Context) -> JsResult<()> { setup .to_object(context)? .call(&JsValue::undefined(), &[forbid.into(), config], context)?; + for name in GUARDED_GLOBALS { + let descriptor = boa_engine::builtins::object::OrdinaryObject::get_own_property_descriptor( + &JsValue::undefined(), + &[context.global_object().into(), JsString::from(name).into()], + context, + )?; + let getter = descriptor + .to_object(context)? + .get(js_string!("get"), context)? + .to_object(context)?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((getter, name.to_string())); + } + } Ok(()) } @@ -207,6 +227,10 @@ fn install_console(context: &mut Context) -> JsResult<()> { #[path = "evaluation_sandbox_tests.rs"] mod tests; +#[cfg(test)] +#[path = "evaluation_sandbox_reflection_tests.rs"] +mod reflection_tests; + impl Sandbox { /// Installs the instrumented source's native identity/thunk helper. pub(crate) fn prepare(&self, context: &mut Context, source: &Instrumented) -> JsResult<()> { diff --git a/libs/extractor/src/evaluation_sandbox_guards.rs b/libs/extractor/src/evaluation_sandbox_guards.rs index 2a18af4d6..080ce35bb 100644 --- a/libs/extractor/src/evaluation_sandbox_guards.rs +++ b/libs/extractor/src/evaluation_sandbox_guards.rs @@ -56,7 +56,7 @@ pub(super) const EXACT_GLOBALS: &[&str] = &[ ]; use boa_engine::{ Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, js_string, - property::PropertyDescriptor, + object::FunctionObjectBuilder, property::PropertyDescriptor, }; fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) -> JsResult<()> { @@ -114,7 +114,7 @@ fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) - pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { let math = context.intrinsics().objects().math(); guard_method(context, &math, ("Math", "random"))?; - for (owner, methods) in LOCALE_METHODS { + for (owner, methods) in GUARDED_METHODS { let constructors = context.intrinsics().constructors(); let holder = match owner { "Object" => constructors.object(), @@ -122,6 +122,7 @@ pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { "BigInt" => constructors.bigint(), "Array" => constructors.array(), "TypedArray" => constructors.typed_array(), + "Function" => constructors.function(), _ => constructors.string(), } .prototype(); @@ -134,63 +135,96 @@ pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { fn guard_descriptor(context: &mut Context) -> JsResult<()> { let object = context.intrinsics().constructors().object().constructor(); - let original = object - .get(js_string!("getOwnPropertyDescriptor"), context)? - .to_object(context)?; - let wrapper = function( - context, - "getOwnPropertyDescriptor", - NativeFunction::from_copy_closure_with_captures( - |this, args, original: &JsObject, context| { - let value = original.call(this, args, context)?; - if let Some(descriptor) = value.as_object() { - let getter = descriptor.get(js_string!("get"), context)?; - let name = context.get_data::().and_then(|evidence| { - evidence - .methods - .borrow() - .iter() - .find(|(method, _)| { - getter.as_object().is_some_and(|getter| getter == *method) - }) - .map(|(_, name)| name.clone()) - }); - if let Some(name) = name { - let site = context - .get_data::() - .and_then(|evidence| evidence.site.borrow().clone()); - return Err(JsError::from_opaque(record(context, &name, site))); + let reflect = context.intrinsics().objects().reflect(); + for (holder, name, batch) in [ + (&object, "getOwnPropertyDescriptor", false), + (&object, "getOwnPropertyDescriptors", true), + (&reflect, "getOwnPropertyDescriptor", false), + ] { + let original = holder + .get(JsString::from(name), context)? + .to_object(context)?; + let realm = context.realm().clone(); + let wrapper = FunctionObjectBuilder::new( + &realm, + NativeFunction::from_copy_closure_with_captures( + |this, args, captures: &(JsObject, bool), context| { + let site = context + .get_data::() + .and_then(|evidence| evidence.site.borrow().clone()); + let value = captures.0.call(this, args, context)?; + if captures.1 { + let descriptors = value.to_object(context)?; + for key in descriptors.own_property_keys(context)? { + check_descriptor( + &descriptors.get(key, context)?, + site.clone(), + context, + )?; + } + } else { + check_descriptor(&value, site, context)?; } - } - Ok(value) - }, - original, - ), - ); - object.define_property_or_throw( - js_string!("getOwnPropertyDescriptor"), - PropertyDescriptor::builder() - .value(wrapper) - .writable(true) - .enumerable(false) - .configurable(true), - context, - )?; + Ok(value) + }, + (original, batch), + ), + ) + .name(JsString::from(name)) + .length(if batch { 1 } else { 2 }) + .build(); + holder.define_property_or_throw( + JsString::from(name), + PropertyDescriptor::builder() + .value(wrapper) + .writable(true) + .enumerable(false) + .configurable(true), + context, + )?; + } Ok(()) } +fn check_descriptor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + let getter = value.as_object().and_then(|descriptor| { + descriptor + .borrow() + .properties() + .get(&js_string!("get").into()) + .and_then(|property| property.value().cloned()) + }); + let name = context.get_data::().and_then(|evidence| { + evidence.methods.borrow().iter().find_map(|(method, name)| { + getter + .as_ref()? + .as_object() + .filter(|getter| getter == method) + .map(|_| name.clone()) + }) + }); + match name { + Some(name) => Err(JsError::from_opaque(record(context, &name, site))), + None => Ok(()), + } +} + /// Globals the engine gives whose values depend on the locale or the clock; /// any other global nothing declares is the environment's pub(super) const GUARDED_GLOBALS: [&str; 5] = ["Date", "Intl", "performance", "crypto", "Temporal"]; -/// Methods giving what the locale or the engine's Unicode data make them, by -/// the prototype holding them -pub(super) const LOCALE_METHODS: [(&str, &[&str]); 6] = [ +/// Methods whose results depend on locale, Unicode data, or transformed source. +pub(super) const GUARDED_METHODS: [(&str, &[&str]); 7] = [ ("Object", &["toLocaleString"]), ("Number", &["toLocaleString"]), ("BigInt", &["toLocaleString"]), ("Array", &["toLocaleString"]), ("TypedArray", &["toLocaleString"]), + ("Function", &["toString"]), ( "String", &[ diff --git a/libs/extractor/src/evaluation_sandbox_reflection_tests.rs b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs new file mode 100644 index 000000000..0060b337e --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs @@ -0,0 +1,320 @@ +use std::path::Path; + +use boa_engine::{Context, JsValue, Source}; +use rstest::rstest; + +use super::{Failure, Sandbox}; + +fn run(script: &str) -> Result { + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(Failure::Js)?; + sandbox.run_source( + &mut context, + Source::from_bytes(&source.code).with_path(Path::new("reflection.js")), + ) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case("Object.getOwnPropertyDescriptors(Math)", "Math.random")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')", "Math.random")] +#[case( + "const api = Reflect;\napi['getOwnProperty' + 'Descriptor'](Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const { getOwnPropertyDescriptor: read } = Reflect;\nread(Math, 'random')", + "Math.random" +)] +#[case( + "const read = Reflect.getOwnPropertyDescriptor.bind(null, Math);\nread('random')", + "Math.random" +)] +#[case( + "const read = Object.getOwnPropertyDescriptors.bind(null);\nread(Math)", + "Math.random" +)] +#[case( + "Object.getOwnPropertyDescriptors(String.prototype)", + "String.prototype.localeCompare" +)] +#[case( + "Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize')", + "String.prototype.normalize" +)] +#[case("Object.getOwnPropertyDescriptors(globalThis)", "Date")] +fn guarded_descriptors_fail_at_the_reflection_call( + #[case] script: &str, + #[case] name: &str, +) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("reflection did not record a forbidden read".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + let place = crate::locate("reflection.js", script, offset); + assert_eq!(violations[0].site(), Some((place.as_str(), offset))); + assert!(violations[0].error().to_string().contains(&place)); + Ok(()) +} + +#[rstest] +fn generated_global_getter_descriptors_are_guarded( + #[values("Object.getOwnPropertyDescriptor", "Reflect.getOwnPropertyDescriptor")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, +) -> Result<(), String> { + // Given + let script = format!("{reader}(globalThis, '{name}')"); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("global guard descriptor escaped".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(0)); + Ok(()) +} + +#[rstest] +#[case("Object.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptors(Math)")] +#[case("Reflect.getOwnPropertyDescriptor(Math, 'random')")] +#[case("Object.getOwnPropertyDescriptor(globalThis, 'Date')")] +#[case("Reflect.getOwnPropertyDescriptor(globalThis, 'Date')")] +fn caught_descriptor_errors_cannot_mutate_the_host_evidence( + #[case] read: &str, +) -> Result<(), String> { + // Given + let script = format!( + "try {{ {read} }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nMath.random = () => 1; 'accepted'" + ); + // When + let Err(Failure::Forbidden(violations)) = run(&script) else { + return Err("caught reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations.len(), 1); + let name = if read.contains("globalThis") { + "Date" + } else { + "Math.random" + }; + assert_eq!(violations[0].name(), name); + assert_eq!(violations[0].site().map(|(_, offset)| offset), Some(6)); + assert_eq!( + violations[0].requirement(), + super::Kind::of(name).requirement() + ); + assert!( + violations[0] + .error() + .to_string() + .contains(&format!("`{name}`")) + ); + Ok(()) +} + +#[test] +fn key_coercion_cannot_replace_the_reflection_call_site() -> Result<(), String> { + // Given + let script = "const key = { toString() { Math.max(1, 2); return 'random' } };\nReflect.getOwnPropertyDescriptor(Math, key)"; + let offset = script + .find("Reflect.getOwnPropertyDescriptor") + .ok_or("missing read")?; + // When + let Err(Failure::Forbidden(violations)) = run(script) else { + return Err("coerced reflection was accepted".to_string()); + }; + // Then + assert_eq!(violations[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) +} + +#[rstest] +#[case("String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Reflect.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", "1")] +#[case("String(Object.getOwnPropertyDescriptors({ x: 1 }).x.value)", "1")] +#[case( + "`${Object.getOwnPropertyDescriptor.length}:${Object.getOwnPropertyDescriptors.length}:${Reflect.getOwnPropertyDescriptor.length}`", + "2:1:2" +)] +#[case("String(Object.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Reflect.getOwnPropertyDescriptor({}, 'missing'))", "undefined")] +#[case("String(Object.getOwnPropertyDescriptor('a', '0').value)", "a")] +#[case("String(Object.getOwnPropertyDescriptors('a')['0'].value)", "a")] +#[case( + "const o = { get random() { return 4 } }; const d = Reflect.getOwnPropertyDescriptor(o, 'random'); String(d.get.call(o))", + "4" +)] +#[case( + "const o = { get x() { throw new Error('getter executed') } }; String(typeof Object.getOwnPropertyDescriptors(o).x.get)", + "function" +)] +#[case( + "const o = {}; Object.defineProperty(o, 'x', { value: 4, writable: false, enumerable: false, configurable: false }); JSON.stringify(Reflect.getOwnPropertyDescriptor(o, 'x'))", + "{\"value\":4,\"writable\":false,\"enumerable\":false,\"configurable\":false}" +)] +#[case( + "const o = { normalize() { return 5 } }; String(Object.getOwnPropertyDescriptors(o).normalize.value())", + "5" +)] +#[case( + "Math.random = () => 7; String(Object.getOwnPropertyDescriptors(Math).random.value())", + "7" +)] +#[case( + "String.prototype.normalize = () => 'own'; String(Reflect.getOwnPropertyDescriptor(String.prototype, 'normalize').value())", + "own" +)] +#[case( + "globalThis.Date = 9; String(Object.getOwnPropertyDescriptor(globalThis, 'Date').value)", + "9" +)] +#[case( + "globalThis.Date = 9; globalThis.Intl = 8; globalThis.performance = 7; globalThis.crypto = 6; globalThis.Temporal = 5; String(Object.getOwnPropertyDescriptors(globalThis).Date.value)", + "9" +)] +#[case( + "Object.defineProperty(globalThis, 'Intl', { get() { return 8 } }); String(Reflect.getOwnPropertyDescriptor(globalThis, 'Intl').get())", + "8" +)] +#[case( + "String(Reflect.getOwnPropertyDescriptor(Math, 'PI').value === Math.PI)", + "true" +)] +#[case( + "const key = Symbol('x'); const o = { [key]: 6 }; String(Object.getOwnPropertyDescriptors(o)[key].value)", + "6" +)] +#[case( + "Object.defineProperty(Object.prototype, 'get', { get() { throw new Error('inherited get') } }); String(Object.getOwnPropertyDescriptor({ x: 1 }, 'x').value)", + "1" +)] +#[case( + "const read = { getOwnPropertyDescriptors() { return { x: { value: 3 } } } }; String(read.getOwnPropertyDescriptors().x.value)", + "3" +)] +#[case( + "let order = ''; const o = new Proxy({ x: 2 }, { getOwnPropertyDescriptor(target, key) { order += 'd'; return Reflect.getOwnPropertyDescriptor(target, key) } }); const key = { toString() { order += 'k'; return 'x' } }; const d = Reflect.getOwnPropertyDescriptor.call(null, o, key); `${order}:${d.value}`", + "kd:2" +)] +fn user_owned_descriptors_keep_their_semantics( + #[case] script: &str, + #[case] expected: &str, +) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary descriptor was forbidden".to_string()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a string")? + .to_std_string_escaped(), + expected + ); + Ok(()) +} + +#[test] +fn reflected_getter_reads_stay_forbidden_during_serialization() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let script = "({ get value() { return Object.getOwnPropertyDescriptors(Math).random.value } })"; + let source = super::instrument(script, "reflection.js"); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + let value = sandbox + .run_source(&mut context, Source::from_bytes(&source.code)) + .map_err(|_| "object creation failed")?; + let json = context.intrinsics().objects().json(); + let stringify = json + .get(boa_engine::js_string!("stringify"), &mut context) + .map_err(|error| error.to_string())? + .to_object(&mut context) + .map_err(|error| error.to_string())?; + // When + let error = stringify + .call(&JsValue::undefined(), &[value], &mut context) + .err() + .ok_or("reflection serialized silently")?; + // Then + let Err(Failure::Forbidden(violations)) = sandbox.check(&[&error]) else { + return Err("serialization lost forbidden evidence".to_string()); + }; + assert_eq!(violations[0].name(), "Math.random"); + assert_eq!( + violations[0].site().map(|(_, offset)| offset), + script.find("Object.getOwnPropertyDescriptors") + ); + Ok(()) +} + +#[test] +fn reflect_primitive_target_keeps_its_native_type_error() -> Result<(), String> { + // Given / When + let value = run("try { Reflect.getOwnPropertyDescriptor(1, 'x') } catch (error) { String(error instanceof TypeError) }") + .map_err(|_| "primitive reflection did not preserve its catchable error")?; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected a caught error")? + .to_std_string_escaped(), + "true" + ); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn stylesheet_descriptor_branch_fails_instead_of_emitting_blue_css() -> Result<(), String> { + // Given + let code = "import { style } from '@vanilla-extract/css';\nexport const card = style({ color: Object.getOwnPropertyDescriptors(Math).random.value ? 'red' : 'blue' });"; + let offset = code + .find("Object.getOwnPropertyDescriptors") + .ok_or("missing read")?; + // When + let error = crate::extract( + "reflected-random.css.ts", + code, + crate::ExtractOption { + import_aliases: std::collections::HashMap::from([( + "@vanilla-extract/css".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..crate::ExtractOption::default() + }, + ) + .err() + .ok_or("reflection silently emitted CSS")? + .to_string(); + // Then + let place = crate::locate("reflected-random.css.ts", code, offset); + assert!(error.starts_with(&format!("{place}:")), "{error}"); + assert!(error.contains("`Math.random`"), "{error}"); + assert!(error.contains(super::Kind::Random.requirement()), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index ddec14e21..b1416e037 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,8 @@ mod as_visit; mod barrel; mod build_time_values; +#[cfg(test)] +mod ci_coverage_tests; mod component; mod composition; mod css_prop; diff --git a/libs/extractor/src/stylesheet_regression_tests.rs b/libs/extractor/src/stylesheet_regression_tests.rs index 5b0c1a972..21cd0725b 100644 --- a/libs/extractor/src/stylesheet_regression_tests.rs +++ b/libs/extractor/src/stylesheet_regression_tests.rs @@ -94,3 +94,26 @@ fn environment_reads_cannot_be_replaced_with_synthetic_build_values( assert!(error.contains(global), "{error}"); assert!(error.contains("Fix:"), "{error}"); } + +#[test] +#[serial] +fn function_source_reflection_cannot_observe_build_instrumentation() { + let source = "import { style } from '@devup-ui/react';\nfunction value() { return Math.PI; }\nexport const card = style({ color: value.toString().includes('__devup_read_site_') ? 'blue' : 'red' });"; + + let error = extract_without_source_map( + "/src/function-source.css.ts", + source, + ExtractOption::default(), + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + + assert!( + error.starts_with("/src/function-source.css.ts:3:"), + "{error}" + ); + assert!(error.contains("Function.prototype.toString"), "{error}"); + assert!(error.contains("transforms the function"), "{error}"); + assert!(error.contains("Fix:"), "{error}"); +} From 1949a6b127cbeb25c848e75318eb564e16ffa468 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 07:00:27 +0900 Subject: [PATCH 07/21] test(extractor): cover evaluation setup and naming seams Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/build_time_values.rs | 9 +- .../src/build_time_values_execute.rs | 35 +++- ...uild_time_values_execute_coverage_tests.rs | 152 +++++++++++++++++- .../extractor/src/evaluation_sandbox_sites.rs | 10 +- .../src/evaluation_sandbox_sites_ci_tests.rs | 26 +++ .../extractor/src/evaluation_sandbox_tests.rs | 3 + .../src/evaluation_sandbox_tests_ci.rs | 29 ++++ libs/extractor/src/fresh_name.rs | 38 +++++ libs/extractor/src/lib.rs | 1 + .../extractor/src/module_loader/operations.rs | 8 +- 10 files changed, 291 insertions(+), 20 deletions(-) create mode 100644 libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs create mode 100644 libs/extractor/src/evaluation_sandbox_tests_ci.rs create mode 100644 libs/extractor/src/fresh_name.rs diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 85cde50d7..a04f1b840 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -1004,10 +1004,11 @@ fn definitions(program: &Program<'_>, code: &str, found: &[Found]) -> Generated if let Some(declaration) = declaration { // A binding whose value throws is one reading it throws for, // leaving the others - for declarator in &declaration.declarations { - let Some(init) = &declarator.init else { - continue; - }; + for (declarator, init) in declaration + .declarations + .iter() + .filter_map(|declarator| declarator.init.as_ref().map(|init| (declarator, init))) + { module.text.push_str(declaration.kind.as_str()); module.text.push(' '); module.copy( diff --git a/libs/extractor/src/build_time_values_execute.rs b/libs/extractor/src/build_time_values_execute.rs index 6a019aa9f..d7b768e44 100644 --- a/libs/extractor/src/build_time_values_execute.rs +++ b/libs/extractor/src/build_time_values_execute.rs @@ -26,6 +26,34 @@ const DEFINITIONS_SOURCE: &str = ""; const VALUE_SOURCE: &str = ""; type Computed = (Vec, BTreeSet); +fn initialize_helpers(sandbox: &Sandbox, context: &mut Context) -> Result<(), Failure> { + sandbox + .run_source( + context, + Source::from_bytes(HELPERS).with_path(Path::new(HELPERS_SOURCE)), + ) + .map(|_| ()) +} + +fn setup_failure(failure: Failure) -> Vec { + let reasons = match failure { + Failure::Js(error) => vec![error.to_string()], + Failure::Forbidden(violations) => violations + .iter() + .map(|violation| violation.error().to_string()) + .collect(), + }; + reasons + .into_iter() + .map(|reason| { + ( + 0, + format!("{reason}; report internal evaluation setup failure"), + ) + }) + .collect() +} + fn stop( failure: Failure, scripts: &[Mapped], @@ -89,12 +117,7 @@ pub(super) fn compute( } let mut context = Context::default(); let sandbox = Sandbox::new(&mut context).map_err(|error| vec![(0, error.to_string())])?; - if let Err(failure) = sandbox.run_source( - &mut context, - Source::from_bytes(HELPERS).with_path(Path::new(HELPERS_SOURCE)), - ) { - return stop(failure, &[], code); - } + initialize_helpers(&sandbox, &mut context).map_err(setup_failure)?; let module = definitions(&program, code, &found); let mut scripts = vec![Mapped::new(module, filename, DEFINITIONS_SOURCE)]; sandbox diff --git a/libs/extractor/src/build_time_values_execute_coverage_tests.rs b/libs/extractor/src/build_time_values_execute_coverage_tests.rs index 195d7b482..2ebea874a 100644 --- a/libs/extractor/src/build_time_values_execute_coverage_tests.rs +++ b/libs/extractor/src/build_time_values_execute_coverage_tests.rs @@ -1,7 +1,7 @@ use boa_engine::{Context, Source}; -use super::stop; -use crate::evaluation_sandbox::Sandbox; +use super::{compute, initialize_helpers, setup_failure, stop}; +use crate::evaluation_sandbox::{Failure, Sandbox}; #[test] fn execution_failure_preserves_guard_error_when_no_generated_script_can_locate_it() @@ -24,3 +24,151 @@ fn execution_failure_preserves_guard_error_when_no_generated_script_can_locate_i assert!(errors[0].1.starts_with("ReferenceError:")); Ok(()) } + +#[test] +fn invalid_source_has_no_computed_values() { + // Given + let code = "import { css } from '@devup-ui/react'; const = css({ w: 2 });"; + // When + let result = compute( + code, + "invalid.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn mutable_sibling_declarations_leave_style_values_at_runtime() { + // Given + let code = "import { css } from '@devup-ui/react'; let unused, width = 2; export const a = css({ w: width + 1 });"; + // When + let result = compute( + code, + "sibling.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ); + // Then + assert!(matches!(result, Ok(None))); +} + +#[test] +fn definitions_skip_ambient_declarators_when_generating_initialized_values() -> Result<(), String> { + use super::super::{Closure, Found, definitions, parse}; + + // Given + let code = "declare const external: number; const width = 2;"; + let allocator = oxc_allocator::Allocator::default(); + let program = parse(&allocator, "definitions.ts", code).ok_or("valid TypeScript rejected")?; + let found = Found { + span: oxc_span::Span::default(), + closure: Closure { + statements: [0, 1].into_iter().collect(), + imports: Default::default(), + }, + shorthand: None, + rules_only: false, + }; + // When + let generated = definitions(&program, code, &[found]); + // Then + assert_eq!(generated.text, "const width = __try__(() => (2), 1);\n"); + Ok(()) +} + +#[test] +fn helpers_initialize_when_realm_is_fresh() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + // When + let result = initialize_helpers(&sandbox, &mut context); + // Then + assert!(result.is_ok()); + assert_eq!( + context + .eval(Source::from_bytes("__literal__({ width: 3 })")) + .map_err(|error| error.to_string())? + .as_string() + .ok_or("literal helper did not serialize value")? + .to_std_string_escaped(), + "{\"width\":3}" + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_native_syntax_error_when_binding_is_reserved() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + context + .eval(Source::from_bytes("const __failed__ = 1;")) + .map_err(|error| error.to_string())?; + let failure = initialize_helpers(&sandbox, &mut context) + .err() + .ok_or("duplicate lexical binding succeeded")?; + let original = match &failure { + Failure::Js(error) => error.to_string(), + Failure::Forbidden(_) => return Err("duplicate binding must be a native JS error".into()), + }; + assert!(original.starts_with("SyntaxError:"), "{original}"); + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn setup_failure_preserves_forbidden_read_provenance() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("Date.now()")) + .err() + .ok_or("forbidden clock read succeeded")?; + let original = match &failure { + Failure::Forbidden(violations) => violations[0].error().to_string(), + Failure::Js(_) => return Err("clock read must preserve forbidden evidence".into()), + }; + // When + let errors = setup_failure(failure); + // Then + assert_eq!( + errors, + vec![( + 0, + format!("{original}; report internal evaluation setup failure") + )] + ); + Ok(()) +} + +#[test] +fn ordinary_js_failure_leaves_computation_at_runtime() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let failure = sandbox + .run_source(&mut context, Source::from_bytes("null.value")) + .err() + .ok_or("native TypeError missing")?; + // When + let result = stop(failure, &[], "null.value"); + // Then + assert!(matches!(result, Ok(None))); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_sites.rs b/libs/extractor/src/evaluation_sandbox_sites.rs index 73730357c..454af45b1 100644 --- a/libs/extractor/src/evaluation_sandbox_sites.rs +++ b/libs/extractor/src/evaluation_sandbox_sites.rs @@ -188,10 +188,8 @@ pub(crate) fn instrument(source: &str, path: &str) -> Instrumented { let mut hash = rustc_hash::FxHasher::default(); source.hash(&mut hash); path.hash(&mut hash); - let mut helper = format!("__devup_read_site_{:x}__", hash.finish()); - while source.contains(&helper) { - helper.push('_'); - } + let helper = + crate::fresh_name::fresh_name(&format!("__devup_read_site_{:x}__", hash.finish()), source); let mut reads = Reads { scoping: &scoping, source, @@ -258,3 +256,7 @@ impl Instrumented { .join("\n") } } + +#[cfg(test)] +#[path = "evaluation_sandbox_sites_ci_tests.rs"] +mod ci_tests; diff --git a/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs new file mode 100644 index 000000000..bcdb01439 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_ci_tests.rs @@ -0,0 +1,26 @@ +use oxc_ast_visit::Visit; + +use super::Reads; + +#[test] +fn unannotated_identifier_is_preserved_without_an_invented_read_site() { + // Given + let source = "unknown"; + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, source, oxc_span::SourceType::default()).parse(); + let scoping = oxc_semantic::Scoping::default(); + let mut reads = Reads { + scoping: &scoping, + source, + helper: "read", + changes: Vec::new(), + sites: Vec::new(), + path: "unannotated.js", + }; + // When + reads.visit_program(&parsed.program); + // Then + assert_eq!(reads.changes, vec![]); + assert_eq!(reads.sites, vec![]); +} diff --git a/libs/extractor/src/evaluation_sandbox_tests.rs b/libs/extractor/src/evaluation_sandbox_tests.rs index 9abd5c3f5..3f7cc425d 100644 --- a/libs/extractor/src/evaluation_sandbox_tests.rs +++ b/libs/extractor/src/evaluation_sandbox_tests.rs @@ -48,6 +48,9 @@ fn reads(script: &str) -> Result, String> { } } +#[path = "evaluation_sandbox_tests_ci.rs"] +mod ci_tests; + #[test] fn deterministic_computation_and_console_calls_run() -> Result<(), String> { assert_eq!( diff --git a/libs/extractor/src/evaluation_sandbox_tests_ci.rs b/libs/extractor/src/evaluation_sandbox_tests_ci.rs new file mode 100644 index 000000000..f99f74a5a --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_tests_ci.rs @@ -0,0 +1,29 @@ +use rstest::rstest; + +use super::{reads, text}; + +#[test] +fn text_rejects_javascript_failure_instead_of_reporting_a_value() { + // Given / When + let result = text("throw new TypeError('fixture failure')"); + // Then + assert!(result.is_err()); +} + +#[test] +fn text_rejects_forbidden_read_even_when_script_catches_it() { + // Given / When + let result = text("try { Date.now() } catch {} 'caught'"); + // Then + assert!(result.is_err()); +} + +#[rstest] +#[case("throw new TypeError('fixture failure')")] +#[case("Math.max(1, 2)")] +fn reads_rejects_runs_without_forbidden_evidence(#[case] script: &str) { + // Given / When + let result = reads(script); + // Then + assert!(result.is_err()); +} diff --git a/libs/extractor/src/fresh_name.rs b/libs/extractor/src/fresh_name.rs new file mode 100644 index 000000000..4ffa16d34 --- /dev/null +++ b/libs/extractor/src/fresh_name.rs @@ -0,0 +1,38 @@ +//! Source-safe names for generated instrumentation helpers. + +pub(crate) fn fresh_name(base: &str, source: &str) -> String { + let mut candidate = base.to_string(); + while source.contains(&candidate) { + candidate.push('_'); + } + candidate +} + +#[cfg(test)] +mod tests { + use super::fresh_name; + use rstest::rstest; + + #[rstest] + #[case("__devup_read_site_123__")] + #[case("__devup_operation_site_123__")] + fn chooses_unused_suffix_when_source_contains_collisions(#[case] base: &str) { + // Given: valid JavaScript with the base and two occupied suffixes. + let source = format!("const {base} = 1, {base}_ = 2, {base}__ = 3;"); + // When + let chosen = fresh_name(base, &source); + // Then + assert_eq!(chosen, format!("{base}___")); + assert!(!source.contains(&chosen)); + } + + #[test] + fn preserves_base_when_source_has_no_collision() { + // Given + let source = "const value = 1;"; + // When + let chosen = fresh_name("__helper__", source); + // Then + assert_eq!(chosen, "__helper__"); + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b1416e037..76e10f8bf 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -12,6 +12,7 @@ mod diagnostics_tests; mod evaluation_sandbox; pub mod extract_style; mod extractor; +mod fresh_name; mod gen_class_name; mod gen_style; mod import_alias_visit; diff --git a/libs/extractor/src/module_loader/operations.rs b/libs/extractor/src/module_loader/operations.rs index a8b864b66..ed120ba53 100644 --- a/libs/extractor/src/module_loader/operations.rs +++ b/libs/extractor/src/module_loader/operations.rs @@ -70,10 +70,10 @@ impl Operations { pub(crate) fn new(source: &str) -> Self { let mut hash = rustc_hash::FxHasher::default(); source.hash(&mut hash); - let mut helper = format!("__devup_operation_site_{:x}__", hash.finish()); - while source.contains(&helper) { - helper.push('_'); - } + let helper = crate::fresh_name::fresh_name( + &format!("__devup_operation_site_{:x}__", hash.finish()), + source, + ); let allocator = oxc_allocator::Allocator::default(); let parsed = oxc_parser::Parser::new(&allocator, source, SourceType::default()).parse(); let mut arrays = Arrays { From 920d8ad40a60cdf5301b22b4241043f03af54a98 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 07:09:14 +0900 Subject: [PATCH 08/21] chore(wasm): record located diagnostics patch changepack Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_located_diagnostics.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changepacks/changepack_log_located_diagnostics.json diff --git a/.changepacks/changepack_log_located_diagnostics.json b/.changepacks/changepack_log_located_diagnostics.json new file mode 100644 index 000000000..6ddd128ba --- /dev/null +++ b/.changepacks/changepack_log_located_diagnostics.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, prove imported module effects before exact stylesheet computation, and prevent placeholder-name collisions from losing styles.", + "date": "2026-10-01T00:00:00.000Z" +} From cc0aaf60e0a8eda15fa77d5d988dc0f78961b2b5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 07:51:30 +0900 Subject: [PATCH 09/21] test(extractor): cover stylesheet precompute errors and import proof graphs Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/ci_coverage_tests.rs | 13 ++-- .../src/stylesheet_policy/coverage_tests.rs | 32 ++++++++ .../src/stylesheet_policy_import_tests.rs | 76 +++++++++++++++++++ 3 files changed, 116 insertions(+), 5 deletions(-) diff --git a/libs/extractor/src/ci_coverage_tests.rs b/libs/extractor/src/ci_coverage_tests.rs index af26900c4..1d9a0e124 100644 --- a/libs/extractor/src/ci_coverage_tests.rs +++ b/libs/extractor/src/ci_coverage_tests.rs @@ -1,18 +1,21 @@ use crate::{ExtractOption, extract}; -#[test] +#[rstest::rstest] +#[case("computed.tsx")] +#[case("computed.css.ts")] #[serial_test::serial] -fn computed_style_guard_error_names_original_read_location() --> Result<(), Box> { +fn computed_style_guard_error_names_original_read_location( + #[case] filename: &str, +) -> Result<(), Box> { // Given let code = "import { css } from '@devup-ui/react';\nconst compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value;\nexport const a = css({ w: compare('a', 'b') });"; // When - let error = extract("computed.tsx", code, ExtractOption::default()) + let error = extract(filename, code, ExtractOption::default()) .err() .ok_or("forbidden style unexpectedly extracted")?; // Then let error = error.to_string(); - assert!(error.starts_with("computed.tsx:2:17:"), "{error}"); + assert!(error.starts_with(&format!("{filename}:2:17:")), "{error}"); assert!(error.contains("String.prototype.localeCompare"), "{error}"); assert!( error.contains("Fix: use a literal or a CSS variable"), diff --git a/libs/extractor/src/stylesheet_policy/coverage_tests.rs b/libs/extractor/src/stylesheet_policy/coverage_tests.rs index 85a5a247c..a5c20f266 100644 --- a/libs/extractor/src/stylesheet_policy/coverage_tests.rs +++ b/libs/extractor/src/stylesheet_policy/coverage_tests.rs @@ -92,3 +92,35 @@ fn joined_style_values_require_data_in_every_branch( // Then assert_eq!(actual, expected, "{expression}"); } + +#[rstest] +#[case("export default class Card {}")] +#[case("export default class { static color = 'red'; }")] +fn default_exported_classes_require_evaluation(#[case] declaration: &str) { + // Given + let code = format!( + "import {{ css }} from '@devup-ui/react'; export const rule = css({{ color: 'red' }}); {declaration}" + ); + // When + let actual = plan( + &code, + "class.css.js", + &ExtractOption::default(), + None, + &|_| false, + ); + // Then + assert_eq!(actual, Plan::Run); +} + +#[rstest] +#[case("boundary.css.ts", true)] +#[case("boundary.css.unsupported", false)] +fn import_proof_requires_a_supported_source_type(#[case] filename: &str, #[case] expected: bool) { + // Given + let code = "import { css } from '@devup-ui/react'; export const rule = css({ color: 'red' });"; + // When + let actual = super::imports_plain(code, filename, &ExtractOption::default(), None); + // Then + assert_eq!(actual, expected); +} diff --git a/libs/extractor/src/stylesheet_policy_import_tests.rs b/libs/extractor/src/stylesheet_policy_import_tests.rs index afe4e078e..b19ef3693 100644 --- a/libs/extractor/src/stylesheet_policy_import_tests.rs +++ b/libs/extractor/src/stylesheet_policy_import_tests.rs @@ -111,3 +111,79 @@ fn a_transitive_effect_keeps_literal_imports_on_the_evaluated_path() { // Then assert_eq!(actual, Plan::Run); } + +#[rstest::rstest] +#[case::diamond(false, Plan::Plain)] +#[case::cycle(true, Plan::Run)] +fn import_graph_requires_a_completed_proof_before_reusing_a_module( + #[case] cycle: bool, + #[case] expected: Plan, +) { + // Given + let resolver = move |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./left", "/src/card.css.ts" | "/src/shared.ts") => ( + "/src/left.ts", + "import { RED } from './shared'; export const LEFT = RED;", + ), + ("./right", "/src/card.css.ts") => ( + "/src/right.ts", + "import { RED } from './shared'; export const RIGHT = RED;", + ), + ("./shared", "/src/left.ts" | "/src/right.ts") => ( + "/src/shared.ts", + if cycle { + "import './left'; export const RED = 'red';" + } else { + "export const RED = 'red';" + }, + ), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + let source = "import { css } from '@devup-ui/react'; import { LEFT } from './left'; import { RIGHT } from './right'; export const card = css({ color: LEFT, backgroundColor: RIGHT });"; + // When + let actual = plan( + source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + &|_| false, + ); + // Then + assert_eq!(actual, expected); +} + +#[rstest::rstest] +#[case::unchanged("", Plan::Plain)] +#[case::changed("tokens.color = 'blue';", Plan::Run)] +fn imported_data_requires_evaluation_when_the_root_changes_it( + #[case] mutation: &str, + #[case] expected: Plan, +) { + // Given + let resolver = |specifier: &str, importer: &str| { + assert_eq!((specifier, importer), ("./tokens", "/src/card.css.ts")); + Some(ResolvedModule { + path: "/src/tokens.ts".to_string(), + code: "export const tokens = { color: 'red' };".to_string(), + }) + }; + let source = format!( + "import {{ css }} from '@devup-ui/react'; import {{ tokens }} from './tokens'; {mutation} export const card = css(tokens);" + ); + // When + let actual = plan( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + &|_| false, + ); + // Then + assert_eq!(actual, expected); +} From 3dc297cafd1beca0457987a3b7f17043af04c828 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 08:51:29 +0900 Subject: [PATCH 10/21] fix(extractor): match import packages at path boundaries Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_located_diagnostics.json | 2 +- libs/extractor/src/barrel.rs | 5 +- libs/extractor/src/build_time_values.rs | 2 +- .../src/build_time_values_execute.rs | 3 +- libs/extractor/src/css_prop.rs | 8 +- libs/extractor/src/imported_constants.rs | 12 +- ...ported_constants_package_boundary_tests.rs | 49 ++++ libs/extractor/src/lib.rs | 1 + libs/extractor/src/module_loader.rs | 2 +- libs/extractor/src/package_specifier.rs | 227 ++++++++++++++++++ 10 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 libs/extractor/src/imported_constants_package_boundary_tests.rs create mode 100644 libs/extractor/src/package_specifier.rs diff --git a/.changepacks/changepack_log_located_diagnostics.json b/.changepacks/changepack_log_located_diagnostics.json index 6ddd128ba..9b776aa65 100644 --- a/.changepacks/changepack_log_located_diagnostics.json +++ b/.changepacks/changepack_log_located_diagnostics.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, prove imported module effects before exact stylesheet computation, and prevent placeholder-name collisions from losing styles.", + "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, prove imported module effects before exact stylesheet computation, and prevent placeholder-name collisions from losing styles. Match import package names only at exact or slash-delimited boundaries so sibling data packages remain external.", "date": "2026-10-01T00:00:00.000Z" } diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 52062045f..29bc26b23 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -294,10 +294,7 @@ struct Walker<'r, 'p> { impl Walker<'_, '_> { fn is_package(&self, source: &str) -> bool { - source == self.package - || source - .strip_prefix(self.package) - .is_some_and(|rest| rest.starts_with('/')) + crate::package_specifier::is_package(source, self.package) } fn module(&mut self, specifier: &str, importer: &str) -> Option> { diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index a04f1b840..56aacd6e5 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -85,7 +85,7 @@ pub(crate) fn has_build_time_values( let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( &program, - &|source| source.starts_with(option.package.as_str()), + &|source| crate::package_specifier::is_package(source, &option.package), &inlined.unknown, &|name| changes.is_changed(name), &|name| changes.known(name), diff --git a/libs/extractor/src/build_time_values_execute.rs b/libs/extractor/src/build_time_values_execute.rs index d7b768e44..88fb78ecc 100644 --- a/libs/extractor/src/build_time_values_execute.rs +++ b/libs/extractor/src/build_time_values_execute.rs @@ -102,7 +102,8 @@ pub(super) fn compute( return Ok(None); }; let is_style = |source: &str| { - source.starts_with(option.package.as_str()) || option.import_aliases.contains_key(source) + crate::package_specifier::is_package(source, &option.package) + || option.import_aliases.contains_key(source) }; let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); let found = find( diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs index 2f59dd1d2..6a1e21b6d 100644 --- a/libs/extractor/src/css_prop.rs +++ b/libs/extractor/src/css_prop.rs @@ -274,11 +274,9 @@ pub(crate) fn may_take_css_prop(code: &str) -> bool { /// Whether importing `source` shows a file uses Emotion pub(crate) fn is_emotion(source: &str) -> bool { - [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { - source - .strip_prefix(package) - .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) - }) + [EMOTION_REACT, "@emotion/styled"] + .iter() + .any(|package| crate::package_specifier::is_package(source, package)) } /// Where the JSX pragma in the comment text `comment` names `@emotion/react` diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 661669134..e539cd2ce 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -384,7 +384,7 @@ pub(crate) fn inline_constants<'a>( } fn is_style_package(option: &ExtractOption, source: &str) -> bool { - source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE + crate::package_specifier::is_package(source, &option.package) || source == crate::STYLEX_PACKAGE } fn inline_in<'a>( @@ -1191,7 +1191,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { self.style_imports.contains(name) || self.imports.get(name).is_some_and(|(source, _)| { source != crate::STYLEX_PACKAGE - && (source.starts_with(modules.option.package.as_str()) + && (crate::package_specifier::is_package(source, &modules.option.package) || modules.option.import_aliases.contains_key(source)) }) } @@ -1210,7 +1210,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { return None; }; - if export != "css" || !source.starts_with(modules.option.package.as_str()) { + if export != "css" || !crate::package_specifier::is_package(source, &modules.option.package) + { return None; } let [argument] = call.arguments.as_slice() else { @@ -1250,7 +1251,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { || self.style_names.contains(name) || self.imports.get(name).is_some_and(|(source, _)| { source == crate::STYLEX_PACKAGE - || source.starts_with(option.package.as_str()) + || crate::package_specifier::is_package(source, &option.package) || option.import_aliases.contains_key(source) }) } @@ -2322,6 +2323,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } } +#[cfg(test)] +#[path = "imported_constants_package_boundary_tests.rs"] +mod package_boundary_tests; #[cfg(test)] mod scope_tests; #[cfg(test)] diff --git a/libs/extractor/src/imported_constants_package_boundary_tests.rs b/libs/extractor/src/imported_constants_package_boundary_tests.rs new file mode 100644 index 000000000..6d88acf31 --- /dev/null +++ b/libs/extractor/src/imported_constants_package_boundary_tests.rs @@ -0,0 +1,49 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{Expression, Statement}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::{ModuleScope, Modules}; +use crate::ExtractOption; + +#[rstest] +#[case("@devup-ui/react-values", false)] +#[case("@devup-ui/react", true)] +#[case("@devup-ui/react/styles", true)] +#[case("@devup-ui/react/compat", true)] +fn package_boundary_css_styles_only_reads_api_exports( + #[case] source: &str, + #[case] expected: bool, +) { + // Given + let allocator = Allocator::default(); + let code = format!("import {{css}} from '{source}';css({{color:'blue'}});"); + let program = Parser::new(&allocator, &code, SourceType::tsx()) + .parse() + .program; + let mut scope = ModuleScope::new("/helper.ts", &program, None); + let Statement::ImportDeclaration(import) = &program.body[0] else { + panic!("import fixture"); + }; + scope.import(import); + let Statement::ExpressionStatement(statement) = &program.body[1] else { + panic!("call fixture"); + }; + let Expression::CallExpression(call) = &statement.expression else { + panic!("call fixture"); + }; + let option = ExtractOption::default(); + let mut modules = Modules { + resolver: None, + option: &option, + exports: Default::default(), + loading: vec![], + }; + // When + let styles = scope.css_styles(&mut modules, call); + // Then + assert_eq!(styles.is_some(), expected); + assert_eq!(scope.is_style_api(&modules, &call.callee), expected); + assert_eq!(scope.is_style_import(&option, "css"), expected); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 76e10f8bf..14b879ed0 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -20,6 +20,7 @@ mod imported_constants; mod module_loader; pub mod module_reference; mod mutations; +mod package_specifier; mod prop_modify_utils; mod prop_valid; mod scope; diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index a3a9348c4..cd573238e 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -369,7 +369,7 @@ pub(crate) fn module_script( let source = import.source.value.as_str(); let specifiers = import.specifiers.as_deref().map_or(&[][..], |s| s); if specifiers.is_empty() { - if entry && !source.starts_with(package.as_str()) { + if entry && !crate::package_specifier::is_package(source, &package) { loader.keep_import(source); } continue; diff --git a/libs/extractor/src/package_specifier.rs b/libs/extractor/src/package_specifier.rs new file mode 100644 index 000000000..a3a12ebe4 --- /dev/null +++ b/libs/extractor/src/package_specifier.rs @@ -0,0 +1,227 @@ +/// Whether an import names the package itself or one of its subpaths. +pub(crate) fn is_package(source: &str, package: &str) -> bool { + source == package + || source + .strip_prefix(package) + .is_some_and(|rest| rest.starts_with('/')) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serial_test::serial; + + use crate::{ExtractOption, ExtractStyleValue, ResolvedModule, extract, extract_with_modules}; + + #[rstest] + #[case("@devup-ui/react", "@devup-ui/react", true)] + #[case("@devup-ui/react/compat", "@devup-ui/react", true)] + #[case("@devup-ui/react/", "@devup-ui/react", true)] + #[case("@devup-ui/react-values", "@devup-ui/react", false)] + #[case("@devup-ui/reactive", "@devup-ui/react", false)] + #[case("@devup-ui/react-values/colors", "@devup-ui/react", false)] + #[case("@devup-ui/reac", "@devup-ui/react", false)] + #[case("prefix/@devup-ui/react", "@devup-ui/react", false)] + #[case("@my/ui", "@my/ui", true)] + #[case("@my/ui/tokens", "@my/ui", true)] + #[case("@my/ui-tokens", "@my/ui", false)] + #[case("@my/ui?query", "@my/ui", false)] + #[case("@my/ui#fragment", "@my/ui", false)] + #[case("@my/ui\\tokens", "@my/ui", false)] + #[case("@emotion/react", "@emotion/react", true)] + #[case("@emotion/react/jsx-runtime", "@emotion/react", true)] + #[case("@emotion/react-values", "@emotion/react", false)] + #[case("@emotion/styled/base", "@emotion/styled", true)] + #[case("@emotion/styled-extra", "@emotion/styled", false)] + #[case("@stylexjs/stylex", "@stylexjs/stylex", true)] + #[case("@stylexjs/stylex/subpath", "@stylexjs/stylex", true)] + #[case("@stylexjs/stylex-extra", "@stylexjs/stylex", false)] + #[case("", "@devup-ui/react", false)] + fn package_boundary_matches_only_package_or_slash_suffix( + #[case] source: &str, + #[case] package: &str, + #[case] expected: bool, + ) { + // Given a source and package, when comparing, then require an exact or slash-delimited boundary. + assert_eq!(super::is_package(source, package), expected); + } + + #[rstest] + #[case(("@devup-ui/react", "@devup-ui/react-values"))] + #[case(("@my/ui", "@my/ui-tokens"))] + #[serial] + fn package_boundary_sibling_values_are_static( + #[case] (package, sibling): (&'static str, &'static str), + #[values(("/shared.css.ts", "css({ color: value })"), ("/shared.tsx", "css({ color: value })"), ("/shared.tsx", ""))] + (filename, consumer): (&str, &str), + ) -> Result<(), Box> { + // Given + let imported = if consumer.starts_with('<') { + "Box" + } else { + "css" + }; + let source = format!( + "import {{{imported}}} from '{package}';import {{value}} from '{sibling}';export const result={consumer};" + ); + let resolver = move |specifier: &str, _: &str| { + (specifier == sibling).then(|| ResolvedModule { + path: "/values.ts".to_string(), + code: "export const value='red';".to_string(), + }) + }; + let option = ExtractOption { + package: package.to_string(), + ..ExtractOption::default() + }; + // When + let output = extract_with_modules(filename, &source, option, false, &resolver)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "color" && style.value == "red" + )), "{:?}", output.styles); + assert!(!output.code.contains("--"), "{}", output.code); + assert_eq!(output.dependencies, vec!["/values.ts"]); + Ok(()) + } + + #[rstest] + #[case(("@devup-ui/react", "@devup-ui/react"))] + #[case(("@devup-ui/react", "@devup-ui/react/styles"))] + #[case(("@devup-ui/react", "@devup-ui/react/compat"))] + #[case(("@my/ui", "@my/ui"))] + #[case(("@my/ui", "@my/ui/styles"))] + #[case(("@my/ui", "@my/ui/compat"))] + #[serial] + fn package_boundary_real_api_computes_styles( + #[case] (package, api): (&str, &str), + ) -> Result<(), Box> { + // Given + let source = format!( + "import {{css}} from '{api}';const twice=(n)=>n*2;export const result=css({{width:twice(3)}});" + ); + let option = ExtractOption { + package: package.to_string(), + ..ExtractOption::default() + }; + // When + let output = extract("/control.tsx", &source, option)?; + // Then + let extracts = api == package || api == format!("{package}/compat"); + assert_eq!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property == "width" && style.value == "24px" + )), extracts, "{:?}", output.styles); + Ok(()) + } + + #[rstest] + #[case("@devup-ui/react-values", false)] + #[case("@devup-ui/react", true)] + #[case("@devup-ui/react/styles", true)] + #[case("@devup-ui/react/compat", true)] + fn package_boundary_external_css_is_not_precomputed( + #[case] source: &str, + #[case] expected: bool, + ) { + // Given + let code = format!( + "import {{css}} from '{source}';const twice=(n)=>n*2;export const result=css({{width:twice(3)}});" + ); + // When + let computed = crate::build_time_values::has_build_time_values( + "/control.tsx", + &code, + &ExtractOption::default(), + None, + ); + // Then + assert_eq!(computed, expected); + } + + #[test] + fn package_boundary_external_css_arguments_are_left_untouched() { + // Given + let code = "import {css} from '@devup-ui/react-values';const twice=(n)=>n*2;export const result=css({width:twice(3)});"; + // When + let computed = crate::build_time_values::evaluate_located( + code, + "/control.tsx", + &ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .unwrap_or_else(|error| panic!("{error:?}")); + // Then + assert_eq!(computed, None); + } + + #[rstest] + #[case("@devup-ui/react-values", true)] + #[case("@devup-ui/react", false)] + #[case("@devup-ui/react/styles", false)] + #[case("@devup-ui/react/compat", false)] + fn package_boundary_side_effect_import_is_kept(#[case] source: &str, #[case] expected: bool) { + // Given + let option = ExtractOption::default(); + let code = format!("import '{source}';export const value='red';"); + let unit = crate::module_loader::Unit::generated("/entry.css.ts", &code); + let mut loader = crate::module_loader::ModuleLoader::new(None, &option); + // When + crate::module_loader::module_script(&unit, &mut loader, true) + .unwrap_or_else(|error| panic!("{error}")); + // Then + assert_eq!( + loader.kept_imports, + if expected { vec![source] } else { vec![] } + ); + } + + #[test] + #[serial] + fn package_boundary_sibling_css_export_is_not_stolen() -> Result<(), Box> + { + // Given + let source = "import {Box} from '@devup-ui/react';import {rules} from './helper';export const result=;"; + let resolver = |specifier: &str, _: &str| { + match specifier { + "./helper" => Some(ResolvedModule { + path: "/helper.ts".to_string(), + code: "import {css} from '@devup-ui/react-values';export const rules=css({color:'blue'});".to_string(), + }), + "@devup-ui/react-values" => Some(ResolvedModule { + path: "/values.ts".to_string(), + code: "export function css(rules){return 'external-'+rules.color;}".to_string(), + }), + _ => None, + } + }; + // When + let output = extract_with_modules( + "/entry.tsx", + source, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!(output.styles.len(), 1, "{:?}", output.styles); + assert!(output.styles.iter().any(|style| matches!(style, ExtractStyleValue::Static(style) if style.property == "color" && style.value == "green"))); + assert!(output.code.contains("rules"), "{}", output.code); + Ok(()) + } + + #[rstest] + #[case("@emotion/react", true)] + #[case("@emotion/react/jsx-runtime", true)] + #[case("@emotion/styled", true)] + #[case("@emotion/styled/base", true)] + #[case("@emotion/react-values", false)] + #[case("@emotion/styled-extra", false)] + fn package_boundary_emotion_recognition_is_unchanged( + #[case] source: &str, + #[case] expected: bool, + ) { + // Given an import specifier, when detecting Emotion, then only its package or subpaths qualify. + assert_eq!(crate::css_prop::is_emotion(source), expected); + } +} From 042d6a69c3c88b827eba467dee7b3b56c6312a1f Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 14:00:58 +0900 Subject: [PATCH 11/21] fix(extractor): preserve evaluated helper effects and opaque CSS reads Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/evaluation_sandbox.rs | 101 ++---- libs/extractor/src/evaluation_sandbox_css.rs | 154 +++++++++ .../src/evaluation_sandbox_css_tests.rs | 91 ++++++ .../src/evaluation_sandbox_evidence.rs | 54 ++++ .../src/evaluation_sandbox_guards.rs | 73 ++--- .../src/evaluation_sandbox_reflection.rs | 267 +++++++++++++++ .../evaluation_sandbox_reflection_tests.rs | 2 +- .../extractor/src/evaluation_sandbox_sites.rs | 4 + .../evaluation_sandbox_sites_operations.rs | 59 ++++ libs/extractor/src/module_loader.rs | 139 +++++--- .../src/module_loader/import_bindings.rs | 83 +++++ .../src/module_loader/retained_css.rs | 174 ++++++++++ .../src/module_loader/script/explain.rs | 3 + .../src/module_loader/script/explain/tests.rs | 22 ++ .../src/module_loader/script/tests.rs | 73 +++++ libs/extractor/src/module_loader/tests.rs | 4 + .../src/module_loader/tests/css_elision.rs | 172 ++++++++++ .../module_loader/tests/css_observations.rs | 191 +++++++++++ .../src/module_loader/tests/css_paths.rs | 71 ++++ .../src/module_loader/tests/css_reads.rs | 272 ++++++++++++++++ .../src/module_loader/tests/retained_css.rs | 263 +++++++++++++++ .../tests/side_effect_dependency.rs | 304 ++++++++++++++++++ libs/extractor/src/source_map.rs | 2 +- libs/extractor/src/source_map/trace.rs | 75 +++++ libs/extractor/src/source_map/trace/tests.rs | 29 ++ libs/extractor/src/vanilla_extract.rs | 13 +- 26 files changed, 2520 insertions(+), 175 deletions(-) create mode 100644 libs/extractor/src/evaluation_sandbox_css.rs create mode 100644 libs/extractor/src/evaluation_sandbox_css_tests.rs create mode 100644 libs/extractor/src/evaluation_sandbox_evidence.rs create mode 100644 libs/extractor/src/evaluation_sandbox_reflection.rs create mode 100644 libs/extractor/src/evaluation_sandbox_sites_operations.rs create mode 100644 libs/extractor/src/module_loader/import_bindings.rs create mode 100644 libs/extractor/src/module_loader/retained_css.rs create mode 100644 libs/extractor/src/module_loader/tests/css_elision.rs create mode 100644 libs/extractor/src/module_loader/tests/css_observations.rs create mode 100644 libs/extractor/src/module_loader/tests/css_paths.rs create mode 100644 libs/extractor/src/module_loader/tests/css_reads.rs create mode 100644 libs/extractor/src/module_loader/tests/retained_css.rs create mode 100644 libs/extractor/src/module_loader/tests/side_effect_dependency.rs diff --git a/libs/extractor/src/evaluation_sandbox.rs b/libs/extractor/src/evaluation_sandbox.rs index 49dba49a3..bcd16a3d9 100644 --- a/libs/extractor/src/evaluation_sandbox.rs +++ b/libs/extractor/src/evaluation_sandbox.rs @@ -11,7 +11,6 @@ //! [`INTERNAL_SOURCE`], so no frame of the code that runs is shifted by it. use std::cell::RefCell; -use std::path::Path; use std::rc::Rc; use boa_engine::{ @@ -38,6 +37,7 @@ enum Kind { Environment, Locale, Source, + Css, } impl Kind { @@ -48,6 +48,9 @@ impl Kind { Self::Environment => "only the page or process running the code knows it", Self::Locale => "its result depends on the locale and the engine's Unicode data", Self::Source => "its text changes when the build transforms the function", + Self::Css => { + "CSS module class names are generated by the bundler and are unknown at build time" + } } } @@ -66,14 +69,26 @@ impl Kind { #[path = "evaluation_sandbox_guards.rs"] mod guards; -use guards::{GUARDED_GLOBALS, SETUP, guard_methods}; +use guards::{SETUP, guard_methods}; + +#[path = "evaluation_sandbox_reflection.rs"] +mod reflection; +use reflection::guard_globals; #[path = "evaluation_sandbox_sites.rs"] mod sites; pub(crate) use sites::{Instrumented, instrument}; +#[path = "evaluation_sandbox_css.rs"] +mod css; +pub(crate) use css::css_object; + +#[path = "evaluation_sandbox_evidence.rs"] +mod evidence; + struct Recorded { name: String, + css: Option, error: JsValue, site: Option<(String, u32)>, } @@ -93,9 +108,15 @@ pub(crate) struct Violation { kind: Kind, error: JsError, site: Option<(String, u32)>, + css_message: Option, } impl Violation { + /// Immutable CSS cause and frames, without Boa's native display suffix. + pub(crate) fn css_message(&self) -> Option<&str> { + self.css_message.as_deref() + } + /// What was read, as the code writes it (`Date`, `String.prototype.normalize`) pub(crate) fn name(&self) -> &str { &self.name @@ -148,6 +169,7 @@ fn record(context: &mut Context, name: &str, site: Option<(String, u32)>) -> JsV if let Some(evidence) = context.get_data::() { evidence.reads.borrow_mut().push(Recorded { name: name.to_string(), + css: None, error: error.clone(), site, }); @@ -173,40 +195,6 @@ fn function(context: &Context, name: &str, native: NativeFunction) -> JsObject { .into() } -fn guard_globals(context: &mut Context) -> JsResult<()> { - let setup = context.eval(Source::from_reader( - SETUP.as_bytes(), - Some(Path::new(INTERNAL_SOURCE)), - ))?; - let forbid = function( - context, - "forbid", - NativeFunction::from_fn_ptr(forbid_native), - ); - let config = JsValue::from_json(&serde_json::json!({ "names": GUARDED_GLOBALS }), context)?; - setup - .to_object(context)? - .call(&JsValue::undefined(), &[forbid.into(), config], context)?; - for name in GUARDED_GLOBALS { - let descriptor = boa_engine::builtins::object::OrdinaryObject::get_own_property_descriptor( - &JsValue::undefined(), - &[context.global_object().into(), JsString::from(name).into()], - context, - )?; - let getter = descriptor - .to_object(context)? - .get(js_string!("get"), context)? - .to_object(context)?; - if let Some(evidence) = context.get_data::() { - evidence - .methods - .borrow_mut() - .push((getter, name.to_string())); - } - } - Ok(()) -} - /// A console whose calls do nothing, as what a module logs changes no value fn install_console(context: &mut Context) -> JsResult<()> { let target = JsObject::with_null_proto(); @@ -292,47 +280,6 @@ impl Sandbox { } } } - - /// Fails if a forbidden read was recorded. `errors` are the errors the host - /// got back from code it ran: one a read threw gives that read its call - /// frames when the code did not rethrow it first, so a host that serializes - /// the values the code made passes the errors that failed it here. - pub(crate) fn check(&self, errors: &[&JsError]) -> Result<(), Failure> { - let recorded = self.evidence.reads.borrow(); - if recorded.is_empty() { - return Ok(()); - } - Err(Failure::Forbidden( - recorded - .iter() - .map(|read| Violation { - kind: Kind::of(&read.name), - name: read.name.clone(), - site: read.site.clone(), - error: match &read.site { - Some((place, _)) => JsNativeError::reference() - .with_message(format!( - "`{}` cannot be read at build time: {}\n at ({place})", - read.name, - Kind::of(&read.name).requirement() - )) - .into(), - None => errors - .iter() - .find(|error| { - error - .as_opaque() - .is_some_and(|thrown| thrown.strict_equals(&read.error)) - }) - .map_or_else( - || JsError::from_opaque(read.error.clone()), - |error| (*error).clone(), - ), - }, - }) - .collect(), - )) - } } #[cfg(test)] diff --git a/libs/extractor/src/evaluation_sandbox_css.rs b/libs/extractor/src/evaluation_sandbox_css.rs new file mode 100644 index 000000000..3a83dace8 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css.rs @@ -0,0 +1,154 @@ +//! Native observations of bundler-owned CSS objects; no export value is fabricated. + +use boa_engine::{ + Context, JsArgs, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, +}; + +use super::{Evidence, Kind, Recorded}; + +pub(super) struct Observation { + pub(super) file: String, + pub(super) error: JsError, + pub(super) message: String, +} + +#[derive(Clone, Copy)] +enum Operation { + Get, + Has, + Keys, + Descriptor, + Prototype, +} + +impl Operation { + fn key(self, args: &[JsValue], context: &mut Context) -> JsResult { + let prefix = match self { + Self::Get => "", + Self::Has => "has:", + Self::Descriptor => "getOwnPropertyDescriptor:", + Self::Keys => return Ok("ownKeys".to_string()), + Self::Prototype => return Ok("getPrototypeOf".to_string()), + }; + let value = args.get_or_undefined(1); + let key = match value.as_symbol() { + Some(symbol) => symbol.descriptive_string().to_std_string_escaped(), + None => value.to_string(context)?.to_std_string_escaped(), + }; + Ok(format!("{prefix}{key}")) + } +} + +struct Request<'a> { + file: &'a str, + args: &'a [JsValue], + operation: Operation, +} + +pub(super) fn message(file: &str, key: &str) -> String { + format!( + "Cannot read CSS export '{key}' of '{file}' at build time: {}", + Kind::Css.requirement() + ) +} + +fn observe(request: Request<'_>, context: &mut Context) -> JsResult { + let Some(evidence) = context.get_data::().cloned() else { + return Err(JsNativeError::error() + .with_message("CSS observation requires a sandbox") + .into()); + }; + let file = request.file; + let name = request.operation.key(request.args, context)?; + let site = evidence.site.borrow().clone(); + let frame = site.as_ref().map_or_else(String::new, |(place, _)| { + format!("\n at ({place})") + }); + let frames = context + .stack_trace() + .enumerate() + .filter_map(|(index, frame)| { + let location = frame.position(); + if index == 0 + && let Some((place, _)) = &site + { + return Some(format!( + "\n at {} ({place})", + location.function_name.to_std_string_escaped() + )); + } + let position = location.position?; + Some(format!( + "\n at {} ({}:{}:{})", + location.function_name.to_std_string_escaped(), + location.path, + position.line_number(), + position.column_number() + )) + }) + .collect::(); + let immutable: JsError = JsNativeError::reference() + .with_message(format!("{}{frame}{frames}", message(file, &name))) + .into(); + let message = format!("ReferenceError: {}{frame}{frames}", message(file, &name)); + let error = immutable.clone().into_opaque(context)?; + let mut reads = evidence.reads.borrow_mut(); + if !reads + .iter() + .any(|read| read.css.as_ref().is_some_and(|css| css.file == file) && read.site == site) + { + reads.push(Recorded { + name, + css: Some(Observation { + file: file.to_string(), + error: immutable, + message, + }), + error: error.clone(), + site, + }); + } + Err(JsError::from_opaque(error)) +} + +/// Creates an opaque object whose actual runtime traps record unknown observations. +pub(crate) fn css_object(context: &mut Context, file: &str) -> JsResult { + let target = JsObject::with_null_proto(); + let handler = JsObject::with_null_proto(); + for (name, operation) in [ + ("get", Operation::Get), + ("has", Operation::Has), + ("ownKeys", Operation::Keys), + ("getOwnPropertyDescriptor", Operation::Descriptor), + ("getPrototypeOf", Operation::Prototype), + ] { + let native = NativeFunction::from_copy_closure_with_captures( + move |_, args, file, context| { + observe( + Request { + file, + args, + operation, + }, + context, + ) + }, + file.to_string(), + ); + handler.create_data_property_or_throw( + JsString::from(name), + super::function(context, name, native), + context, + )?; + } + context + .intrinsics() + .constructors() + .proxy() + .constructor() + .construct(&[target.into(), handler.into()], None, context) +} + +#[cfg(test)] +#[path = "evaluation_sandbox_css_tests.rs"] +mod tests; diff --git a/libs/extractor/src/evaluation_sandbox_css_tests.rs b/libs/extractor/src/evaluation_sandbox_css_tests.rs new file mode 100644 index 000000000..8dc7afa69 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_css_tests.rs @@ -0,0 +1,91 @@ +use boa_engine::{Context, Source, js_string}; + +use super::super::{Failure, Sandbox}; + +#[test] +fn css_observation_remains_fatal_when_host_serializes_after_evaluation() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("no CSS value is invented")?; + // When + let Err(Failure::Forbidden(reads)) = sandbox.check(&[&error]) else { + return Err("host read must be a fatal CSS observation".to_string()); + }; + // Then + assert_eq!(reads.len(), 1); + assert!( + reads[0] + .css_message() + .is_some_and(|message| message.contains("export 'card' of '/src/styles.module.css'")) + ); + assert_eq!(reads[0].site(), None); + Ok(()) +} + +#[test] +fn css_observation_reports_context_boundary_when_proxy_is_used_without_sandbox() +-> Result<(), String> { + // Given + let mut context = Context::default(); + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + // When + let error = object + .get(js_string!("card"), &mut context) + .err() + .ok_or("a host observer needs its sandbox")?; + // Then + assert!( + error + .to_string() + .contains("CSS observation requires a sandbox") + ); + Ok(()) +} + +#[test] +fn css_observation_is_immutable_when_caught_error_prototype_is_replaced() -> Result<(), String> { + // Given + let mut context = Context::default(); + let sandbox = Sandbox::new(&mut context).map_err(|error| error.to_string())?; + let object = super::css_object(&mut context, "/src/styles.module.css") + .map_err(|error| error.to_string())?; + context + .register_global_property( + js_string!("styles"), + object, + boa_engine::property::Attribute::empty(), + ) + .map_err(|error| error.to_string())?; + let source = super::super::instrument( + "try{styles.card}catch(error){error.message='mutated';ReferenceError.prototype.name='Changed';}try{styles.other}catch{}", + "fixture.js", + ); + sandbox + .prepare(&mut context, &source) + .map_err(|error| error.to_string())?; + // When + let Err(Failure::Forbidden(reads)) = + sandbox.run_source(&mut context, Source::from_bytes(&source.code)) + else { + return Err("caught errors cannot erase host evidence".to_string()); + }; + // Then + assert_eq!(reads.len(), 2); + assert!(reads[0].css_message().is_some_and(|message| { + message.starts_with("ReferenceError: Cannot read CSS export 'card'") + })); + assert!( + !reads[0] + .css_message() + .unwrap_or_default() + .contains("mutated") + ); + Ok(()) +} diff --git a/libs/extractor/src/evaluation_sandbox_evidence.rs b/libs/extractor/src/evaluation_sandbox_evidence.rs new file mode 100644 index 000000000..3ab6c4230 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_evidence.rs @@ -0,0 +1,54 @@ +//! Converts host-held observations into failures without trusting caught CSS errors. + +use boa_engine::{JsError, JsNativeError}; + +use super::{Failure, Kind, Sandbox, Violation}; + +impl Sandbox { + /// Fails if a forbidden read was recorded. `errors` are the errors the host + /// got back from code it ran: one a read threw gives that read its call + /// frames when the code did not rethrow it first, so a host that serializes + /// the values the code made passes the errors that failed it here. + pub(crate) fn check(&self, errors: &[&JsError]) -> Result<(), Failure> { + let recorded = self.evidence.reads.borrow(); + if recorded.is_empty() { + return Ok(()); + } + Err(Failure::Forbidden( + recorded + .iter() + .map(|read| Violation { + kind: if read.css.is_some() { + Kind::Css + } else { + Kind::of(&read.name) + }, + name: read.name.clone(), + css_message: read.css.as_ref().map(|css| css.message.clone()), + site: read.site.clone(), + error: match (&read.css, &read.site) { + (Some(css), _) => css.error.clone(), + (None, Some((place, _))) => JsNativeError::reference() + .with_message(format!( + "`{}` cannot be read at build time: {}\n at ({place})", + read.name, + Kind::of(&read.name).requirement() + )) + .into(), + (None, None) => errors + .iter() + .find(|error| { + error + .as_opaque() + .is_some_and(|thrown| thrown.strict_equals(&read.error)) + }) + .map_or_else( + || JsError::from_opaque(read.error.clone()), + |error| (*error).clone(), + ), + }, + }) + .collect(), + )) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_guards.rs b/libs/extractor/src/evaluation_sandbox_guards.rs index 080ce35bb..3b112dc05 100644 --- a/libs/extractor/src/evaluation_sandbox_guards.rs +++ b/libs/extractor/src/evaluation_sandbox_guards.rs @@ -1,5 +1,6 @@ //! What the sandbox forbids reading, and the source that guards the globals. +use super::reflection::{check_accessor, check_descriptor}; use super::{Evidence, function, record}; pub(super) const EXACT_GLOBALS: &[&str] = &[ @@ -55,7 +56,7 @@ pub(super) const EXACT_GLOBALS: &[&str] = &[ "console", ]; use boa_engine::{ - Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, js_string, + Context, JsArgs, JsError, JsObject, JsResult, JsString, JsValue, NativeFunction, object::FunctionObjectBuilder, property::PropertyDescriptor, }; @@ -97,7 +98,7 @@ fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) - JsString::from(method), PropertyDescriptor::builder() .get(guard.clone()) - .set(setter) + .set(setter.clone()) .enumerable(false) .configurable(true), context, @@ -107,6 +108,10 @@ fn guard_method(context: &mut Context, holder: &JsObject, label: (&str, &str)) - .methods .borrow_mut() .push((guard, format!("{owner}.{method}"))); + evidence + .methods + .borrow_mut() + .push((setter, format!("{owner}.{method}"))); } Ok(()) } @@ -135,11 +140,14 @@ pub(super) fn guard_methods(context: &mut Context) -> JsResult<()> { fn guard_descriptor(context: &mut Context) -> JsResult<()> { let object = context.intrinsics().constructors().object().constructor(); + let prototype = context.intrinsics().constructors().object().prototype(); let reflect = context.intrinsics().objects().reflect(); for (holder, name, batch) in [ - (&object, "getOwnPropertyDescriptor", false), - (&object, "getOwnPropertyDescriptors", true), - (&reflect, "getOwnPropertyDescriptor", false), + (&object, "getOwnPropertyDescriptor", Some(false)), + (&object, "getOwnPropertyDescriptors", Some(true)), + (&reflect, "getOwnPropertyDescriptor", Some(false)), + (&prototype, "__lookupGetter__", None), + (&prototype, "__lookupSetter__", None), ] { let original = holder .get(JsString::from(name), context)? @@ -148,22 +156,26 @@ fn guard_descriptor(context: &mut Context) -> JsResult<()> { let wrapper = FunctionObjectBuilder::new( &realm, NativeFunction::from_copy_closure_with_captures( - |this, args, captures: &(JsObject, bool), context| { + |this, args, captures: &(JsObject, Option), context| { let site = context .get_data::() .and_then(|evidence| evidence.site.borrow().clone()); let value = captures.0.call(this, args, context)?; - if captures.1 { - let descriptors = value.to_object(context)?; - for key in descriptors.own_property_keys(context)? { - check_descriptor( - &descriptors.get(key, context)?, - site.clone(), - context, - )?; + match captures.1 { + Some(true) => { + let descriptors = value.to_object(context)?; + for key in descriptors.own_property_keys(context)? { + check_descriptor( + &descriptors.get(key, context)?, + site.clone(), + context, + )?; + } + } + Some(false) => { + check_descriptor(&value, site, context)?; } - } else { - check_descriptor(&value, site, context)?; + None => check_accessor(&value, site, context)?, } Ok(value) }, @@ -171,7 +183,7 @@ fn guard_descriptor(context: &mut Context) -> JsResult<()> { ), ) .name(JsString::from(name)) - .length(if batch { 1 } else { 2 }) + .length(if batch == Some(false) { 2 } else { 1 }) .build(); holder.define_property_or_throw( JsString::from(name), @@ -186,33 +198,6 @@ fn guard_descriptor(context: &mut Context) -> JsResult<()> { Ok(()) } -fn check_descriptor( - value: &JsValue, - site: Option<(String, u32)>, - context: &mut Context, -) -> JsResult<()> { - let getter = value.as_object().and_then(|descriptor| { - descriptor - .borrow() - .properties() - .get(&js_string!("get").into()) - .and_then(|property| property.value().cloned()) - }); - let name = context.get_data::().and_then(|evidence| { - evidence.methods.borrow().iter().find_map(|(method, name)| { - getter - .as_ref()? - .as_object() - .filter(|getter| getter == method) - .map(|_| name.clone()) - }) - }); - match name { - Some(name) => Err(JsError::from_opaque(record(context, &name, site))), - None => Ok(()), - } -} - /// Globals the engine gives whose values depend on the locale or the clock; /// any other global nothing declares is the environment's pub(super) const GUARDED_GLOBALS: [&str; 5] = ["Date", "Intl", "performance", "crypto", "Temporal"]; diff --git a/libs/extractor/src/evaluation_sandbox_reflection.rs b/libs/extractor/src/evaluation_sandbox_reflection.rs new file mode 100644 index 000000000..607afe169 --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_reflection.rs @@ -0,0 +1,267 @@ +//! Identity-based protection of synthetic accessors exposed by reflection. + +use super::{ + Evidence, INTERNAL_SOURCE, SETUP, forbid_native, function, guards::GUARDED_GLOBALS, record, +}; +use boa_engine::{ + Context, JsError, JsResult, JsString, JsValue, NativeFunction, Source, js_string, +}; + +pub(super) fn guard_globals(context: &mut Context) -> JsResult<()> { + let setup = context.eval(Source::from_reader( + SETUP.as_bytes(), + Some(std::path::Path::new(INTERNAL_SOURCE)), + ))?; + let forbid = function( + context, + "forbid", + NativeFunction::from_fn_ptr(forbid_native), + ); + let config = JsValue::from_json(&serde_json::json!({ "names": GUARDED_GLOBALS }), context)?; + setup + .to_object(context)? + .call(&JsValue::undefined(), &[forbid.into(), config], context)?; + for name in GUARDED_GLOBALS { + let descriptor = boa_engine::builtins::object::OrdinaryObject::get_own_property_descriptor( + &JsValue::undefined(), + &[context.global_object().into(), JsString::from(name).into()], + context, + )? + .to_object(context)?; + for key in [js_string!("get"), js_string!("set")] { + let accessor = descriptor.get(key, context)?.to_object(context)?; + if let Some(evidence) = context.get_data::() { + evidence + .methods + .borrow_mut() + .push((accessor, name.to_string())); + } + } + } + Ok(()) +} + +pub(super) fn check_descriptor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + if let Some(descriptor) = value.as_object() { + let getter = descriptor + .borrow() + .properties() + .get(&js_string!("get").into()) + .and_then(|property| property.value().cloned()); + if let Some(getter) = getter { + check_accessor(&getter, site, context)?; + } + } + Ok(()) +} + +pub(super) fn check_accessor( + value: &JsValue, + site: Option<(String, u32)>, + context: &mut Context, +) -> JsResult<()> { + let name = context.get_data::().and_then(|evidence| { + evidence.methods.borrow().iter().find_map(|(method, name)| { + value + .as_object() + .filter(|accessor| accessor == method) + .map(|_| name.clone()) + }) + }); + match name { + Some(name) => Err(JsError::from_opaque(record(context, &name, site))), + None => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::super::{Failure, reflection_tests::run}; + use rstest::rstest; + + #[rstest] + #[case("(()=>{}).__lookupGetter__('toString')", "Function.prototype.toString")] + #[case("Math.__lookupGetter__('random')", "Math.random")] + #[case("Math.__lookupSetter__('random')", "Math.random")] + #[case( + "const read = Object.prototype.__lookupGetter__;\nread.call(Math, 'random')", + "Math.random" + )] + #[case( + "const read = Object.prototype.__lookupSetter__.bind(Math);\nread('random')", + "Math.random" + )] + #[case( + "const key = 'random';\nMath['__lookup' + 'Getter__'](key)", + "Math.random" + )] + #[case( + "const key = { toString() { Math.max(1, 2); return 'random' } };\nMath.__lookupSetter__(key)", + "Math.random" + )] + #[case( + "const child = Object.create(Math);\nchild.__lookupGetter__('random')", + "Math.random" + )] + fn legacy_protected_accessors_fail_at_actual_call( + #[case] script: &str, + #[case] name: &str, + ) -> Result<(), String> { + // Given + let offset = script.rfind('\n').map_or(0, |at| at + 1); + // When + let Err(Failure::Forbidden(reads)) = run(script) else { + return Err("legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(offset)); + Ok(()) + } + + #[rstest] + fn legacy_global_accessors_record_before_throw_even_when_errors_are_mutated( + #[values("__lookupGetter__", "__lookupSetter__")] reader: &str, + #[values("Date", "Intl", "performance", "crypto", "Temporal")] name: &str, + ) -> Result<(), String> { + // Given + let script = format!( + "try {{ globalThis.{reader}('{name}') }} catch (error) {{ error.message = 'changed'; error.name = 'changed'; }}\nglobalThis.{name} = 7; 'accepted'" + ); + // When + let Err(Failure::Forbidden(reads)) = run(&script) else { + return Err("caught legacy reflection escaped".into()); + }; + // Then + assert_eq!(reads.len(), 1); + assert_eq!(reads[0].name(), name); + assert_eq!(reads[0].site().map(|(_, at)| at), Some(6)); + assert!(reads[0].error().to_string().contains(&format!("`{name}`"))); + assert_eq!( + reads[0].requirement(), + super::super::Kind::of(name).requirement() + ); + Ok(()) + } + + #[rstest] + #[case( + "Math.random = () => 7; String(Math.__lookupGetter__('random'))", + "undefined" + )] + #[case( + "Math.random = () => 7; String(Math.__lookupSetter__('random'))", + "undefined" + )] + #[case( + "Function.prototype.toString = () => 'own'; String((()=>{}).__lookupGetter__('toString'))", + "undefined" + )] + #[case( + "globalThis.Date = 7; String(globalThis.__lookupSetter__('Date'))", + "undefined" + )] + #[case( + "const o = { n: 3, get random() { return this.n }, set random(v) { this.n = v } }; const child = Object.create(o); child.__lookupSetter__('random').call(child, 8); String(child.__lookupGetter__('random').call(child))", + "8" + )] + #[case( + "Object.defineProperty(Math, 'random', { get() { return 4 }, set(v) {} }); String(Math.__lookupGetter__('random')())", + "4" + )] + #[case( + "Object.defineProperty(globalThis, 'Intl', { set(v) { this.saved = v } }); globalThis.__lookupSetter__('Intl').call(globalThis, 9); String(globalThis.saved)", + "9" + )] + #[case( + "String(Object.prototype.__lookupGetter__.call('a', '0'))", + "undefined" + )] + #[case("String(Object.prototype.__lookupSetter__.call(1, 'x'))", "undefined")] + #[case("String(({}).__lookupGetter__('missing'))", "undefined")] + #[case("String(({ random: 2 }).__lookupSetter__('random'))", "undefined")] + #[case( + "String(Object.prototype.__lookupGetter__.length) + ':' + Object.prototype.__lookupSetter__.length", + "1:1" + )] + #[case( + "let order = ''; const o = new Proxy({ get x() { return 2 } }, { getOwnPropertyDescriptor(t, k) { order += 'd'; return Reflect.getOwnPropertyDescriptor(t, k) } }); const key = { toString() { order += 'k'; return 'x' } }; const get = Object.prototype.__lookupGetter__.call(o, key); `${order}:${get()}`", + "kd:2" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupGetter__.call(null, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + #[case( + "let order = ''; try { Object.prototype.__lookupSetter__.call(undefined, { toString() { order += 'k'; return 'x' } }) } catch(e) { order += e instanceof TypeError ? 't' : 'e' } order", + "t" + )] + fn legacy_ordinary_accessors_preserve_native_semantics( + #[case] script: &str, + #[case] expected: &str, + ) -> Result<(), String> { + // Given / When + let value = match run(script) { + Ok(value) => value, + Err(Failure::Js(error)) => return Err(error.to_string()), + Err(Failure::Forbidden(_)) => return Err("ordinary legacy accessor forbidden".into()), + }; + // Then + assert_eq!( + value + .as_string() + .ok_or("expected string")? + .to_std_string_escaped(), + expected + ); + Ok(()) + } + + #[rstest] + #[case( + "(()=>{}).__lookupGetter__('toString')", + "Function.prototype.toString", + "()=>{}" + )] + #[case( + "Math.__lookupGetter__('random')", + "Math.random", + "Math.__lookupGetter__" + )] + #[serial_test::serial] + fn legacy_stylesheet_branches_fail_instead_of_emitting_blue_css( + #[case] read: &str, + #[case] name: &str, + #[case] callsite: &str, + ) -> Result<(), String> { + // Given + let code = format!( + "import {{style}} from '@devup-ui/react';\nexport const card=style({{color:{read}?'blue':'red'}});" + ); + let offset = code.find(callsite).ok_or("missing read")?; + // When + let error = crate::extract("legacy.css.ts", &code, crate::ExtractOption::default()) + .err() + .ok_or("legacy reflection silently emitted CSS")? + .to_string(); + // Then + assert!( + error.starts_with(&format!( + "{}:", + crate::locate("legacy.css.ts", &code, offset) + )), + "{error}" + ); + assert!(error.contains(&format!("`{name}`")), "{error}"); + assert!( + error.contains("Fix: use a literal or a CSS variable"), + "{error}" + ); + Ok(()) + } +} diff --git a/libs/extractor/src/evaluation_sandbox_reflection_tests.rs b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs index 0060b337e..0959ca7fe 100644 --- a/libs/extractor/src/evaluation_sandbox_reflection_tests.rs +++ b/libs/extractor/src/evaluation_sandbox_reflection_tests.rs @@ -5,7 +5,7 @@ use rstest::rstest; use super::{Failure, Sandbox}; -fn run(script: &str) -> Result { +pub(super) fn run(script: &str) -> Result { let mut context = Context::default(); let sandbox = Sandbox::new(&mut context).map_err(Failure::Js)?; let source = super::instrument(script, "reflection.js"); diff --git a/libs/extractor/src/evaluation_sandbox_sites.rs b/libs/extractor/src/evaluation_sandbox_sites.rs index 454af45b1..1e48e3070 100644 --- a/libs/extractor/src/evaluation_sandbox_sites.rs +++ b/libs/extractor/src/evaluation_sandbox_sites.rs @@ -7,6 +7,9 @@ use oxc_ast_visit::{Visit, walk}; use oxc_semantic::{Scoping, SemanticBuilder}; use oxc_span::{GetSpan, SourceType, Span}; +#[path = "evaluation_sandbox_sites_operations.rs"] +mod operations; + /// Source edits and read coordinates, reusable before module mapper code generation. pub(crate) struct Instrumented { pub(crate) code: String, @@ -200,6 +203,7 @@ pub(crate) fn instrument(source: &str, path: &str) -> Instrumented { }; if parsed.diagnostics.is_empty() { reads.visit_program(&parsed.program); + operations::Operations(&mut reads).visit_program(&parsed.program); } reads .changes diff --git a/libs/extractor/src/evaluation_sandbox_sites_operations.rs b/libs/extractor/src/evaluation_sandbox_sites_operations.rs new file mode 100644 index 000000000..8b4fb78aa --- /dev/null +++ b/libs/extractor/src/evaluation_sandbox_sites_operations.rs @@ -0,0 +1,59 @@ +//! Original operation positions for proxy traps without explicit member lookups. + +use oxc_ast::ast::{ + BinaryExpression, ForInStatement, SpreadElement, TemplateLiteral, UnaryExpression, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::operator::UnaryOperator; + +pub(super) struct Operations<'r, 's>(pub(super) &'r mut super::Reads<'s>); + +impl super::Reads<'_> { + fn value_site(&mut self, value: Span, operation: Span) { + let id = self.site(operation); + self.changes.push(( + Span::new(value.start, value.start), + format!("{}({id}, ", self.helper), + )); + self.changes + .push((Span::new(value.end, value.end), ")".to_string())); + } +} + +impl<'a> Visit<'a> for Operations<'_, '_> { + fn visit_binary_expression(&mut self, binary: &BinaryExpression<'a>) { + self.0.value_site(binary.right.span(), binary.span); + walk::walk_binary_expression(self, binary); + } + + fn visit_unary_expression(&mut self, unary: &UnaryExpression<'a>) { + match unary.operator { + UnaryOperator::UnaryPlus | UnaryOperator::UnaryNegation | UnaryOperator::BitwiseNot => { + self.0.value_site(unary.argument.span(), unary.span); + } + UnaryOperator::LogicalNot + | UnaryOperator::Typeof + | UnaryOperator::Void + | UnaryOperator::Delete => {} + } + walk::walk_unary_expression(self, unary); + } + + fn visit_spread_element(&mut self, spread: &SpreadElement<'a>) { + self.0.value_site(spread.argument.span(), spread.span); + walk::walk_spread_element(self, spread); + } + + fn visit_for_in_statement(&mut self, statement: &ForInStatement<'a>) { + self.0.value_site(statement.right.span(), statement.span); + walk::walk_for_in_statement(self, statement); + } + + fn visit_template_literal(&mut self, template: &TemplateLiteral<'a>) { + for expression in &template.expressions { + self.0.value_site(expression.span(), expression.span()); + } + walk::walk_template_literal(self, template); + } +} diff --git a/libs/extractor/src/module_loader.rs b/libs/extractor/src/module_loader.rs index cd573238e..f3224e4f1 100644 --- a/libs/extractor/src/module_loader.rs +++ b/libs/extractor/src/module_loader.rs @@ -12,17 +12,20 @@ use oxc_ast::ast::{ use oxc_parser::Parser; use oxc_semantic::SemanticBuilder; use oxc_span::{GetSpan, SourceType, Span}; -use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::{ExtractOption, ModuleResolver, utils::is_vanilla_extract_file}; +mod import_bindings; pub(crate) mod operations; +mod retained_css; mod script; #[cfg(test)] mod tests; mod validate; +use import_bindings::{BindingSource, ImportBindings}; +use retained_css::{CssImport, RetainedCss}; pub(crate) use script::{Body, Origin, SCRIPT_PATH, Script, Unit}; pub(crate) use validate::validate; @@ -69,6 +72,7 @@ fn is_evaluating(filename: &str) -> bool { enum LoadError { NoResolver, Unresolved, + CssPath(retained_css::PathError), /// The module's own error, told where it is already Failed(String), } @@ -78,6 +82,10 @@ impl LoadError { /// `place` gives fn describe(self, specifier: &str, importer: &str, place: impl FnOnce() -> String) -> String { match self { + Self::NoResolver | Self::Unresolved if retained_css::is_css(specifier) => format!( + "{}: Cannot resolve CSS '{specifier}' from '{importer}': the stylesheet loads CSS through this helper and the file cannot be found. Fix: correct the path or import CSS from a component module; after creating a missing file, re-save the stylesheet to retry", + place() + ), Self::NoResolver => format!( "{}: Cannot load '{specifier}' without a module resolver. Fix: build through a Devup UI plugin, which resolves imports, or write what '{specifier}' provides in this file", place() @@ -87,6 +95,7 @@ impl LoadError { place() ), Self::Failed(error) => error, + Self::CssPath(error) => format!("{}: {}", place(), error.describe()), } } } @@ -116,6 +125,8 @@ pub(crate) struct ModuleLoader<'r> { /// bindings are read when used, as ES modules read an import cycle pending: FxHashSet, next_module: usize, + retained_css: RetainedCss, + css_bindings: FxHashMap, /// Every file read, including those the loaded stylesheets read pub dependencies: BTreeSet, /// What the stylesheet imports for its side effects, and the stylesheets it @@ -124,6 +135,22 @@ pub(crate) struct ModuleLoader<'r> { } impl<'r> ModuleLoader<'r> { + /// Instantiates CSS objects without inventing any of their exports. + pub(crate) fn prepare_css( + &self, + context: &mut boa_engine::Context, + ) -> boa_engine::JsResult<()> { + for (name, file) in &self.css_bindings { + let object = crate::evaluation_sandbox::css_object(context, file)?; + context.register_global_property( + boa_engine::JsString::from(name.as_str()), + object, + boa_engine::property::Attribute::empty(), + )?; + } + Ok(()) + } + pub(crate) fn new(resolver: Option<&'r ModuleResolver>, option: &'r ExtractOption) -> Self { Self { resolver, @@ -133,6 +160,8 @@ impl<'r> ModuleLoader<'r> { loading: Vec::new(), pending: FxHashSet::default(), next_module: 0, + retained_css: RetainedCss::default(), + css_bindings: FxHashMap::default(), dependencies: BTreeSet::new(), kept_imports: Vec::new(), } @@ -144,6 +173,15 @@ impl<'r> ModuleLoader<'r> { } } + fn keep_entry_import(&mut self, specifier: &str) { + if retained_css::is_generated(specifier, self.option) + || !retained_css::is_css(specifier) + || self.retained_css.keep_root(specifier, self.resolver) + { + self.keep_import(specifier); + } + } + /// The script to run: the loaded modules' definitions, then the `entry` /// stylesheet pub(crate) fn script(&self, entry: &ModuleScript) -> Script { @@ -171,6 +209,32 @@ impl<'r> ModuleLoader<'r> { fn load(&mut self, specifier: &str, importer: &str, direct: bool) -> Result { let resolver = self.resolver.ok_or(LoadError::NoResolver)?; let module = resolver(specifier, importer).ok_or(LoadError::Unresolved)?; + if retained_css::is_css(&module.path) { + if let Some(kept) = self + .retained_css + .retain( + CssImport { + specifier, + path: &module.path, + direct, + }, + resolver, + &mut self.kept_imports, + ) + .map_err(LoadError::CssPath)? + { + self.keep_import(&kept); + } + if let Some(name) = self.loaded.get(&module.path) { + return Ok(name.clone()); + } + let name = format!("__css_{}__", self.next_module); + self.next_module += 1; + self.css_bindings.insert(name.clone(), module.path.clone()); + self.pending.insert(name.clone()); + self.loaded.insert(module.path, name.clone()); + return Ok(name); + } let stylesheet = is_vanilla_extract_file(&module.path); if direct && stylesheet { self.keep_import(specifier); @@ -329,6 +393,9 @@ pub(crate) fn module_script( entry: bool, ) -> Result { let (script, filename) = (unit.script(), unit.filename()); + if entry { + loader.retained_css.root = filename.to_string(); + } let load_module = |loader: &mut ModuleLoader<'_>, specifier: &str, at: u32| { loader .load(specifier, filename, entry) @@ -360,8 +427,7 @@ pub(crate) fn module_script( // Imports of a module still evaluating are read where they are used, as ES // modules read an import cycle let mut modules: FxHashMap = FxHashMap::default(); - let mut lazy: FxHashMap = FxHashMap::default(); - let mut lazy_names: FxHashMap = FxHashMap::default(); + let mut bindings = ImportBindings::default(); for statement in &program.body { let Statement::ImportDeclaration(import) = statement else { continue; @@ -369,8 +435,14 @@ pub(crate) fn module_script( let source = import.source.value.as_str(); let specifiers = import.specifiers.as_deref().map_or(&[][..], |s| s); if specifiers.is_empty() { - if entry && !crate::package_specifier::is_package(source, &package) { - loader.keep_import(source); + if !crate::package_specifier::is_package(source, &package) + && (entry || !retained_css::is_generated(source, loader.option)) + { + if entry { + loader.keep_entry_import(source); + } else { + load_module(loader, source, import.source.span.start)?; + } } continue; } @@ -380,44 +452,15 @@ pub(crate) fn module_script( load_module(loader, source, import.source.span.start)? }; if loader.pending.contains(&module) { - let lazy_module = &module; - for specifier in specifiers { - let binding = match specifier { - ImportDeclarationSpecifier::ImportSpecifier(specifier) => { - format!("{lazy_module}[{:?}]", specifier.imported.name()) - } - ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { - format!("{lazy_module}[\"default\"]") - } - ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.clone(), - }; - let local = specifier.local(); - if let Some(symbol) = local.symbol_id.get() { - lazy.insert(symbol, binding.clone()); - } - lazy_names.insert(local.name.to_string(), binding); - } - } - modules.insert(import.span.start, module); - } - let mut replacements: Vec<(u32, u32, String)> = Vec::new(); - for (symbol, binding) in &lazy { - for reference in semantic.scoping().get_resolved_reference_ids(*symbol) { - let node = semantic.scoping().get_reference(*reference).node_id(); - let span = semantic.nodes().kind(node).span(); - let replacement = match semantic.nodes().parent_kind(node) { - AstKind::ObjectProperty(property) if property.shorthand => { - format!( - "{}: {binding}", - &script[span.start as usize..span.end as usize] - ) - } - AstKind::ExportSpecifier(_) => continue, - _ => binding.clone(), + let source = match loader.css_bindings.get(&module) { + Some(_) => BindingSource::Css(&module), + None => BindingSource::Module(&module), }; - replacements.push((span.start, span.end, replacement)); + bindings.link(specifiers, source); } + modules.insert(import.span.start, module); } + let mut replacements = bindings.rewrites(&semantic, script); // CommonJS: `require` of a literal path loads the module like an import let commonjs = !program.body.iter().any(Statement::is_module_declaration) && semantic @@ -437,11 +480,12 @@ pub(crate) fn module_script( && let [Argument::StringLiteral(specifier)] = call.arguments.as_slice() { let module = load_module(loader, specifier.value.as_str(), specifier.span.start)?; - replacements.push(( - call.span.start, - call.span.end, - format!("(\"__exports__\" in {module} ? {module}.__exports__ : {module})"), - )); + let replacement = if loader.css_bindings.contains_key(&module) { + module + } else { + format!("(\"__exports__\" in {module} ? {module}.__exports__ : {module})") + }; + replacements.push((call.span.start, call.span.end, replacement)); } } } @@ -499,10 +543,7 @@ pub(crate) fn module_script( Statement::ExportNamedDeclaration(export) => { for specifier in &export.specifiers { let local = export_name(&specifier.local); - exports.push(( - export_name(&specifier.exported), - lazy_names.get(&local).cloned().unwrap_or(local), - )); + exports.push((export_name(&specifier.exported), bindings.exported(local))); } } Statement::ExportFromDeclaration(export) => { diff --git a/libs/extractor/src/module_loader/import_bindings.rs b/libs/extractor/src/module_loader/import_bindings.rs new file mode 100644 index 000000000..41a9950dd --- /dev/null +++ b/libs/extractor/src/module_loader/import_bindings.rs @@ -0,0 +1,83 @@ +//! Live import reads, including CSS exports whose values only the bundler knows. + +use oxc_ast::{AstKind, ast::ImportDeclarationSpecifier}; +use oxc_semantic::Semantic; +use oxc_span::GetSpan; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashMap; + +pub(super) enum BindingSource<'a> { + Module(&'a str), + Css(&'a str), +} + +#[derive(Default)] +pub(super) struct ImportBindings { + symbols: FxHashMap, + names: FxHashMap, +} + +impl ImportBindings { + pub(super) fn link( + &mut self, + specifiers: &[ImportDeclarationSpecifier<'_>], + source: BindingSource<'_>, + ) { + for specifier in specifiers { + let binding = match &source { + BindingSource::Module(module) => match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + format!("{module}[{:?}]", specifier.imported.name()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) => { + format!("{module}[\"default\"]") + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.to_string(), + }, + BindingSource::Css(module) => match specifier { + ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + format!("{module}[{:?}]", specifier.imported.name()) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(_) + | ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => module.to_string(), + }, + }; + let local = specifier.local(); + self.names.insert(local.name.to_string(), binding.clone()); + if let Some(symbol) = local.symbol_id.get() { + self.symbols.insert(symbol, binding); + } + } + } + + pub(super) fn exported(&self, local: String) -> String { + self.names.get(&local).cloned().unwrap_or(local) + } + + pub(super) fn rewrites( + &self, + semantic: &Semantic<'_>, + script: &str, + ) -> Vec<(u32, u32, String)> { + let mut replacements = Vec::new(); + for (symbol, binding) in &self.symbols { + for reference in semantic.scoping().get_resolved_reference_ids(*symbol) { + let node = semantic.scoping().get_reference(*reference).node_id(); + let span = semantic.nodes().kind(node).span(); + let parent = semantic.nodes().parent_kind(node); + let expression = binding; + let replacement = match parent { + AstKind::ObjectProperty(property) if property.shorthand => format!( + "{}: {expression}", + &script[usize::try_from(span.start).unwrap_or(script.len()) + ..usize::try_from(span.end).unwrap_or(script.len())] + ), + AstKind::ExportSpecifier(_) => continue, + _ => expression.clone(), + }; + replacements.push((span.start, span.end, replacement)); + } + } + replacements + } +} diff --git a/libs/extractor/src/module_loader/retained_css.rs b/libs/extractor/src/module_loader/retained_css.rs new file mode 100644 index 000000000..34b635d88 --- /dev/null +++ b/libs/extractor/src/module_loader/retained_css.rs @@ -0,0 +1,174 @@ +//! CSS loads survive removal of evaluated helpers without becoming content dependencies. + +use rustc_hash::FxHashSet; + +use crate::{ExtractOption, ModuleResolver}; + +pub(super) fn file_part(specifier: &str) -> &str { + specifier + .split_once('?') + .map_or(specifier, |(path, _)| path) +} + +pub(super) fn is_css(specifier: &str) -> bool { + file_part(specifier) + .rsplit_once('.') + .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("css")) +} + +pub(super) fn is_generated(specifier: &str, option: &ExtractOption) -> bool { + let Some(file) = file_part(specifier) + .strip_prefix(&option.css_dir) + .and_then(|suffix| suffix.strip_prefix('/')) + else { + return false; + }; + file == "devup-ui.css" + || file + .strip_prefix("devup-ui-") + .and_then(|suffix| suffix.strip_suffix(".css")) + .is_some_and(|number| { + !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) + }) +} + +pub(super) struct CssImport<'a> { + pub specifier: &'a str, + pub path: &'a str, + pub direct: bool, +} + +#[derive(Debug)] +pub(super) struct PathError { + root: String, + target: String, +} + +impl PathError { + pub(super) fn describe(&self) -> String { + format!( + "Cannot retain CSS '{}' relative to '{}': the paths have different Windows drives or incompatible roots. Fix: keep the stylesheet and CSS on the same drive, or import CSS from a component module", + self.target, self.root + ) + } +} + +#[derive(Default)] +pub(super) struct RetainedCss { + pub root: String, + files: FxHashSet, + deferred_roots: Vec, + resolved: bool, +} + +impl RetainedCss { + pub(super) fn keep_root(&mut self, specifier: &str, resolver: Option<&ModuleResolver>) -> bool { + if self.resolved { + if let Some(module) = resolver.and_then(|resolve| resolve(specifier, &self.root)) { + return self.files.insert(identity(&module.path)); + } + } else { + self.deferred_roots.push(specifier.to_string()); + } + true + } + + pub(super) fn retain( + &mut self, + import: CssImport<'_>, + resolver: &ModuleResolver, + kept_imports: &mut Vec, + ) -> Result, PathError> { + // Root-only bare loads still belong to the bundler. Resolve their identities + // only when an evaluated closure needs resolved-file deduplication. + for specifier in self.deferred_roots.drain(..) { + if let Some(module) = resolver(&specifier, &self.root) + && !self.files.insert(identity(&module.path)) + { + kept_imports.retain(|kept| *kept != specifier); + } + } + self.resolved = true; + if !self.files.insert(identity(import.path)) { + return Ok(None); + } + if import.direct { + return Ok(Some(import.specifier.to_string())); + } + let relative = relative_specifier(&self.root, import.path)?; + let suffix = import + .specifier + .find('?') + .map_or("", |at| &import.specifier[at..]); + Ok(Some(format!("{relative}{suffix}"))) + } +} + +struct PortablePath { + volume: String, + segments: Vec, +} + +impl PortablePath { + fn parse(path: &str) -> Self { + let path = file_part(path).replace('\\', "/"); + let (volume, tail) = if path.as_bytes().get(1) == Some(&b':') { + (path[..2].to_string(), &path[2..]) + } else if path.starts_with('/') { + ("/".to_string(), path.as_str()) + } else { + (String::new(), path.as_str()) + }; + let mut segments = Vec::new(); + for segment in tail.split('/') { + match segment { + "" | "." => {} + ".." if segments.last().is_some_and(|last| last != "..") => { + segments.pop(); + } + ".." if !volume.is_empty() => {} + segment => segments.push(segment.to_string()), + } + } + Self { volume, segments } + } +} + +fn identity(path: &str) -> String { + let parsed = PortablePath::parse(path); + let path = format!("{}/{}", parsed.volume, parsed.segments.join("/")); + if parsed.volume.ends_with(':') { + path.to_ascii_lowercase() + } else { + path + } +} + +pub(super) fn relative_specifier(root: &str, target: &str) -> Result { + let mut root_path = PortablePath::parse(root); + root_path.segments.pop(); + let target_path = PortablePath::parse(target); + if !root_path.volume.eq_ignore_ascii_case(&target_path.volume) { + return Err(PathError { + root: root.to_string(), + target: target.to_string(), + }); + } + let windows = root_path.volume.ends_with(':'); + let common = root_path + .segments + .iter() + .zip(&target_path.segments) + .take_while(|(left, right)| { + if windows { + left.eq_ignore_ascii_case(right) + } else { + left == right + } + }) + .count(); + let parents = "../".repeat(root_path.segments.len() - common); + let tail = target_path.segments[common..].join("/"); + let prefix = if parents.is_empty() { "./" } else { &parents }; + Ok(format!("{prefix}{tail}")) +} diff --git a/libs/extractor/src/module_loader/script/explain.rs b/libs/extractor/src/module_loader/script/explain.rs index 14fa221e5..4ea43c0e7 100644 --- a/libs/extractor/src/module_loader/script/explain.rs +++ b/libs/extractor/src/module_loader/script/explain.rs @@ -102,6 +102,9 @@ fn parse(error: &str) -> (String, Vec>, Option) { /// What to change for an error to stop fn fix(cause: &str) -> String { + if cause.starts_with("ReferenceError: Cannot read CSS export '") { + return "use the class in a runtime component (className={styles.card}) or write the style with css()".to_string(); + } if cause.contains(IMPORT_CYCLE) { return "break the import cycle, or read the imported value only after both modules finished evaluating, for example inside a function called later".to_string(); } diff --git a/libs/extractor/src/module_loader/script/explain/tests.rs b/libs/extractor/src/module_loader/script/explain/tests.rs index 527e0f796..716d65905 100644 --- a/libs/extractor/src/module_loader/script/explain/tests.rs +++ b/libs/extractor/src/module_loader/script/explain/tests.rs @@ -133,3 +133,25 @@ fn every_kind_of_error_has_a_fix() { assert!(fix("RangeError: x").starts_with("remove or guard the code")); assert!(fix("ReferenceError: x is not initialized").starts_with("remove or guard the code")); } + +#[test] +fn generic_fix_is_preserved_when_user_prose_mentions_css_exports() { + // Given + for error in [ + "Error: Cannot read CSS export 'card' of '/styles.css'", + "ReferenceError: user says ReferenceError: Cannot read CSS export 'card'", + ] { + // When + let told = tell(error); + // Then + assert_eq!( + parts(&told).0, + format!("/fallback.ts: JS execution error: {error}") + ); + assert_eq!( + parts(&told).1, + "remove or guard the code that fails here, or correct the cause above, so the stylesheet evaluates without throwing" + ); + assert_eq!(told.matches("Fix:").count(), 1); + } +} diff --git a/libs/extractor/src/module_loader/script/tests.rs b/libs/extractor/src/module_loader/script/tests.rs index 3a412dd91..5fbe0b0ff 100644 --- a/libs/extractor/src/module_loader/script/tests.rs +++ b/libs/extractor/src/module_loader/script/tests.rs @@ -1,4 +1,77 @@ use super::*; +use oxc_ast_visit::{Visit, walk}; +use oxc_span::SourceType; + +#[derive(Default)] +struct Calls(Vec); + +impl<'a> Visit<'a> for Calls { + fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { + self.0.push(call.span.start as usize); + walk::walk_call_expression(self, call); + } +} + +#[rstest::rstest] +#[case("()=>{}")] +#[case("function(){}")] +#[case("(value)=>value")] +#[case("async()=>{}")] +fn anonymous_calls_keep_original_expression_starts_when_formatted( + #[case] function: &str, +) -> Result<(), String> { + // Given + let source = + format!("const label: string='a b';\nexport const value=({function}).read('key');"); + let unit = Unit::written("anonymous.ts", &source, &source, &[])?; + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, unit.script(), SourceType::mjs()).parse(); + let mut calls = Calls::default(); + calls.visit_program(&parsed.program); + let at = *calls.0.first().ok_or("missing call")?; + let expected = source.find(function).ok_or("missing original function")?; + // When + let location = unit.place(at); + // Then + assert_eq!(location, crate::locate("anonymous.ts", &source, expected)); + Ok(()) +} + +#[rstest::rstest] +fn anonymous_calls_keep_distinct_original_sites_when_prior_layers_change( + #[values("\n", "\r\n", "\r", "\u{2028}", "\u{2029}")] newline: &str, +) -> Result<(), String> { + // Given + let source = format!( + "import {{style}} from '@vanilla-extract/css';{newline}const label: string='😀 한';{newline}export const value=style({{first:(()=>{{}}).read('key')?'a b':'c',second:(()=>{{}}).read('key')?'d':'e'}});" + ); + let aliased = source.replace("@vanilla-extract/css", "@devup-ui/react"); + let alias_start = source + .find("@vanilla-extract/css") + .ok_or("missing import")?; + let aliases = [( + alias_start, + alias_start + "@vanilla-extract/css".len(), + "@devup-ui/react".len(), + )]; + let prefix = "const earlier: number=0;\n"; + let code = format!("{prefix}{aliased}"); + let earlier = [(0, 0, prefix.len())]; + let unit = Unit::written("duplicates.ts", &code, &source, &[&earlier, &aliases])?; + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, unit.script(), SourceType::mjs()).parse(); + let mut calls = Calls::default(); + calls.visit_program(&parsed.program); + let expected: Vec<_> = source + .match_indices("()=>{}") + .map(|(at, _)| crate::locate("duplicates.ts", &source, at)) + .collect(); + // When + let locations: Vec<_> = calls.0.iter().skip(1).map(|&at| unit.place(at)).collect(); + // Then + assert_eq!(locations, expected); + Ok(()) +} #[test] fn a_generated_module_is_told_by_its_file() { diff --git a/libs/extractor/src/module_loader/tests.rs b/libs/extractor/src/module_loader/tests.rs index 8416b1d02..a7fd4631a 100644 --- a/libs/extractor/src/module_loader/tests.rs +++ b/libs/extractor/src/module_loader/tests.rs @@ -7,8 +7,12 @@ use crate::import_alias_visit::{Edit, transform_import_aliases_with_edits}; use crate::vanilla_extract::{Stylesheet, execute_located}; use crate::{ExtractOption, ImportAlias, ResolvedModule}; +mod css_paths; +mod css_reads; mod regressions; +mod retained_css; mod sandbox; +mod side_effect_dependency; type Files = &'static [(&'static str, &'static str)]; diff --git a/libs/extractor/src/module_loader/tests/css_elision.rs b/libs/extractor/src/module_loader/tests/css_elision.rs new file mode 100644 index 000000000..49451d586 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_elision.rs @@ -0,0 +1,172 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import {card} from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_retention_keeps_unused_root_value_imports_when_source_is_javascript( + #[case] import: &str, + #[values("js", "jsx", "mjs")] extension: &str, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import './before.css';{import}import './after.css';export const value=['red'].join('');" + ); + let filename = format!("/src/card.css.{extension}"); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + &filename, + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + imports.kept_imports, + ["./before.css", "./styles.module.css", "./after.css"] + ); + Ok(()) +} + +#[rstest::rstest] +#[case("const styles=require('./styles.module.css');")] +#[serial] +fn css_retention_keeps_unused_value_loads_when_helper_is_commonjs( + #[case] declaration: &str, +) -> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let helper = format!("{declaration}exports.color='red';"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.cjs".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.cjs") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + super::super::retained_css::ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert!( + output.code.contains("import \"./styles.module.css\";"), + "{}", + output.code + ); + assert_eq!(output.dependencies, ["/src/helper.cjs"]); + Ok(()) +} + +#[rstest::rstest] +#[case("function neverCalled(){return styles.card;}", true)] +#[case("type Styles = typeof styles;", false)] +#[serial] +fn css_retention_distinguishes_value_and_type_references_when_root_is_typescript( + #[case] reference: &str, + #[case] retained: bool, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import styles from './styles.module.css';{reference}export const value=['red'].join('');" + ); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + let expected = if retained { + vec!["./styles.module.css"] + } else { + vec![] + }; + assert_eq!(imports.kept_imports, expected); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_keeps_unused_value_loads_when_root_is_commonjs() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, _: &str| { + (specifier == "./styles.module.css").then(|| ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "const styles=require('./styles.module.css');const value=['red'].join('');", + "/src/card.css.cjs", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./styles.module.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_rejects_actual_read_when_helper_is_commonjs() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.cjs".to_string(), + code: "const styles=require('./styles.module.css');\nexports.color=styles.card;" + .to_string(), + }), + ("./styles.module.css", "/src/helper.cjs") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: super::super::retained_css::CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + super::super::retained_css::ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.cjs:2:15:"), "{error}"); + assert!( + error.contains("export 'card'") && error.contains("/src/styles.module.css"), + "{error}" + ); +} diff --git a/libs/extractor/src/module_loader/tests/css_observations.rs b/libs/extractor/src/module_loader/tests/css_observations.rs new file mode 100644 index 000000000..e00e7d65c --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_observations.rs @@ -0,0 +1,191 @@ +use serial_test::serial; + +use super::super::retained_css::{CSS, ENTRY}; +use crate::{ExtractOption, ExtractOutput, ResolvedModule}; + +fn extract_helper(helper: &str) -> Result> { + let helper = helper.to_string(); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) +} + +#[rstest::rstest] +#[case("styles.card", "card")] +#[case("styles['card']", "card")] +#[case("styles['']", "")] +#[case("styles[Symbol.toPrimitive]", "Symbol(Symbol.toPrimitive)")] +#[case("styles.valueOf", "valueOf")] +#[case("styles.toString", "toString")] +#[case("String(styles)", "Symbol(Symbol.toPrimitive)")] +#[case("+styles", "Symbol(Symbol.toPrimitive)")] +#[case("styles + ''", "Symbol(Symbol.toPrimitive)")] +#[case("`${styles}`", "Symbol(Symbol.toPrimitive)")] +#[case("'card' in styles", "has:card")] +#[case("Object.keys(styles)", "ownKeys")] +#[case("Reflect.ownKeys(styles)", "ownKeys")] +#[case("(()=>{for(const key in styles){}return 'blue';})()", "ownKeys")] +#[case("({...styles})", "ownKeys")] +#[case("[...styles]", "Symbol(Symbol.iterator)")] +#[case("JSON.stringify(styles)", "toJSON")] +#[case( + "Object.getOwnPropertyDescriptor(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case( + "Reflect.getOwnPropertyDescriptor(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case("Object.getOwnPropertyDescriptors(styles)", "ownKeys")] +#[case("Object.getPrototypeOf(styles)", "getPrototypeOf")] +#[case("Reflect.getPrototypeOf(styles)", "getPrototypeOf")] +#[case("styles.__proto__", "__proto__")] +#[case( + "Object.prototype.hasOwnProperty.call(styles,'card')", + "getOwnPropertyDescriptor:card" +)] +#[case("Reflect.has(styles,'card')", "has:card")] +#[case("Reflect.get(styles,'card')", "card")] +#[case("(()=>{const {card}=styles;return card;})()", "card")] +#[case("styles[(()=> 'card')()]", "card")] +#[serial] +fn css_observation_is_fatal_when_top_level_read_is_caught( + #[case] observation: &str, + #[case] key: &str, + #[values( + "import styles from './styles.module.css';", + "import * as styles from './styles.module.css';" + )] + import: &str, +) { + // Given: the fallback is a wrong CSS value, and error mutation cannot erase evidence. + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nlet color: string='blue';\ntry{{\n {observation};\n}}catch(error){{error.message='mutated';error.stack='';color='red';}}\nexport {{color}};" + ); + // When + let error = extract_helper(&helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let column = match observation { + "(()=>{for(const key in styles){}return 'blue';})()" => 9, + "({...styles})" => 5, + "[...styles]" => 4, + "`${styles}`" => 6, + "(()=>{const {card}=styles;return card;})()" => 16, + _ => 3, + }; + assert!( + error.starts_with(&format!("/src/helper.ts:4:{column}:")), + "{observation}: {error}" + ); + assert!(error.contains(&format!("export '{key}'")), "{error}"); + assert!( + error.contains("/src/styles.module.css") && error.contains("generated by the bundler"), + "{error}" + ); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!( + error.contains("className={styles.card}") && error.contains("css()"), + "{error}" + ); + assert!( + !error.contains("mutated") && !error.contains("devup-ui-stylesheet"), + "{error}" + ); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card")] +#[case("import * as styles from './styles.module.css';", "styles.card")] +#[case("import {card} from './styles.module.css';", "card")] +#[case("import {card} from './styles.module.css';", "typeof card")] +#[serial] +fn css_observation_keeps_first_site_once_when_called_read_is_caught_repeatedly( + #[case] import: &str, + #[case] read: &str, +) { + // Given + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nconst label: string='한글😀';\nfunction read(){{\n try{{return {read};}}catch(error){{error.message='mutated';return 'red';}}\n}}\nread();export const color=read();" + ); + // When + let error = extract_helper(&helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let column = if read == "typeof card" { 21 } else { 14 }; + assert!( + error.starts_with(&format!("/src/helper.ts:4:{column}:")), + "{error}" + ); + assert_eq!(error.matches("JS execution error:").count(), 1, "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!( + error.contains("export 'card'") && !error.contains("mutated"), + "{error}" + ); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_observation_allows_known_object_when_typeof_and_passing_do_not_inspect_it( + #[case] import: &str, +) -> Result<(), Box> { + // Given: passing to both called and uncalled functions must not read properties. + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nfunction identity(value){{return value;}}\nfunction neverCalled(value){{return value.card;}}\nconst holder={{styles}};const passed=identity(holder.styles);const thunk=()=>neverCalled(passed);identity(thunk);\nexport const color=typeof passed==='object'?'blue':'red';" + ); + // When + let output = extract_helper(&helper)?; + // Then + assert!(output.styles.iter().any( + |style| matches!(style, crate::ExtractStyleValue::Static(value) if value.value() == "blue") + )); + assert_eq!(output.dependencies, ["/src/helper.ts"]); + assert!(output.code.contains("import \"./styles.module.css\";")); + Ok(()) +} + +#[test] +#[serial] +fn css_observation_preserves_first_key_when_dynamic_keys_repeat_at_one_site() { + // Given + css::file_map::reset_file_map(); + let helper = "import styles from './styles.module.css';\nlet color='blue';\nfor(const key of ['card','other']){\n try{color=styles[key];}catch(error){error.message='mutated';color='red';}\n}\nexport {color};"; + // When + let error = extract_helper(helper) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:4:13:"), "{error}"); + assert!( + error.contains("export 'card'") && !error.contains("export 'other'"), + "{error}" + ); + assert_eq!(error.matches("JS execution error:").count(), 1, "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); +} diff --git a/libs/extractor/src/module_loader/tests/css_paths.rs b/libs/extractor/src/module_loader/tests/css_paths.rs new file mode 100644 index 000000000..58c3beeaa --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_paths.rs @@ -0,0 +1,71 @@ +use crate::module_loader::retained_css::{is_generated, relative_specifier}; + +#[rstest::rstest] +#[case("/src/card.css.ts", "/src/styles.css", "./styles.css")] +#[case( + "/src/pages/card.css.ts", + "/styles/global.css", + "../../styles/global.css" +)] +#[case( + "C:\\app\\src\\card.css.ts", + "c:/app/src/../styles/global.css?inline", + "../styles/global.css" +)] +#[case( + "src/pages/card.css.ts", + "src/styles/global.css", + "../styles/global.css" +)] +#[case("card.css.ts", "global.css", "./global.css")] +fn css_retention_relativizes_when_paths_have_compatible_roots( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + assert!(relative.starts_with("./") || relative.starts_with("../")); + assert!(!relative.contains('\\')); + Ok(()) +} + +#[rstest::rstest] +#[case("C:/app/card.css.ts", "D:/styles/global.css")] +#[case("src/card.css.ts", "/styles/global.css")] +fn css_retention_rejects_when_paths_have_incompatible_roots( + #[case] root: &str, + #[case] target: &str, +) -> Result<(), String> { + // Given / When + let error = relative_specifier(root, target) + .err() + .ok_or("expected incompatible roots")?; + // Then + assert!(error.describe().contains(root) && error.describe().contains(target)); + assert!(error.describe().contains("Fix:")); + Ok(()) +} + +#[rstest::rstest] +#[case("df/styles/devup-ui.css", true)] +#[case("df/styles/devup-ui-17.css?inline", true)] +#[case("df/styles/my.module.css", false)] +#[case("df/styles/sub/user.css", false)] +#[case("df/styles-other/devup-ui.css", false)] +fn css_retention_exempts_only_generated_loads_when_css_directory_contains_user_files( + #[case] specifier: &str, + #[case] expected: bool, +) { + // Given + let option = crate::ExtractOption { + css_dir: "df/styles".to_string(), + ..crate::ExtractOption::default() + }; + // When + let generated = is_generated(specifier, &option); + // Then + assert_eq!(generated, expected); +} diff --git a/libs/extractor/src/module_loader/tests/css_reads.rs b/libs/extractor/src/module_loader/tests/css_reads.rs new file mode 100644 index 000000000..4b0bac1e4 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/css_reads.rs @@ -0,0 +1,272 @@ +use serial_test::serial; + +use super::retained_css::{CSS, ENTRY}; +use crate::{ExtractOption, ResolvedModule}; + +#[path = "css_observations.rs"] +mod observations; + +#[path = "css_elision.rs"] +mod elision; + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", false)] +#[case("import {card as cardClass} from './styles.module.css';", false)] +#[case("import * as styles from './styles.module.css';", false)] +#[case( + "import styles, {card as cardClass} from './styles.module.css';import * as namespace from './styles.module.css';", + false +)] +#[case( + "import styles from './styles.module.css';function neverCalled(){return styles.card;}", + true +)] +#[case( + "import {card} from './styles.module.css';function neverCalled(){return card;}", + true +)] +#[case( + "import * as styles from './styles.module.css';function neverCalled(){return styles.card;}", + true +)] +#[case("import './styles.module.css';", true)] +#[serial] +fn css_retention_follows_default_elision_when_css_bindings_are_unused_or_uncalled( + #[case] declarations: &str, + #[case] retained_in_ts: bool, + #[values("ts", "tsx", "mts", "cts", "js", "jsx", "mjs")] extension: &str, +) -> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let helper = format!("{declarations}export const color='red';"); + let helper_path = format!("/src/helper.{extension}"); + let retained = retained_in_ts || matches!(extension, "js" | "jsx" | "mjs" | "cjs"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: helper_path.clone(), + code: helper.clone(), + }), + ("./styles.module.css", importer) if importer == helper_path => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!( + output.code.contains("import \"./styles.module.css\";"), + retained, + "{}", + output.code + ); + assert_eq!(output.dependencies, [format!("/src/helper.{extension}")]); + assert!(output.styles.iter().any( + |style| matches!(style, crate::ExtractStyleValue::Static(value) if value.value() == "red") + )); + Ok(()) +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card", "card")] +#[case("import styles from './styles.module.css';", "styles", "toJSON")] +#[case( + "import {card as cardClass} from './styles.module.css';", + "cardClass", + "card" +)] +#[case( + "import * as styles from './styles.module.css';", + "styles.card", + "card" +)] +#[case("import styles from './styles.module.css';", "styles['card']", "card")] +#[case("import * as styles from './styles.module.css';", "styles", "toJSON")] +#[serial] +fn css_retention_rejects_actual_read_at_original_site_when_export_is_unknown( + #[case] import: &str, + #[case] read: &str, + #[case] key: &str, +) { + // Given: type erasure and Unicode must not shift the read's original location. + css::file_map::reset_file_map(); + let helper = + format!("{import}\nconst label: string='한글😀';\nexport const color: string = {read};"); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + let site = if read == "styles" { + "/src/card.css.ts:1:88:" + } else { + "/src/helper.ts:3:30:" + }; + assert!(error.starts_with(site), "{error}"); + assert!( + error.contains("/src/styles.module.css") && error.contains(&format!("export '{key}'")), + "{error}" + ); + assert!( + error.contains("generated by the bundler") && error.contains("unknown at build time"), + "{error}" + ); + assert!(error.contains("Fix: use the class in a runtime component (className={styles.card}) or write the style with css()"), "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';", "styles.card")] +#[case("import {card} from './styles.module.css';", "card")] +#[case("import * as styles from './styles.module.css';", "styles.card")] +#[serial] +fn css_retention_rejects_called_function_read_when_css_value_is_requested( + #[case] import: &str, + #[case] read: &str, +) { + // Given + css::file_map::reset_file_map(); + let helper = format!( + "{import}\nfunction read(): string {{\n return {read};\n}}\nexport const color=read();" + ); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: helper.clone(), + }), + ("./styles.module.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/styles.module.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:3:10:"), "{error}"); + assert!( + error.contains("export 'card'") && error.contains("/src/styles.module.css"), + "{error}" + ); + assert!(error.contains("at read (/src/helper.ts:3:10)"), "{error}"); + assert_eq!(error.matches("Fix:").count(), 1, "{error}"); + assert!(error.contains("Fix: use the class in a runtime component (className={styles.card}) or write the style with css()"), "{error}"); +} + +#[rstest::rstest] +#[case("import styles from './styles.module.css';")] +#[case("import {card} from './styles.module.css';")] +#[case("import * as styles from './styles.module.css';")] +#[serial] +fn css_retention_elides_root_unused_css_bindings_when_typescript_erases_them( + #[case] import: &str, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!( + "import './before.css';{import}import './after.css';export const value=['red'].join('');" + ); + let resolver = |_: &str, _: &str| -> Option { + panic!("root-only unused CSS loads remain bundler-owned") + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./before.css", "./after.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_preserves_order_when_unused_helper_bindings_are_erased() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import styles from './first.module.css';import './child';import {card} from './last.module.css';export const color='red';", + ), + ("./child", "/src/helper.ts") => ("/src/child.ts", "import './child.css';"), + ("./first.module.css", "/src/helper.ts") => ("/src/first.module.css", CSS), + ("./last.module.css", "/src/helper.ts") => ("/src/last.module.css", CSS), + ("./child.css", "/src/child.ts") => ("/src/child.css", CSS), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const value=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, ["./child.css"]); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_ignores_type_only_imports_when_no_css_is_loaded() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = "import type Styles from './styles.module.css';import {type Card} from './other.module.css';export const color='red';"; + let resolver = |_: &str, _: &str| -> Option { + panic!("type-only CSS must not be resolved") + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, Vec::::new()); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/retained_css.rs b/libs/extractor/src/module_loader/tests/retained_css.rs new file mode 100644 index 000000000..248ac6bd8 --- /dev/null +++ b/libs/extractor/src/module_loader/tests/retained_css.rs @@ -0,0 +1,263 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +pub(super) const ENTRY: &str = "import {style} from '@devup-ui/react';import {color} from './helper';export const card=style({color});"; +pub(super) const CSS: &str = ".card { color: red; }"; + +#[rstest::rstest] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "./global.css", + "/src/global.css", + "./global.css" +)] +#[case( + "/src/pages/card.css.ts", + "/src/helpers/helper.ts", + "./global.css", + "/src/helpers/global.css", + "../helpers/global.css" +)] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "theme/package.css", + "/node_modules/theme/dist/package.css", + "../node_modules/theme/dist/package.css" +)] +#[case( + "/src/card.css.ts", + "/src/helper.ts", + "./global.CSS?inline", + "/src/global.CSS?inline", + "./global.CSS?inline" +)] +#[case( + "C:/app/src/card.css.ts", + "C:/app/helpers/helper.ts", + "./global.css", + "C:\\app\\helpers\\global.css", + "../helpers/global.css" +)] +#[serial] +fn css_retention_emits_portable_specifiers_when_helper_loads_css( + #[case] root: &'static str, + #[case] helper: &'static str, + #[case] imported: &'static str, + #[case] target: &'static str, + #[case] emitted: &'static str, +) -> Result<(), Box> { + // Given: the resolver discriminates importer directories and package exports. + css::file_map::reset_file_map(); + let helper_code = format!("import {imported:?};export const color='red';"); + let resolver = move |specifier: &str, importer: &str| { + let (path, code) = if (specifier, importer) == ("./helper", root) { + (helper, helper_code.as_str()) + } else if (specifier, importer) == (imported, helper) + || (specifier, importer) == (emitted, root) + { + (target, CSS) + } else { + return None; + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let output = + crate::extract_with_modules(root, ENTRY, ExtractOption::default(), false, &resolver)?; + // Then: the emitted load resolves to the helper's actual CSS, not root/global.css. + let allocator = oxc_allocator::Allocator::default(); + let parsed = + oxc_parser::Parser::new(&allocator, &output.code, oxc_span::SourceType::mjs()).parse(); + let loads: Vec<_> = parsed + .program + .body + .iter() + .filter_map(|statement| match statement { + oxc_ast::ast::Statement::ImportDeclaration(import) + if !crate::package_specifier::is_package( + import.source.value.as_str(), + "@devup-ui/react", + ) => + { + Some(import.source.value.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(loads, [emitted]); + assert_eq!( + resolver(loads[0], root).map(|module| module.path), + Some(target.to_string()) + ); + assert_eq!(output.dependencies, [helper]); + Ok(()) +} + +#[rstest::rstest] +#[case("import './root.css';import {color} from './helper';import './after.css';", vec!["./root.css", "./shared.css?inline", "./child.css", "./helper.css", "./after.css"])] +#[case("import './shared-alias.css';import {color} from './helper';import './after.css';", vec!["./shared-alias.css", "./child.css", "./helper.css", "./after.css"])] +#[case("import {color} from './helper';import './shared-alias.css';import './after.css';", vec!["./shared.css?inline", "./child.css", "./helper.css", "./after.css"])] +#[case("import './shared-alias.css';import './shared.css?inline';import {color} from './helper';", vec!["./shared-alias.css", "./child.css", "./helper.css"])] +#[serial] +fn css_retention_orders_and_deduplicates_when_root_and_helpers_interleave( + #[case] imports: &str, + #[case] expected: Vec<&str>, +) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import './child';import './helper.css';import './child';export const color='red';", + ), + ("./child", "/src/helper.ts") => ( + "/src/child.ts", + "import './shared.css?inline';import './child.css';", + ), + ("./shared.css?inline", "/src/child.ts" | "/src/card.css.ts") + | ("./shared-alias.css", "/src/card.css.ts") => ("/src/shared.css", CSS), + ("./root.css", "/src/card.css.ts") => ("/src/root.css", CSS), + ("./after.css", "/src/card.css.ts") => ("/src/after.css", CSS), + ("./child.css", "/src/child.ts" | "/src/card.css.ts") => ("/src/child.css", CSS), + ("./helper.css", "/src/helper.ts" | "/src/card.css.ts") => ("/src/helper.css", CSS), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + let source = format!("{imports}export const value=color;"); + // When + let (_, output) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!(output.kept_imports, expected); + for specifier in &output.kept_imports { + assert!( + resolver(specifier, "/src/card.css.ts").is_some(), + "{specifier}" + ); + } + assert_eq!( + output.dependencies.into_iter().collect::>(), + ["/src/child.ts", "/src/helper.ts"] + ); + Ok(()) +} + +#[test] +#[serial] +fn css_retention_locates_missing_file_when_helper_imports_it() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + ((specifier, importer) == ("./helper", "/src/card.css.ts")).then(|| ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "const label: string='한글😀';\nimport './missing.css';export const color='red';" + .to_string(), + }) + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("/src/helper.ts:2:8:"), "{error}"); + assert!( + error.contains("./missing.css") && error.contains("cannot be found"), + "{error}" + ); + assert!( + error.contains("stylesheet loads CSS through this helper"), + "{error}" + ); + assert!( + error.contains("Fix: correct the path or import CSS from a component module"), + "{error}" + ); +} + +#[test] +#[serial] +fn css_retention_locates_drive_error_when_target_is_on_another_drive() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "C:/src/card.css.ts") => Some(ResolvedModule { + path: "C:/src/helper.ts".to_string(), + code: "import './global.css';export const color='red';".to_string(), + }), + ("./global.css", "C:/src/helper.ts") => Some(ResolvedModule { + path: "D:/styles/global.css".to_string(), + code: CSS.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "C:/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!(error.starts_with("C:/src/helper.ts:1:8:"), "{error}"); + assert!( + error.contains("different Windows drives") && error.contains("D:/styles/global.css"), + "{error}" + ); + assert!(error.contains("Fix:"), "{error}"); +} + +#[test] +#[serial] +fn css_retention_preserves_exemptions_when_helper_has_generated_and_api_loads() -> Result<(), String> +{ + // Given + css::file_map::reset_file_map(); + let option = ExtractOption { + css_dir: "df/styles".to_string(), + ..ExtractOption::default() + }; + let resolver = |specifier: &str, importer: &str| { + assert_eq!((specifier, importer), ("./helper", "/src/card.css.ts")); + Some(ResolvedModule { path: "/src/helper.ts".to_string(), code: "import '@devup-ui/react/devup-ui.css';import '@devup-ui/react/compat';import 'df/styles/devup-ui.css';export const color='red';".to_string() }) + }; + // When + let (_, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const value=color;", + "/src/card.css.ts", + &option, + Some(&resolver), + )?; + // Then + assert_eq!(imports.kept_imports, Vec::::new()); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + ["/src/helper.ts"] + ); + Ok(()) +} diff --git a/libs/extractor/src/module_loader/tests/side_effect_dependency.rs b/libs/extractor/src/module_loader/tests/side_effect_dependency.rs new file mode 100644 index 000000000..f8c1383bd --- /dev/null +++ b/libs/extractor/src/module_loader/tests/side_effect_dependency.rs @@ -0,0 +1,304 @@ +use serial_test::serial; + +use crate::{ExtractOption, ResolvedModule}; + +const ENTRY: &str = "import {style} from '@devup-ui/react'; import {color} from './helper'; export const card=style({color});"; + +#[rstest::rstest] +#[case( + "throw new Error('side-effect boom');", + "/src/effect.ts:1:7", + "Error: side-effect boom" +)] +#[case( + "const label: string = '한글😀';\nfunction fail(): void {\n throw new Error('side-effect boom');\n}\nfail();", + "/src/effect.ts:3:9", + "Error: side-effect boom" +)] +#[case( + "try{Date.now()}catch{}", + "/src/effect.ts:1:5", + "ReferenceError: `Date`" +)] +#[case( + "const label: string = '한글😀';\ntry {\n Date.now();\n} catch {}", + "/src/effect.ts:3:3", + "ReferenceError: `Date`" +)] +#[serial] +fn side_effect_dependency_failures_keep_original_locations( + #[case] effect: &'static str, + #[case] location: &str, + #[case] cause: &str, +) { + // Given + css::file_map::reset_file_map(); + let resolver = move |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './effect';export const color='red';".to_string(), + }), + ("./effect", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/effect.ts".to_string(), + code: effect.to_string(), + }), + _ => None, + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!( + error.starts_with(&format!("{location}: JS execution error: {cause}")), + "{error}" + ); + assert!(error.contains(". Fix: "), "{error}"); + assert!(error.contains(&format!("({location})")), "{error}"); + assert!(!error.contains("devup-ui-stylesheet"), "{error}"); +} + +#[test] +#[serial] +fn side_effect_dependency_missing_import_is_located_in_helper() { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + (specifier == "./helper" && importer == "/src/card.css.ts").then(|| ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "const label: string = '한글😀';\nimport './missing';\nexport const color='red';" + .to_string(), + }) + }; + // When + let error = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + ) + .err() + .map(|error| error.to_string()) + .unwrap_or_default(); + // Then + assert!( + error.starts_with( + "/src/helper.ts:2:8: Cannot resolve './missing' from '/src/helper.ts'. Fix: " + ), + "{error}" + ); +} + +#[test] +#[serial] +fn side_effect_dependency_pure_module_is_tracked_in_output() +-> Result<(), Box> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './effect';export const color='red';".to_string(), + }), + ("./effect", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/effect.ts".to_string(), + code: "export const unused: string = 'pure';".to_string(), + }), + _ => None, + }; + // When + let output = crate::extract_with_modules( + "/src/card.css.ts", + ENTRY, + ExtractOption::default(), + false, + &resolver, + )?; + // Then + assert_eq!(output.dependencies, ["/src/effect.ts", "/src/helper.ts"]); + assert!(output.styles.iter().any(|style| matches!(style, + crate::ExtractStyleValue::Static(value) if value.property() == "color" && value.value() == "red" + ))); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_duplicate_diamond_cycle_executes_once_in_order() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + let (path, code) = match (specifier, importer) { + ("./helper", "/src/card.css.ts") => ( + "/src/helper.ts", + "import {events} from './init';import './left';import './left';import './right';events.push('helper');export const color=events.join(',');", + ), + ("./init", "/src/helper.ts" | "/src/left.ts" | "/src/right.ts" | "/src/shared.ts") => { + ("/src/init.ts", "export const events=['init'];") + } + ("./left", "/src/helper.ts" | "/src/shared.ts") => ( + "/src/left.ts", + "import {events} from './init';import './shared';events.push('left');", + ), + ("./right", "/src/helper.ts") => ( + "/src/right.ts", + "import {events} from './init';import './shared-alias';events.push('right');", + ), + ("./shared", "/src/left.ts") | ("./shared-alias", "/src/right.ts") => ( + "/src/shared.ts", + "import {events} from './init';import './left';events.push('shared');", + ), + _ => return None, + }; + Some(ResolvedModule { + path: path.to_string(), + code: code.to_string(), + }) + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const order=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [( + "order".to_string(), + "\"init,shared,left,right,helper\"".to_string() + )] + ); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + [ + "/src/helper.ts", + "/src/init.ts", + "/src/left.ts", + "/src/right.ts", + "/src/shared.ts" + ] + ); + Ok(()) +} + +#[rstest::rstest] +#[case("./root-effect")] +#[case("external-package")] +#[case("./global.css")] +#[case("@devup-ui/react-values")] +#[serial] +fn side_effect_dependency_entry_imports_stay_kept(#[case] specifier: &str) -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let source = format!("import '{specifier}';export const color='red';"); + let resolver = |_: &str, _: &str| -> Option { + panic!("entry bare import must not be loaded") + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + &source, + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("color".to_string(), "\"red\"".to_string())] + ); + assert_eq!(imports.kept_imports, [specifier]); + assert_eq!(imports.dependencies.len(), 0); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_helper_css_and_api_subpaths_are_not_evaluated() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import './global.css';import '@devup-ui/react';import '@devup-ui/react/compat';import '@devup-ui/react/styles';export const color='red';".to_string(), + }), + ("./global.css", "/src/helper.ts") => Some(ResolvedModule { + path: "/src/global.css".to_string(), + code: ".card { color: red; }".to_string(), + }), + _ => panic!("API subpaths must not be resolved: {specifier} from {importer}"), + } + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const colorValue=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("colorValue".to_string(), "\"red\"".to_string())] + ); + assert_eq!(imports.dependencies.len(), 1); + assert!(imports.dependencies.contains("/src/helper.ts")); + assert_eq!(imports.kept_imports, ["./global.css"]); + Ok(()) +} + +#[test] +#[serial] +fn side_effect_dependency_external_sibling_package_executes() -> Result<(), String> { + // Given + css::file_map::reset_file_map(); + let resolver = |specifier: &str, importer: &str| { + match (specifier, importer) { + ("./helper", "/src/card.css.ts") => Some(ResolvedModule { + path: "/src/helper.ts".to_string(), + code: "import {state} from './state';import '@devup-ui/react-values';export const color=state.color;" + .to_string(), + }), + ("@devup-ui/react-values", "/src/helper.ts") => Some(ResolvedModule { + path: "/node_modules/@devup-ui/react-values/index.js".to_string(), + code: "import {state} from './state';state.color='blue';".to_string(), + }), + ("./state", "/src/helper.ts" | "/node_modules/@devup-ui/react-values/index.js") => Some(ResolvedModule { + path: "/src/state.ts".to_string(), + code: "export const state={color:'red'};".to_string(), + }), + _ => None, + } + }; + // When + let (collected, imports) = crate::vanilla_extract::execute_stylesheet( + "import {color} from './helper';export const colorValue=color;", + "/src/card.css.ts", + &ExtractOption::default(), + Some(&resolver), + )?; + // Then + assert_eq!( + collected.constant_exports, + [("colorValue".to_string(), "\"blue\"".to_string())] + ); + assert_eq!( + imports.dependencies.into_iter().collect::>(), + [ + "/node_modules/@devup-ui/react-values/index.js", + "/src/helper.ts", + "/src/state.ts" + ] + ); + Ok(()) +} diff --git a/libs/extractor/src/source_map.rs b/libs/extractor/src/source_map.rs index 78b544043..f2f5eb73d 100644 --- a/libs/extractor/src/source_map.rs +++ b/libs/extractor/src/source_map.rs @@ -9,7 +9,7 @@ use crate::import_alias_visit::{Edit, source_offset}; mod trace; -pub(crate) use trace::{Trace, marks}; +pub(crate) use trace::{Trace, complete_marks, marks}; /// Where the lines of a text start, split as the codegen splits them pub(crate) struct Lines<'t> { diff --git a/libs/extractor/src/source_map/trace.rs b/libs/extractor/src/source_map/trace.rs index 589f1ea44..a087c267e 100644 --- a/libs/extractor/src/source_map/trace.rs +++ b/libs/extractor/src/source_map/trace.rs @@ -1,6 +1,12 @@ //! Offsets of generated text, traced back to the text it was made from +use oxc_ast::{ + AstKind, AstType, + ast::{Expression, ParenthesizedExpression, Program}, +}; +use oxc_ast_visit::Visit; use oxc_sourcemap::SourceMap; +use oxc_span::{GetSpan, SourceType}; use super::Lines; use crate::import_alias_visit::{Edit, source_offset}; @@ -22,6 +28,75 @@ pub(crate) fn marks(map: &SourceMap<'_>, generated: &str, parsed: &str) -> Vec<( .collect() } +#[derive(Default)] +struct Syntax { + shape: Vec<(AstType, bool)>, + starts: Vec<(usize, usize)>, +} + +/// The first expression token, not the parentheses codegen uses to group it. +fn expression_start(expression: &Expression<'_>) -> usize { + let expression = expression.get_inner_expression(); + match expression { + Expression::CallExpression(call) => expression_start(&call.callee), + Expression::StaticMemberExpression(member) => expression_start(&member.object), + Expression::ComputedMemberExpression(member) => expression_start(&member.object), + Expression::PrivateFieldExpression(member) => expression_start(&member.object), + _ => expression.span().start as usize, + } +} + +impl<'a> Visit<'a> for Syntax { + fn enter_node(&mut self, kind: AstKind<'a>) { + self.shape.push((kind.ty(), true)); + let original = match kind { + AstKind::CallExpression(call) => expression_start(&call.callee), + AstKind::StaticMemberExpression(member) => expression_start(&member.object), + AstKind::ComputedMemberExpression(member) => expression_start(&member.object), + AstKind::PrivateFieldExpression(member) => expression_start(&member.object), + _ => kind.span().start as usize, + }; + self.starts.push((kind.span().start as usize, original)); + } + + fn leave_node(&mut self, kind: AstKind<'a>) { + self.shape.push((kind.ty(), false)); + } + + fn visit_parenthesized_expression(&mut self, expression: &ParenthesizedExpression<'a>) { + self.visit_expression(&expression.expression); + } +} + +/// Completes sparse codegen marks with matching syntax starts. Pairing is +/// structural, after transformation, so duplicate expressions stay distinct. +pub(crate) fn complete_marks( + program: &Program<'_>, + generated: &str, + marks: &mut Vec<(usize, usize)>, +) { + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, generated, SourceType::default()).parse(); + let mut original = Syntax::default(); + original.visit_program(program); + let mut printed = Syntax::default(); + printed.visit_program(&parsed.program); + if !parsed.diagnostics.is_empty() || original.shape != printed.shape { + return; + } + let sparse = std::mem::take(marks); + marks.extend( + printed + .starts + .into_iter() + .zip(original.starts) + .map(|((at, _), (_, original))| (at, original)) + .filter(|(at, _)| sparse.binary_search_by_key(at, |(at, _)| *at).is_err()), + ); + marks.extend(sparse); + marks.sort_by_key(|&(generated, _)| generated); +} + /// Where the text of a stage came from in the source as written pub(crate) struct Trace { /// `(offset in generated, offset in source)`, ascending, the start of the diff --git a/libs/extractor/src/source_map/trace/tests.rs b/libs/extractor/src/source_map/trace/tests.rs index b68565ea7..f7c6776fa 100644 --- a/libs/extractor/src/source_map/trace/tests.rs +++ b/libs/extractor/src/source_map/trace/tests.rs @@ -49,3 +49,32 @@ fn the_layers_of_edits_are_undone_last_made_first() { assert_eq!(trace.resolve("aXYZdG", "abcdef", 5), 4); assert_eq!(trace.resolve("aXYZdG", "abcdef", 0), 0); } + +#[rstest::rstest] +#[case("const value = foo(bar());")] +#[case("const value = (")] +fn existing_marks_are_preserved_when_printed_syntax_cannot_be_paired(#[case] generated: &str) { + // Given + let source = "const value=foo();bar();"; + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + let mut marked = vec![(0, 6)]; + // When + complete_marks(&parsed.program, generated, &mut marked); + // Then + assert_eq!(Trace::new(&marked, &[]).resolve(generated, source, 0), 6); +} + +#[test] +fn existing_duplicate_marks_keep_the_last_mapping_when_syntax_marks_are_added() { + // Given + let source = "const value=foo();"; + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + let generated = Codegen::new().build(&parsed.program).code; + let mut marked = vec![(0, 0), (0, 6)]; + // When + complete_marks(&parsed.program, &generated, &mut marked); + // Then + assert_eq!(Trace::new(&marked, &[]).resolve(&generated, source, 0), 6); +} diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 231cc6b38..23abfc69c 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -293,8 +293,8 @@ pub(crate) fn execute_located( let file_num = get_file_num_by_filename(filename); let run = loader.script(&entry); let imports = StylesheetImports { - dependencies: loader.dependencies, - kept_imports: loader.kept_imports, + dependencies: std::mem::take(&mut loader.dependencies), + kept_imports: std::mem::take(&mut loader.kept_imports), }; let imported = crate::module_loader::evaluating_import(); if imported @@ -315,6 +315,9 @@ pub(crate) fn execute_located( let mut context = Context::default(); let sandbox = crate::evaluation_sandbox::Sandbox::new(&mut context) .map_err(|error| run.explain(&error.to_string(), filename))?; + loader + .prepare_css(&mut context) + .map_err(|error| run.explain(&error.to_string(), filename))?; let operations = crate::module_loader::operations::Operations::new(&run.text); operations .prepare(&mut context) @@ -387,6 +390,9 @@ fn sandbox_error( crate::evaluation_sandbox::Failure::Forbidden(violations) => violations .iter() .map(|violation| { + if let Some(message) = violation.css_message() { + return run.explain(&rebase(message), filename); + } let recorded = rebase(&violation.error().to_string()); let frames = recorded.find("\n at ").map_or("", |at| &recorded[at..]); let immutable = format!( @@ -671,10 +677,11 @@ fn strip(code: &str, filename: &str) -> Stripped { ..CodegenOptions::default() }) .build(&program); - let marks = generated + let mut marks = generated .map .map(|map| source_map::marks(&map, &generated.code, code)) .unwrap_or_default(); + source_map::complete_marks(&program, &generated.code, &mut marks); Stripped { code: generated.code, marks, From 27666e721e71d07ad641012dfd790b3172979ee3 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 14:11:35 +0900 Subject: [PATCH 12/21] chore(wasm): document helper CSS diagnostics in patch changepack Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .changepacks/changepack_log_located_diagnostics.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changepacks/changepack_log_located_diagnostics.json b/.changepacks/changepack_log_located_diagnostics.json index 9b776aa65..b5cdc04f9 100644 --- a/.changepacks/changepack_log_located_diagnostics.json +++ b/.changepacks/changepack_log_located_diagnostics.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, prove imported module effects before exact stylesheet computation, and prevent placeholder-name collisions from losing styles. Match import package names only at exact or slash-delimited boundaries so sibling data packages remain external.", + "note": "Report extraction, module-resolution and stylesheet execution failures at their original source locations with the cause and repair. Reject recoverable syntax errors, nondeterministic build-time reads, synthetic descriptor/function-source and legacy accessor reflection, and unsafe stylesheet execution fallbacks instead of silently changing CSS. Preserve module resolver and serialization exceptions, evaluate transitive helper side effects, and prevent placeholder-name collisions from losing styles. Match import package names only at exact or slash-delimited boundaries so sibling data packages remain external. Retain helper CSS loads in evaluation order with deduplicated portable relative specifiers and query suffixes, without CSS content dependencies. Report missing CSS or incompatible path roots as located errors, and reject host-recorded unknown CSS export observations even when caught; unused bindings, object passing and typeof follow default TypeScript/JavaScript import semantics.", "date": "2026-10-01T00:00:00.000Z" } From 6d0c9c837598a08103051f51effda5ea65ffb2fc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 15:19:07 +0900 Subject: [PATCH 13/21] test(extractor): cover portable CSS paths and nested member provenance Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/module_loader/tests/css_paths.rs | 43 ++++++++++++ libs/extractor/src/source_map/trace/tests.rs | 67 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/libs/extractor/src/module_loader/tests/css_paths.rs b/libs/extractor/src/module_loader/tests/css_paths.rs index 58c3beeaa..7806029b8 100644 --- a/libs/extractor/src/module_loader/tests/css_paths.rs +++ b/libs/extractor/src/module_loader/tests/css_paths.rs @@ -32,6 +32,49 @@ fn css_retention_relativizes_when_paths_have_compatible_roots( Ok(()) } +#[rstest::rstest] +#[case("/src/card.css.ts", "/../../styles/global.css", "../styles/global.css")] +#[case("/../../src/card.css.ts", "/styles/global.css", "../styles/global.css")] +#[case( + "C:\\src\\card.css.ts", + "c:\\..\\..\\styles\\global.css", + "../styles/global.css" +)] +fn css_retention_clamps_parent_traversal_when_absolute_paths_reach_volume_root( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + Ok(()) +} + +#[rstest::rstest] +#[case( + "src/card.css.ts", + "../../styles/global.css", + "../../../styles/global.css" +)] +#[case( + "../../src/card.css.ts", + "styles/global.css", + "../../../styles/global.css" +)] +fn css_retention_preserves_parent_segments_when_relative_paths_start_above_base( + #[case] root: &str, + #[case] target: &str, + #[case] expected: &str, +) -> Result<(), crate::module_loader::retained_css::PathError> { + // Given / When + let relative = relative_specifier(root, target)?; + // Then + assert_eq!(relative, expected); + Ok(()) +} + #[rstest::rstest] #[case("C:/app/card.css.ts", "D:/styles/global.css")] #[case("src/card.css.ts", "/styles/global.css")] diff --git a/libs/extractor/src/source_map/trace/tests.rs b/libs/extractor/src/source_map/trace/tests.rs index f7c6776fa..758caf5fb 100644 --- a/libs/extractor/src/source_map/trace/tests.rs +++ b/libs/extractor/src/source_map/trace/tests.rs @@ -78,3 +78,70 @@ fn existing_duplicate_marks_keep_the_last_mapping_when_syntax_marks_are_added() // Then assert_eq!(Trace::new(&marked, &[]).resolve(&generated, source, 0), 6); } + +#[rstest::rstest] +#[case::computed_member( + "const value=( function () { return registry; } )()[key].run();", + "(function", + "function" +)] +#[case::private_field( + "class Vault { #run; invoke() { return ( function () { return holder; } )().#run().next(); } }", + "(function", + "function" +)] +fn nested_callee_marks_use_the_original_expression_start_when_grouping_is_printed( + #[case] source: &str, + #[case] generated_group: &str, + #[case] original_expression: &str, +) -> Result<(), &'static str> { + // Given + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let output = Codegen::new() + .with_options(CodegenOptions { + source_map_path: Some(PathBuf::from("nested.js")), + ..CodegenOptions::default() + }) + .build(&parsed.program); + let generated_start = output + .code + .find(generated_group) + .ok_or("expected codegen expression grouping")?; + let original_start = source + .find(original_expression) + .ok_or("expected original expression token")?; + let sparse: Vec<_> = marks( + &output.map.ok_or("expected codegen source map")?, + &output.code, + source, + ) + .into_iter() + .filter(|&(at, _)| at != generated_start) + .collect(); + let mut marked = sparse.clone(); + + // When + complete_marks(&parsed.program, &output.code, &mut marked); + + // Then + let starts: Vec<_> = marked + .iter() + .filter(|&&(at, _)| at == generated_start) + .map(|&(_, original)| original) + .collect(); + assert_eq!(starts.first(), Some(&original_start)); + assert_eq!(starts, vec![original_start; starts.len()]); + assert_eq!( + Trace::new(&marked, &[]).resolve(&output.code, source, generated_start), + original_start + ); + let retained: Vec<_> = marked + .iter() + .copied() + .filter(|(at, _)| sparse.binary_search_by_key(at, |(at, _)| *at).is_ok()) + .collect(); + assert_eq!(retained, sparse); + Ok(()) +} From 0f3ccaef24960efc2f0b9cbd24d011cec04bd80d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 17 Oct 2026 15:14:03 +0900 Subject: [PATCH 14/21] fix(extractor): intern repeated evaluated css key slots Refs #682 #694 #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/vanilla_extract_css.rs | 5 + .../src/vanilla_extract_css_capture.rs | 11 +- .../vanilla_extract_css_key_slots_tests.rs | 175 ++++++++++++++++++ .../src/vanilla_extract_css_values.rs | 1 + 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 libs/extractor/src/vanilla_extract_css_key_slots_tests.rs diff --git a/libs/extractor/src/vanilla_extract_css.rs b/libs/extractor/src/vanilla_extract_css.rs index de993a5bd..7c501acd6 100644 --- a/libs/extractor/src/vanilla_extract_css.rs +++ b/libs/extractor/src/vanilla_extract_css.rs @@ -7,6 +7,10 @@ use super::{StyleCollector, StyleEntry, array_items, js_str}; mod serialization; use serialization::Values; +#[cfg(test)] +#[path = "vanilla_extract_css_key_slots_tests.rs"] +mod key_slots_tests; + fn unsupported() -> boa_engine::JsError { JsNativeError::typ() .with_message("css() needs static plain rules, CSS text or registered classes") @@ -32,6 +36,7 @@ pub(super) fn call( names: &names, collector, active: FxHashSet::default(), + key_slots: FxHashMap::default(), plan: crate::own_css_plan::Plan { origin: collector.borrow().origin.clone(), ..crate::own_css_plan::Plan::default() diff --git a/libs/extractor/src/vanilla_extract_css_capture.rs b/libs/extractor/src/vanilla_extract_css_capture.rs index d0dde9bf6..f4f446d27 100644 --- a/libs/extractor/src/vanilla_extract_css_capture.rs +++ b/libs/extractor/src/vanilla_extract_css_capture.rs @@ -32,8 +32,15 @@ impl Values<'_> { PropertyKey::Index(index) => index.get().to_string(), PropertyKey::Symbol(_) => return Err(unsupported()), }; - let slot = self.plan.slots.len(); - self.plan.slots.push(string_code(&name, self.names)); + let code = string_code(&name, self.names); + let slot = if let Some(&slot) = self.key_slots.get(&code) { + slot + } else { + let slot = self.plan.slots.len(); + self.plan.slots.push(code.clone()); + self.key_slots.insert(code, slot); + slot + }; let value = self.plan.nodes.len(); self.plan.nodes.push(Node::Array(Vec::new())); let arrays = if data_slot(&name) { diff --git a/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs new file mode 100644 index 000000000..e6d66c2cc --- /dev/null +++ b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs @@ -0,0 +1,175 @@ +use super::super::{Collector, StyleCollector, StyleEntry, register_vanilla_extract_apis}; +use crate::{ExtractOption, ExtractOutput, extract_without_source_map, own_css_plan::Node}; +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::ast::Expression; +use oxc_parser::Parser; +use oxc_span::SourceType; +use std::{cell::RefCell, rc::Rc}; + +fn native_call( + collector: &StyleCollector, + context: &mut Context, + source: &str, +) -> Result { + let id = context + .eval(Source::from_bytes(source)) + .map_err(|error| error.to_string())?; + let id = id + .as_string() + .ok_or("css must return a class")? + .to_std_string_escaped(); + collector + .borrow() + .styles + .styles + .get(&id) + .cloned() + .ok_or_else(|| "missing captured call".into()) +} + +fn generated_cardinality(entry: &StyleEntry) -> Result<(usize, usize), String> { + let allocator = Allocator::default(); + let expression = Parser::new( + &allocator, + entry.css.as_deref().ok_or("missing generated call")?, + SourceType::tsx(), + ) + .parse_expression() + .map_err(|error| format!("{error:?}"))?; + let Expression::CallExpression(call) = expression else { + return Err("expected generated call".into()); + }; + let Some(Expression::ArrayExpression(array)) = call.arguments[0].as_expression() else { + return Err("expected bounded-call array".into()); + }; + let wrappers = array + .elements + .iter() + .filter_map(|item| item.as_expression()) + .filter(|item| { + matches!( + crate::utils::unwrap_syntax_only(item), + Expression::ObjectExpression(_) + ) + }) + .count(); + Ok((array.elements.len(), wrappers)) +} + +#[rstest::rstest] +#[case(16)] +#[case(256)] +#[case(4096)] +fn key_slots_bound_generated_ast_when_keys_repeat(#[case] depth: usize) -> Result<(), String> { + // Given: the native API captures every object in a heap-built chain. + let collector = Rc::new(RefCell::new(Collector::default())); + let mut context = Context::default(); + register_vanilla_extract_apis(&mut context, &collector)?; + let source = format!( + "let rules={{p:2}};for(let i=0;i<{depth};i++){{rules={{_hover:rules}}}}__vanilla_extract__.css(rules);" + ); + // When + let entry = native_call(&collector, &mut context, &source)?; + // Then: graph occurrences survive; only generated key expressions share slots. + let plan = entry.own_plan.as_ref().ok_or("missing deep plan")?; + assert_eq!(plan.nodes.len(), depth + 2); + let mut id = plan.roots[0]; + for _ in 0..depth { + let Node::Object(properties) = &plan.nodes[id] else { + return Err("lost object occurrence".into()); + }; + assert_eq!(properties.len(), 1); + assert_eq!(properties[0].name, "_hover"); + id = properties[0].value; + } + let Node::Object(properties) = &plan.nodes[id] else { + return Err("lost leaf object".into()); + }; + assert_eq!(properties.len(), 1); + assert_eq!(properties[0].name, "p"); + assert!(matches!(plan.nodes[properties[0].value], Node::Scalar(_))); + assert_eq!(generated_cardinality(&entry)?, (3, 2)); + Ok(()) +} + +#[test] +fn key_slots_use_each_calls_known_expression_snapshot() -> Result<(), String> { + // Given: identical authored keys become known identifiers only in the second call. + let collector = Rc::new(RefCell::new(Collector::default())); + let mut context = Context::default(); + register_vanilla_extract_apis(&mut context, &collector)?; + let source = concat!( + "(()=>{let rules={", + "p:2};for(let i=0;i<16;i++){rules={_hover:rules}}return __vanilla_extract__.css(rules)})()" + ); + let first = native_call(&collector, &mut context, source)?; + collector + .borrow_mut() + .styles + .styles + .insert("_hover".into(), StyleEntry::default()); + // When + let second = native_call(&collector, &mut context, source)?; + // Then: no cross-call slot indices or raw-name-to-expression cache. + assert_eq!( + first.css.as_deref(), + Some("css([({[\"_hover\"]:0}),({[\"p\"]:0}),2])") + ); + assert_eq!( + second.css.as_deref(), + Some("css([({[_hover]:0}),({[\"p\"]:0}),2])") + ); + assert_eq!(generated_cardinality(&first)?, (3, 2)); + assert_eq!(generated_cardinality(&second)?, (3, 2)); + Ok(()) +} + +fn compile(source: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract_without_source_map("/key-slots.css.ts", source, ExtractOption::default()) + .map_err(|error| error.to_string()) +} + +#[test] +#[serial_test::serial] +fn key_slots_keep_identical_scalar_expression_separate() -> Result<(), String> { + // Given: the scalar is read before an identical key expression is captured. + let selector = format!("&{}", ":hover".repeat(16)); + let expected = compile(&format!( + "import{{css}}from'@devup-ui/react';export const card=css({{fontFamily:'_hover',selectors:{{'{selector}':{{fontFamily:'_hover'}}}}}});" + ))?; + let source = concat!( + "import{css}from'@devup-ui/react';let rules={fontFamily:'_hover'};", + "for(let i=0;i<16;i++){rules={_hover:rules}}", + "export const card=css({fontFamily:'_hover',...rules});" + ); + // When + let output = compile(source)?; + // Then + assert_eq!(output.code, expected.code); + assert_eq!(output.styles, expected.styles); + Ok(()) +} + +#[test] +#[serial_test::serial] +fn key_slots_keep_separate_calls_and_known_class_selectors() -> Result<(), String> { + // Given: two bounded calls have different slot order and registered references. + let selector = format!("&{}", ":hover".repeat(16)); + let expected = compile(&format!( + "import{{css}}from'@devup-ui/react';const base=css({{color:'red'}});export const first=css({{selectors:{{[`${{base}} &`]:{{selectors:{{'{selector}':{{p:2}}}}}}}}}});export const second=css(base,{{selectors:{{'{selector}':{{fontFamily:'_hover'}}}}}});" + ))?; + let source = concat!( + "import{css}from'@devup-ui/react';const base=css({color:'red'});", + "let a={p:2},b={fontFamily:'_hover'};for(let i=0;i<16;i++){a={_hover:a};b={_hover:b}}", + "export const first=css({selectors:{[`${base} &`]:a}});export const second=css(base,b);" + ); + // When + let output = compile(source)?; + // Then + assert_eq!(output.code, expected.code); + assert_eq!(output.styles, expected.styles); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_values.rs b/libs/extractor/src/vanilla_extract_css_values.rs index ccc08653b..5fec8f4e4 100644 --- a/libs/extractor/src/vanilla_extract_css_values.rs +++ b/libs/extractor/src/vanilla_extract_css_values.rs @@ -10,6 +10,7 @@ pub(super) struct Values<'a> { pub names: &'a FxHashMap, pub collector: &'a StyleCollector, pub active: FxHashSet, + pub key_slots: FxHashMap, pub plan: Plan, } From 304779c25bae4bd25e6fb3b64f8a2f901e884207 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 18 Oct 2026 05:30:06 +0900 Subject: [PATCH 15/21] test(extractor): cover evaluated stylesheet bridge contracts Refs #682 #683 #694 #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/dead_properties/instrument.rs | 25 ++++ libs/extractor/src/lib.rs | 2 + .../module_loader/script/coverage_tests.rs | 14 ++ .../serialization_gate_boundaries_tests.rs | 1 + libs/extractor/src/own_css_bindings.rs | 66 +++++++-- libs/extractor/src/own_css_merge.rs | 8 +- libs/extractor/src/own_css_visit.rs | 56 ++++--- .../src/stylesheet_coverage_contract_tests.rs | 138 ++++++++++++++++++ .../src/vanilla_extract_css_capture.rs | 4 + .../src/vanilla_extract_css_capture_tests.rs | 60 ++++++++ .../vanilla_extract_css_key_slots_tests.rs | 9 +- 11 files changed, 331 insertions(+), 52 deletions(-) create mode 100644 libs/extractor/src/stylesheet_coverage_contract_tests.rs create mode 100644 libs/extractor/src/vanilla_extract_css_capture_tests.rs diff --git a/libs/extractor/src/dead_properties/instrument.rs b/libs/extractor/src/dead_properties/instrument.rs index 7f04a73b7..549c7d4d9 100644 --- a/libs/extractor/src/dead_properties/instrument.rs +++ b/libs/extractor/src/dead_properties/instrument.rs @@ -78,3 +78,28 @@ pub(crate) fn instrument_evaluated( edits, }) } + +#[cfg(test)] +mod tests { + #[test] + fn evaluated_instrumentation_prefers_the_authored_dead_key() -> Result<(), String> { + // Given: retained evaluation reports a different dead key than the authored call. + let source = "import{css}from'@devup-ui/react';\ncss({boxAlign:'start'});"; + let code = "import{css}from'@devup-ui/react';css({boxPack:'end'});"; + let input = super::Stylesheet { + source, + code, + filename: "original.css.ts", + edits: &[], + }; + // When + let error = super::instrument_evaluated(input, "@devup-ui/react", Some(&[])) + .err() + .ok_or("authored dead key must reject")?; + // Then + assert!(error.starts_with("original.css.ts:2:6:"), "{error}"); + assert!(error.contains("boxAlign"), "{error}"); + assert_eq!(error.matches("boxPack").count(), 0, "{error}"); + Ok(()) + } +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 3a82e072a..dc019f9d5 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -44,6 +44,8 @@ mod styled_reads; #[cfg(test)] mod styled_source_tests; #[cfg(test)] +mod stylesheet_coverage_contract_tests; +#[cfg(test)] mod stylesheet_css_adapter_error_tests; #[cfg(test)] mod stylesheet_css_adapter_tests; diff --git a/libs/extractor/src/module_loader/script/coverage_tests.rs b/libs/extractor/src/module_loader/script/coverage_tests.rs index e514114b3..1d9f041c2 100644 --- a/libs/extractor/src/module_loader/script/coverage_tests.rs +++ b/libs/extractor/src/module_loader/script/coverage_tests.rs @@ -4,6 +4,20 @@ use serial_test::serial; use super::Unit; use crate::{ExtractOption, ExtractOutput}; +#[test] +fn generated_only_script_errors_use_file_fallback() { + // Given + let mut script = super::Script::default(); + script.generated("throw new Error('generated');"); + // When + let error = script.explain("TypeError: generated", "generated.css.ts"); + // Then + assert_eq!( + error, + "generated.css.ts:1:1: JS execution error: TypeError: generated. Fix: check that every value read, called or constructed here exists when the stylesheet is evaluated at build time" + ); +} + #[test] fn retained_code_rejects_malformed_source_map_instead_of_inventing_a_trace() -> Result<(), String> { // Given diff --git a/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs b/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs index dbafc495a..63f1c7a2c 100644 --- a/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs +++ b/libs/extractor/src/module_loader/serialization_gate_boundaries_tests.rs @@ -20,6 +20,7 @@ use rustc_hash::FxHashMap; "({get first(){reads++;return 1},get next(){reads++;return ()=>1},last:{value:1}})", 2 )] +#[case("[()=>1,()=>2,new Map(),(()=>{const v={};v.self=v;return v})()]", 0)] fn gate_boundaries_export_fallback_preserves_getter_short_circuit( #[case] expression: &str, #[case] expected_reads: u32, diff --git a/libs/extractor/src/own_css_bindings.rs b/libs/extractor/src/own_css_bindings.rs index 8b35aab68..24e9d8f31 100644 --- a/libs/extractor/src/own_css_bindings.rs +++ b/libs/extractor/src/own_css_bindings.rs @@ -9,22 +9,24 @@ use oxc_ast::ast::{Expression, ObjectPropertyKind, Program, VariableDeclarator}; use oxc_ast_visit::Visit; use rustc_hash::FxHashMap; +struct Calls { + named: FxHashMap, + spans: FxHashMap<(u32, u32), Plan>, +} + +impl<'a> Visit<'a> for Calls { + fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { + if let Some(name) = it.id.get_binding_identifier() + && let Some(plan) = self.named.remove(name.name.as_str()) + && let Some(Expression::CallExpression(call)) = &it.init + { + self.spans.insert((call.span.start, call.span.end), plan); + } + } +} + impl<'a> DevupVisitor<'a> { pub(crate) fn own_rules(&mut self, named: FxHashMap, program: &Program<'a>) { - struct Calls { - named: FxHashMap, - spans: FxHashMap<(u32, u32), Plan>, - } - impl<'a> Visit<'a> for Calls { - fn visit_variable_declarator(&mut self, it: &VariableDeclarator<'a>) { - if let Some(name) = it.id.get_binding_identifier() - && let Some(plan) = self.named.remove(name.name.as_str()) - && let Some(Expression::CallExpression(call)) = &it.init - { - self.spans.insert((call.span.start, call.span.end), plan); - } - } - } let mut calls = Calls { named, spans: FxHashMap::default(), @@ -77,3 +79,39 @@ impl<'a> DevupVisitor<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binding_visitor_only_associates_named_call_initializers() { + // Given + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new( + &allocator, + "const retained=css([]), ignored=0;const {destructured}=source;", + oxc_span::SourceType::mjs(), + ) + .parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut calls = Calls { + named: ["retained", "ignored", "destructured"] + .into_iter() + .map(|name| (name.into(), Plan::default())) + .collect(), + spans: FxHashMap::default(), + }; + // When + calls.visit_program(&parsed.program); + // Then + assert_eq!( + calls.spans.keys().copied().collect::>(), + vec![(15, 22)] + ); + assert_eq!( + calls.named.keys().map(String::as_str).collect::>(), + vec!["destructured"] + ); + } +} diff --git a/libs/extractor/src/own_css_merge.rs b/libs/extractor/src/own_css_merge.rs index cc2429d84..7781b9155 100644 --- a/libs/extractor/src/own_css_merge.rs +++ b/libs/extractor/src/own_css_merge.rs @@ -6,13 +6,13 @@ pub(crate) fn merge(plan: &mut Plan, roots: &[usize]) -> usize { for &source in roots { let mut pending = vec![(root, source)]; while let Some((target, source)) = pending.pop() { - let Node::Object(added) = &plan.nodes[source] else { + let (Node::Object(added), Node::Object(properties)) = + (&plan.nodes[source], &plan.nodes[target]) + else { continue; }; let added = added.clone(); - let Node::Object(mut properties) = plan.nodes[target].clone() else { - continue; - }; + let mut properties = properties.clone(); for mut property in added { if let Some(index) = properties.iter().position(|p| p.name == property.name) { let previous = properties.remove(index); diff --git a/libs/extractor/src/own_css_visit.rs b/libs/extractor/src/own_css_visit.rs index 9691974b5..2607e4ff8 100644 --- a/libs/extractor/src/own_css_visit.rs +++ b/libs/extractor/src/own_css_visit.rs @@ -22,38 +22,34 @@ struct RulePart { impl<'a> DevupVisitor<'a> { pub(super) fn compile_own_rules(&mut self, expression: &mut Expression<'a>) -> bool { - let Expression::CallExpression(call) = expression else { - return false; - }; - let Some(plan) = self.own_rules.remove(&(call.span.start, call.span.end)) else { - return false; - }; - let offset = call.span.start; - let Some(Expression::ArrayExpression(array)) = - call.arguments.first().and_then(Argument::as_expression) - else { - return false; - }; - let origin = plan.origin.clone(); - let mut rules = self.bind_own_rules(plan, array); - let start = self.errors.len(); - *expression = if rules.plan.template.is_some() { - self.own_template(&mut rules, offset) - } else { - let part = RulePart { - roots: rules.plan.roots.clone(), - composing: rules.plan.composing, + if let Expression::CallExpression(call) = expression + && let Some(plan) = self.own_rules.remove(&(call.span.start, call.span.end)) + && let Some(Expression::ArrayExpression(array)) = + call.arguments.first().and_then(Argument::as_expression) + { + let offset = call.span.start; + let origin = plan.origin.clone(); + let mut rules = self.bind_own_rules(plan, array); + let start = self.errors.len(); + *expression = if rules.plan.template.is_some() { + self.own_template(&mut rules, offset) + } else { + let part = RulePart { + roots: rules.plan.roots.clone(), + composing: rules.plan.composing, + }; + self.own_class(&mut rules, part, offset) }; - self.own_class(&mut rules, part, offset) - }; - if let Some((location, api)) = origin { - self.own_failures - .extend(self.errors.drain(start..).map(|(_, message)| { - let message = message.replacen("`css()`", &format!("`{api}()`"), 1); - format!("{location}: {message}") - })); + if let Some((location, api)) = origin { + self.own_failures + .extend(self.errors.drain(start..).map(|(_, message)| { + let message = message.replacen("`css()`", &format!("`{api}()`"), 1); + format!("{location}: {message}") + })); + } + return true; } - true + false } fn own_errors(&mut self, offset: u32, styles: &[ExtractStyleProp<'a>]) { diff --git a/libs/extractor/src/stylesheet_coverage_contract_tests.rs b/libs/extractor/src/stylesheet_coverage_contract_tests.rs new file mode 100644 index 000000000..e10e06a5f --- /dev/null +++ b/libs/extractor/src/stylesheet_coverage_contract_tests.rs @@ -0,0 +1,138 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, extract_without_source_map}; +use serial_test::serial; + +#[test] +#[serial] +fn coverage_contract_rejects_dead_font_face_descriptor_when_media_query_is_imports() +-> Result<(), String> { + // Given + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let source = "import{globalCss}from'@devup-ui/react';globalCss({'@media':{imports:{fontFaces:[{fontFamily:'Example',boxAlign:'center'}]}}});"; + let column = source.find("boxAlign").ok_or("missing authored key")? + 1; + // When + let error = + extract_without_source_map("/coverage-contract.tsx", source, ExtractOption::default()) + .err() + .ok_or("dead font-face descriptor accepted")? + .to_string(); + // Then + assert!( + error.starts_with(&format!("/coverage-contract.tsx:1:{column}:")), + "{error}" + ); + assert!(error.contains("`boxAlign`"), "{error}"); + assert!(error.contains("box-align"), "{error}"); + Ok(()) +} + +fn compile(body: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + extract_without_source_map( + "/coverage-contract.css.ts", + &format!("import{{css}}from'@devup-ui/react';{body}"), + ExtractOption::default(), + ) + .map_err(|error| error.to_string()) +} + +#[rstest::rstest] +#[case("css(base,{props:data,styleOrder:3,p:2})")] +#[case("css('external',{props:data,p:2})")] +#[case("css(['external',null,false,undefined,{props:data,p:2}])")] +#[case("css({props:data,_hover:{as:'section',p:2}})")] +#[case("css({props:data,_nthChild:{params:[{},[],'2n'],p:2}})")] +#[case(concat!("css({props:data,':hover':{", "p:2},'[data-on]':{", "w:3},'> a':{", "h:4}})"))] +#[case(concat!("css({props:data,'@media (min-width:1px)':{'@media not print and (color)':{", "p:2}}})"))] +#[serial] +fn coverage_contract_bridge_keeps_shallow_rules(#[case] expression: &str) -> Result<(), String> { + // Given: independent shallow extraction and an ignored, heap-built payload. + let prefix = "const base=css({color:'red'});"; + let shallow = expression.replace("props:data", "props:0"); + let expected = compile(&format!("{prefix}export const card={shallow};"))?; + assert!(expected.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property() == "padding" && style.value() == "8px" + ))); + let source = format!( + "{prefix}let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card={expression};" + ); + // When + let actual = compile(&source)?; + // Then + assert_eq!(actual.code, expected.code); + assert_eq!(actual.styles, expected.styles); + Ok(()) +} + +#[rstest::rstest] +#[case("selectors:{invalidSelector:{p:2}}")] +#[case(concat!("_invalidSelector:{", "p:2}"))] +#[serial] +fn coverage_contract_bridge_rejects_invalid_selectors(#[case] rules: &str) -> Result<(), String> { + // Given + let source = format!( + "let data=0;for(let i=0;i<16;i++)data={{payload:data}};export const card=css({{props:data,{rules}}});" + ); + // When + let error = compile(&source).err().ok_or("invalid selector accepted")?; + // Then + assert!(error.starts_with("/coverage-contract.css.ts:1:"), "{error}"); + assert!(error.contains(crate::utils::SELECTOR_NAME), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn coverage_contract_bridge_template_keeps_scalar_declarations() -> Result<(), String> { + // Given: a structured statement forces the bridge alongside literal CSS values. + let source = concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};export const card=css`color:${'red'};${{props:data,p:2}}`;" + ); + // When + let output = compile(source)?; + // Then + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property() == "color" && style.value() == "red" + ))); + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property() == "padding" && style.value() == "8px" + ))); + Ok(()) +} + +#[test] +#[serial] +fn coverage_contract_bridge_template_rejects_unplaced_scalar() -> Result<(), String> { + // Given: the text interpolation is not a declaration or a standalone statement. + let source = concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};export const card=css`color:red;${{props:data,p:2}} ${'orphan'} {", + "color:blue}`;" + ); + // When + let error = compile(source).err().ok_or("unplaced scalar accepted")?; + // Then + assert!(error.starts_with("/coverage-contract.css.ts:1:"), "{error}"); + assert!(error.contains("css"), "{error}"); + Ok(()) +} + +#[test] +#[serial] +fn coverage_contract_bridge_rejects_responsive_arrays_in_data_slots() -> Result<(), String> { + // Given: capture preserves vars as data; lowering still checks its responsive shape. + let source = concat!( + "let data=0;for(let i=0;i<16;i++)data={", + "payload:data};export const card=css({props:data,vars:[[1,2]]});" + ); + // When + let error = compile(source) + .err() + .ok_or("nested responsive vars accepted")?; + // Then + assert!(error.starts_with("/coverage-contract.css.ts:1:"), "{error}"); + assert!(error.contains(crate::utils::RESPONSIVE_ARRAY), "{error}"); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_capture.rs b/libs/extractor/src/vanilla_extract_css_capture.rs index f4f446d27..dbf511a29 100644 --- a/libs/extractor/src/vanilla_extract_css_capture.rs +++ b/libs/extractor/src/vanilla_extract_css_capture.rs @@ -4,6 +4,10 @@ use super::Values; use crate::own_css_plan::{Node, Plan, Property}; use boa_engine::{Context, JsObject, JsResult, JsValue, property::PropertyKey}; +#[cfg(test)] +#[path = "vanilla_extract_css_capture_tests.rs"] +mod tests; + #[derive(Clone, Copy)] enum Arrays { Data, diff --git a/libs/extractor/src/vanilla_extract_css_capture_tests.rs b/libs/extractor/src/vanilla_extract_css_capture_tests.rs new file mode 100644 index 000000000..33fc6f85c --- /dev/null +++ b/libs/extractor/src/vanilla_extract_css_capture_tests.rs @@ -0,0 +1,60 @@ +use super::{Values, validate}; +use crate::{own_css_plan::Plan, vanilla_extract::Collector}; +use boa_engine::{Context, Source}; +use rustc_hash::{FxHashMap, FxHashSet}; +use std::{cell::RefCell, rc::Rc}; + +#[test] +fn capture_keeps_numeric_data_keys_and_scalar_declarations() -> Result<(), String> { + // Given: a numeric key belongs to ignored props, not a CSS declaration. + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes("({props:{0:'data'},color:'red'})")) + .map_err(|error| error.to_string())?; + // When + let root = values + .capture(&value, &mut context) + .map_err(|error| error.to_string())?; + // Then + assert_eq!( + values.plan.code(root), + r#"{"props":{"0":"data"},"color":"red"}"# + ); + assert_eq!(validate(&values.plan, root), vec![]); + Ok(()) +} + +#[test] +fn capture_rejects_nested_responsive_arrays_before_lowering() -> Result<(), String> { + // Given + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes("({p:[[1,2]]})")) + .map_err(|error| error.to_string())?; + // When + let error = values + .capture(&value, &mut context) + .err() + .ok_or("nested array accepted")?; + // Then + assert!(error.to_string().contains(crate::utils::RESPONSIVE_ARRAY)); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs index e6d66c2cc..a275f8b56 100644 --- a/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs +++ b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs @@ -30,15 +30,16 @@ fn native_call( fn generated_cardinality(entry: &StyleEntry) -> Result<(usize, usize), String> { let allocator = Allocator::default(); - let expression = Parser::new( + let Ok(expression) = Parser::new( &allocator, entry.css.as_deref().ok_or("missing generated call")?, SourceType::tsx(), ) - .parse_expression() - .map_err(|error| format!("{error:?}"))?; + .parse_expression() else { + panic!("generated bounded call must parse"); + }; let Expression::CallExpression(call) = expression else { - return Err("expected generated call".into()); + panic!("expected generated call"); }; let Some(Expression::ArrayExpression(array)) = call.arguments[0].as_expression() else { return Err("expected bounded-call array".into()); From 425eb47bc82b956f60ced6bb4b7d14ff5ab79442 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 22 Oct 2026 15:10:22 +0900 Subject: [PATCH 16/21] test(extractor): batch deep heap fixture construction Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_deep_fixture_cost.json | 7 +++++++ .../src/stylesheet_deep_object_tests.rs | 12 ++++++++---- .../src/stylesheet_gate_boundaries_deep_tests.rs | 16 ++++++++++------ 3 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 .changepacks/changepack_log_deep_fixture_cost.json diff --git a/.changepacks/changepack_log_deep_fixture_cost.json b/.changepacks/changepack_log_deep_fixture_cost.json new file mode 100644 index 000000000..e18726b63 --- /dev/null +++ b/.changepacks/changepack_log_deep_fixture_cost.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Keep 100000-level heap stress coverage while reducing fixture construction overhead. No new build errors or runtime behavior changes.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/libs/extractor/src/stylesheet_deep_object_tests.rs b/libs/extractor/src/stylesheet_deep_object_tests.rs index 6ac96bb5b..858d1aaba 100644 --- a/libs/extractor/src/stylesheet_deep_object_tests.rs +++ b/libs/extractor/src/stylesheet_deep_object_tests.rs @@ -48,7 +48,10 @@ fn deep_object_ignored_data_is_captured_without_nested_generated_syntax() -> Res "import{", "css}from'@devup-ui/react';export const card=css({p:2});" ))?; - let source = "import{css}from'@devup-ui/react';let data=1;for(let i=0;i<100000;i++){data={payload:data}}export const card=css({props:data,p:2});"; + let source = concat!( + "import{css}from'@devup-ui/react';let data=1;for(let i=0;i<25000;i++){data={payload:{payload:{payload:{", + "payload:data}}}}}export const card=css({props:data,p:2});" + ); // When let output = compile(source)?; // Then @@ -127,9 +130,10 @@ fn deep_object_shallow_contexts_match_ordinary_compilation( #[rstest::rstest] #[case(concat!("let value={", "};value.self=value;css({props:value});"))] #[case("let value={};const a=[value];value.a=a;css(value);")] -#[case( - "let value=()=>1;for(let i=0;i<100000;i++){value={payload:value}}try{css({props:value})}catch(e){}" -)] +#[case(concat!( + "let value=()=>1;for(let i=0;i<25000;i++){value={payload:{payload:{payload:{", + "payload:value}}}}}try{css({props:value})}catch(e){}" +))] #[serial] fn deep_object_invalid_graphs_remain_terminal(#[case] body: &str) -> Result<(), String> { // Given diff --git a/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs b/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs index 718f2dbea..abdc5a5ad 100644 --- a/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs +++ b/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs @@ -16,7 +16,7 @@ fn gate_boundaries_heap_deep_composition_uses_default_native_stack() -> Result<( .map_err(|error| error.to_string())?; reset_class_map(); reset_file_map(); - let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<100000;i++){rules=[rules]}export const card=css(rules);"; + let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<25000;i++){rules=[[[[rules]]]]}export const card=css(rules);"; // When let output = extract_without_source_map("/heap.css.ts", source, ExtractOption::default()) .map_err(|error| error.to_string())?; @@ -27,11 +27,15 @@ fn gate_boundaries_heap_deep_composition_uses_default_native_stack() -> Result<( } #[rstest::rstest] -#[case("let value=()=>1;for(let i=0;i<100000;i++){value=[value]}export const card=css({p:value});")] #[case( - "const first=[];let rules=first;for(let i=0;i<100000;i++){rules=[rules]}first.push(rules);export const card=css(rules);" + "let value=()=>1;for(let i=0;i<25000;i++){value=[[[[value]]]]}export const card=css({p:value});" +)] +#[case( + "const first=[];let rules=first;for(let i=0;i<25000;i++){rules=[[[[rules]]]]}first.push(rules);export const card=css(rules);" +)] +#[case( + "let value=1;for(let i=0;i<25000;i++){value=[[[[value]]]]}export const card=css({p:value});" )] -#[case("let value=1;for(let i=0;i<100000;i++){value=[value]}export const card=css({p:value});")] #[serial] fn gate_boundaries_heap_deep_invalid_data_returns_located_error( #[case] body: &str, @@ -66,7 +70,7 @@ fn gate_boundaries_heap_deep_tag_composition_matches_flat_rules() -> Result<(), .map_err(|error| error.to_string())?; reset_class_map(); reset_file_map(); - let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<100000;i++){rules=[rules]}export const card=css`${rules}`;"; + let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<25000;i++){rules=[[[[rules]]]]}export const card=css`${rules}`;"; // When let output = extract_without_source_map("/heap-tag.css.ts", source, ExtractOption::default()) .map_err(|error| error.to_string())?; @@ -93,7 +97,7 @@ fn gate_boundaries_heap_deep_object_matches_flat_selector_at_default_stack() -> reset_file_map(); let source = concat!( "import{", - "css}from'@devup-ui/react';let rules={p:2};for(let i=0;i<100000;i++){rules={_hover:rules}}export const card=css(rules);" + "css}from'@devup-ui/react';let rules={p:2};for(let i=0;i<25000;i++){rules={_hover:{_hover:{_hover:{_hover:rules}}}}}export const card=css(rules);" ); // When let output = From f497aa85fa5decf102ccc69895cde0ec9d0a21db Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 22 Oct 2026 16:17:34 +0900 Subject: [PATCH 17/21] test(extractor): trim deep fixture loop bookkeeping Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/stylesheet_deep_object_tests.rs | 8 ++++---- .../src/stylesheet_gate_boundaries_deep_tests.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/libs/extractor/src/stylesheet_deep_object_tests.rs b/libs/extractor/src/stylesheet_deep_object_tests.rs index 858d1aaba..19798281d 100644 --- a/libs/extractor/src/stylesheet_deep_object_tests.rs +++ b/libs/extractor/src/stylesheet_deep_object_tests.rs @@ -49,8 +49,8 @@ fn deep_object_ignored_data_is_captured_without_nested_generated_syntax() -> Res "css}from'@devup-ui/react';export const card=css({p:2});" ))?; let source = concat!( - "import{css}from'@devup-ui/react';let data=1;for(let i=0;i<25000;i++){data={payload:{payload:{payload:{", - "payload:data}}}}}export const card=css({props:data,p:2});" + "import{css}from'@devup-ui/react';let data=1;for(let i=12500;i--;){data={payload:{payload:{payload:{payload:{payload:{payload:{payload:{", + "payload:data}}}}}}}}}export const card=css({props:data,p:2});" ); // When let output = compile(source)?; @@ -131,8 +131,8 @@ fn deep_object_shallow_contexts_match_ordinary_compilation( #[case(concat!("let value={", "};value.self=value;css({props:value});"))] #[case("let value={};const a=[value];value.a=a;css(value);")] #[case(concat!( - "let value=()=>1;for(let i=0;i<25000;i++){value={payload:{payload:{payload:{", - "payload:value}}}}}try{css({props:value})}catch(e){}" + "let value=()=>1;for(let i=12500;i--;){value={payload:{payload:{payload:{payload:{payload:{payload:{payload:{", + "payload:value}}}}}}}}}try{css({props:value})}catch(e){}" ))] #[serial] fn deep_object_invalid_graphs_remain_terminal(#[case] body: &str) -> Result<(), String> { diff --git a/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs b/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs index abdc5a5ad..97da57862 100644 --- a/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs +++ b/libs/extractor/src/stylesheet_gate_boundaries_deep_tests.rs @@ -16,7 +16,7 @@ fn gate_boundaries_heap_deep_composition_uses_default_native_stack() -> Result<( .map_err(|error| error.to_string())?; reset_class_map(); reset_file_map(); - let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<25000;i++){rules=[[[[rules]]]]}export const card=css(rules);"; + let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=12500;i--;){rules=[[[[[[[[rules]]]]]]]]}export const card=css(rules);"; // When let output = extract_without_source_map("/heap.css.ts", source, ExtractOption::default()) .map_err(|error| error.to_string())?; @@ -28,13 +28,13 @@ fn gate_boundaries_heap_deep_composition_uses_default_native_stack() -> Result<( #[rstest::rstest] #[case( - "let value=()=>1;for(let i=0;i<25000;i++){value=[[[[value]]]]}export const card=css({p:value});" + "let value=()=>1;for(let i=12500;i--;){value=[[[[[[[[value]]]]]]]]}export const card=css({p:value});" )] #[case( - "const first=[];let rules=first;for(let i=0;i<25000;i++){rules=[[[[rules]]]]}first.push(rules);export const card=css(rules);" + "const first=[];let rules=first;for(let i=12500;i--;){rules=[[[[[[[[rules]]]]]]]]}first.push(rules);export const card=css(rules);" )] #[case( - "let value=1;for(let i=0;i<25000;i++){value=[[[[value]]]]}export const card=css({p:value});" + "let value=1;for(let i=12500;i--;){value=[[[[[[[[value]]]]]]]]}export const card=css({p:value});" )] #[serial] fn gate_boundaries_heap_deep_invalid_data_returns_located_error( @@ -70,7 +70,7 @@ fn gate_boundaries_heap_deep_tag_composition_matches_flat_rules() -> Result<(), .map_err(|error| error.to_string())?; reset_class_map(); reset_file_map(); - let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=0;i<25000;i++){rules=[[[[rules]]]]}export const card=css`${rules}`;"; + let source = "import{css}from'@devup-ui/react';let rules={p:2,color:'red'};for(let i=12500;i--;){rules=[[[[[[[[rules]]]]]]]]}export const card=css`${rules}`;"; // When let output = extract_without_source_map("/heap-tag.css.ts", source, ExtractOption::default()) .map_err(|error| error.to_string())?; @@ -97,7 +97,7 @@ fn gate_boundaries_heap_deep_object_matches_flat_selector_at_default_stack() -> reset_file_map(); let source = concat!( "import{", - "css}from'@devup-ui/react';let rules={p:2};for(let i=0;i<25000;i++){rules={_hover:{_hover:{_hover:{_hover:rules}}}}}export const card=css(rules);" + "css}from'@devup-ui/react';let rules={p:2};for(let i=12500;i--;){rules={_hover:{_hover:{_hover:{_hover:{_hover:{_hover:{_hover:{_hover:rules}}}}}}}}}export const card=css(rules);" ); // When let output = From 6d68063bf141f6760252e1b48fc320c804d92253 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 22 Oct 2026 19:13:26 +0900 Subject: [PATCH 18/21] test(extractor): cover captured stylesheet contracts Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...changepack_log_css_capture_validation.json | 7 ++ .../src/stylesheet_coverage_contract_tests.rs | 74 +++++++++++++++++++ .../src/vanilla_extract_css_capture.rs | 9 ++- .../src/vanilla_extract_css_capture_tests.rs | 31 ++++++++ .../vanilla_extract_css_key_slots_tests.rs | 37 ++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 .changepacks/changepack_log_css_capture_validation.json diff --git a/.changepacks/changepack_log_css_capture_validation.json b/.changepacks/changepack_log_css_capture_validation.json new file mode 100644 index 000000000..84470eef1 --- /dev/null +++ b/.changepacks/changepack_log_css_capture_validation.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Narrow CSS capture validation states and cover namespace tags, registered template composition, nested responsive rejection and bounded-call checker errors without changing runtime behavior.", + "date": "2026-10-22T00:00:00.000Z" +} diff --git a/libs/extractor/src/stylesheet_coverage_contract_tests.rs b/libs/extractor/src/stylesheet_coverage_contract_tests.rs index e10e06a5f..2d646e640 100644 --- a/libs/extractor/src/stylesheet_coverage_contract_tests.rs +++ b/libs/extractor/src/stylesheet_coverage_contract_tests.rs @@ -136,3 +136,77 @@ fn coverage_contract_bridge_rejects_responsive_arrays_in_data_slots() -> Result< assert!(error.contains(crate::utils::RESPONSIVE_ARRAY), "{error}"); Ok(()) } + +#[test] +#[serial] +fn coverage_contract_namespace_tag_composes_constant_rules() -> Result<(), String> { + // Given: an ordinary call independently defines the expected declarations. + let expected = compile("export const card=css({p:2,color:'tomato'});")?; + let source = "import * as UI from '@devup-ui/react';const rules={p:2,color:'tomato'};export const card=UI.css`${rules}`;"; + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + // When + let output = extract_without_source_map( + "/coverage-contract.css.ts", + source, + ExtractOption::default(), + ) + .map_err(|error| error.to_string())?; + // Then + let card = output + .code + .lines() + .find(|line| line.starts_with("export const card = ")) + .ok_or("missing namespace card export")?; + let expected_card = expected + .code + .lines() + .find(|line| line.starts_with("export const card = ")) + .ok_or("missing ordinary card export")?; + assert_eq!(card, expected_card); + assert_eq!(output.styles, expected.styles); + Ok(()) +} + +#[test] +#[serial] +fn coverage_contract_bridge_template_keeps_registered_scalar_on_card() -> Result<(), String> { + // Given: a fresh ordinary call supplies the base class independently of the template. + let base = compile(concat!("export const card=css({", "p:1});"))?; + let base = base + .code + .split_once("export const card = \"") + .and_then(|(_, tail)| tail.split_once('"')) + .ok_or("missing ordinary base class")? + .0; + let source = concat!( + "const base=css({p:1});let data=0;for(let i=0;i<128;i++)data={", + "payload:data};export const card=css`color:blue; ${base} ${{props:data,p:2}}`;" + ); + // When + let output = compile(source)?; + // Then: the base must be selected by card, not merely present in the stylesheet. + let card = output + .code + .split_once("export const card = \"") + .and_then(|(_, tail)| tail.split_once('"')) + .ok_or("missing compiled card classes")? + .0; + assert!( + card.split_whitespace().any(|class| class == base), + "{output:?}" + ); + for (property, value) in [("color", "blue"), ("padding", "8px")] { + assert!( + output.styles.iter().any(|style| { + matches!(style, ExtractStyleValue::Static(style) + if style.property() == property && style.value() == value) + && matches!(style.extract(Some("/coverage-contract.css.ts")), + Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) + if card.split_whitespace().any(|class| class == name)) + }), + "missing selected {property}:{value}: {output:?}" + ); + } + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_capture.rs b/libs/extractor/src/vanilla_extract_css_capture.rs index dbf511a29..60e4f54bb 100644 --- a/libs/extractor/src/vanilla_extract_css_capture.rs +++ b/libs/extractor/src/vanilla_extract_css_capture.rs @@ -129,7 +129,11 @@ fn data_slot(name: &str) -> bool { } pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> { - use crate::dead_properties::ObjectKind; + #[derive(Clone, Copy)] + enum ObjectKind { + Styles, + Selectors, + } enum Step<'a> { Value(usize, ObjectKind, bool), Key(&'a Property, ObjectKind, bool), @@ -173,10 +177,9 @@ pub(super) fn validate(plan: &Plan, root: usize) -> Vec<(String, &'static str)> pending.push(Step::Value(property.value, kind, entry)); } } - ObjectKind::Selectors | ObjectKind::Records => { + ObjectKind::Selectors => { pending.push(Step::Value(property.value, ObjectKind::Styles, entry)); } - ObjectKind::Globals => {} } } Step::Value(id, kind, entry) => match &plan.nodes[id] { diff --git a/libs/extractor/src/vanilla_extract_css_capture_tests.rs b/libs/extractor/src/vanilla_extract_css_capture_tests.rs index 33fc6f85c..5874a3731 100644 --- a/libs/extractor/src/vanilla_extract_css_capture_tests.rs +++ b/libs/extractor/src/vanilla_extract_css_capture_tests.rs @@ -58,3 +58,34 @@ fn capture_rejects_nested_responsive_arrays_before_lowering() -> Result<(), Stri assert!(error.to_string().contains(crate::utils::RESPONSIVE_ARRAY)); Ok(()) } + +#[rstest::rstest] +#[case("({_hover:[{p:[1,2]}]})", "_hover -> 0 -> p")] +#[case(concat!("({_hover:[{_hover:[{", "p:2}]}]})"), "_hover -> 0 -> _hover")] +fn capture_validation_rejects_responsive_arrays_across_entry_objects( + #[case] source: &str, + #[case] path: &str, +) -> Result<(), String> { + // Given: real capture resets array context at each property of an entry object. + let names = FxHashMap::default(); + let collector = Rc::new(RefCell::new(Collector::default())); + let mut values = Values { + names: &names, + collector: &collector, + active: FxHashSet::default(), + key_slots: FxHashMap::default(), + plan: Plan::default(), + }; + let mut context = Context::default(); + let value = context + .eval(Source::from_bytes(source)) + .map_err(|error| error.to_string())?; + let root = values + .capture(&value, &mut context) + .map_err(|error| error.to_string())?; + // When + let errors = validate(&values.plan, root); + // Then + assert_eq!(errors, vec![(path.into(), crate::utils::RESPONSIVE_ARRAY)]); + Ok(()) +} diff --git a/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs index a275f8b56..77712d2db 100644 --- a/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs +++ b/libs/extractor/src/vanilla_extract_css_key_slots_tests.rs @@ -58,6 +58,43 @@ fn generated_cardinality(entry: &StyleEntry) -> Result<(usize, usize), String> { Ok((array.elements.len(), wrappers)) } +#[test] +#[should_panic(expected = "generated bounded call must parse")] +fn key_slots_checker_rejects_malformed_generated_syntax() { + // Given + let entry = StyleEntry { + css: Some("css([".into()), + ..StyleEntry::default() + }; + // When / Then: the checker must reject unparseable generated syntax. + assert!(generated_cardinality(&entry).is_ok()); +} + +#[test] +#[should_panic(expected = "expected generated call")] +fn key_slots_checker_rejects_non_call_expression() { + // Given + let entry = StyleEntry { + css: Some("[]".into()), + ..StyleEntry::default() + }; + // When / Then: a parseable expression is not sufficient to be a bounded call. + assert!(generated_cardinality(&entry).is_ok()); +} + +#[test] +fn key_slots_checker_rejects_non_array_argument() { + // Given + let entry = StyleEntry { + css: Some(concat!("css({", "p:2})").into()), + ..StyleEntry::default() + }; + // When + let result = generated_cardinality(&entry); + // Then + assert_eq!(result, Err("expected bounded-call array".into())); +} + #[rstest::rstest] #[case(16)] #[case(256)] From 1d6f7915405b20b17cf7bad5026097c77dbff028 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 22 Oct 2026 23:29:06 +0900 Subject: [PATCH 19/21] test(extractor): cover bound namespace tag inlining Refs #685, #694 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/imported_constants_px_tests.rs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/libs/extractor/src/imported_constants_px_tests.rs b/libs/extractor/src/imported_constants_px_tests.rs index 6fc49d25f..0746e6884 100644 --- a/libs/extractor/src/imported_constants_px_tests.rs +++ b/libs/extractor/src/imported_constants_px_tests.rs @@ -1,6 +1,68 @@ use super::*; use rstest::rstest; +#[test] +fn namespace_css_tag_materializes_record_when_inlined_without_alias_normalization() { + // Given: authored namespace syntax reaches the parsed-source seam unchanged. + let source = concat!( + "import * as UI from '@devup-ui/react';", + "const rules={p:2,color:'tomato'};", + "export const card=UI.css`${rules}`;", + ); + let allocator = Allocator::default(); + let mut program = crate::parsing::parse(&allocator, "/src/namespace.ts", source) + .unwrap_or_else(|error| panic!("invalid namespace fixture: {error}")); + + // When: inline real constants with semantic binding resolution and no px marks. + inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "/src/namespace.ts", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + + // Then: the exported namespace tag contains the actual materialized record. + let Some(Statement::ExportDeclaration(export)) = program.body.last() else { + panic!("expected exported card"); + }; + let oxc_ast::ast::Declaration::VariableDeclaration(declaration) = &export.declaration else { + panic!("expected card declaration"); + }; + let Some(Expression::TaggedTemplateExpression(tagged)) = + declaration.declarations[0].init.as_ref() + else { + panic!("expected namespace css tag"); + }; + let Expression::StaticMemberExpression(member) = &tagged.tag else { + panic!("expected namespace member"); + }; + let Expression::Identifier(namespace) = &member.object else { + panic!("expected namespace binding"); + }; + assert_eq!(namespace.name, "UI"); + assert_eq!(member.property.name, "css"); + let [Expression::ObjectExpression(object)] = tagged.quasi.expressions.as_slice() else { + panic!("expected materialized record interpolation"); + }; + let [ + ObjectPropertyKind::ObjectProperty(p), + ObjectPropertyKind::ObjectProperty(color), + ] = object.properties.as_slice() + else { + panic!("expected two record properties"); + }; + assert_eq!(p.key.static_name().as_deref(), Some("p")); + assert_eq!(crate::utils::js_number_literal(&p.value), Some(2.0)); + assert_eq!(color.key.static_name().as_deref(), Some("color")); + let Expression::StringLiteral(value) = &color.value else { + panic!("expected materialized color string"); + }; + assert_eq!(value.value, "tomato"); +} + #[test] fn own_css_record_keeps_fallback_order_when_authored_px_mark_is_inlined() { // Given: Emotion-style fallback arrays, not responsive breakpoints. From 241d7cfbe579506f7c2611713885b2e6758843e4 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 24 Oct 2026 09:16:14 +0900 Subject: [PATCH 20/21] ci: allow 180 seconds per coverage test binary Refs #683, #685, #694 Follow the 2026-10-24 user decision after the bounded optimize-first unit did not establish the 50 s target; preserve every test and default stack. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0dbdea6da..c3f5c9258 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "scripts": { "lint": "cargo fmt --all -- --check && cargo clippy --all-targets --all-features -- -D warnings && node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js", "lint:fix": "node --require ./eslint-typescript6.cjs ./node_modules/eslint/bin/eslint.js --fix && cargo fmt", - "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun run test:bun", + "test": "cargo tarpaulin --out xml --out stdout --all-targets --engine llvm --timeout 180 $(node -p \"process.env.CI ? '--fail-under 100' : ''\") && bun run test:bun", "test:bun": "node -e \"require('node:fs').rmSync('coverage/root', { recursive: true, force: true })\" && bun test --coverage && bun run test:vite", "test:vite": "node -e \"require('node:fs').rmSync('coverage/vite', { recursive: true, force: true })\" && bun --config=./bunfig.vite.toml test --coverage", "test:e2e": "bunx playwright test", From 9ef1292a04c21fe4c07314a0f21aa21167fadc2b Mon Sep 17 00:00:00 2001 From: Jeong Min Oh Date: Sat, 24 Oct 2026 22:09:09 +0900 Subject: [PATCH 21/21] test(extractor): cover imported evaluation refusal Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/imported_styled_coverage_tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/libs/extractor/src/imported_styled_coverage_tests.rs b/libs/extractor/src/imported_styled_coverage_tests.rs index 71e381c42..4feac75d2 100644 --- a/libs/extractor/src/imported_styled_coverage_tests.rs +++ b/libs/extractor/src/imported_styled_coverage_tests.rs @@ -90,3 +90,28 @@ fn imported_name_when_member_is_not_exact_does_not_select_a_definition( assert_eq!(name.as_deref(), expected); Ok(()) } + +#[test] +fn imported_evaluation_when_forbidden_helper_is_read_keeps_definition_opaque() { + // Given: a descriptor hides the locale-dependent read from the lexical check. + let allocator = Allocator::default(); + let option = ExtractOption::default(); + let resolver = |_: &str, _: &str| None; + let reader = Reader { + option: &option, + resolver: &resolver, + allocator: &allocator, + modules: FxHashMap::default(), + reading: Vec::new(), + dependencies: BTreeSet::new(), + }; + let source = "import { styled } from '@devup-ui/react'; const compare = Object.getOwnPropertyDescriptor(String.prototype, 'localeCompare').value; export const Base = styled.div({ padding: compare('a', 'b') });"; + // When: the imported-definition retry runs the actual deterministic sandbox. + let computed = reader.evaluate_values( + "/composition/forbidden.tsx", + source, + &crate::imported_constants::Unknown::default(), + ); + // Then: the forbidden helper prevents a computed definition from being published. + assert_eq!(computed, None); +}