Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c647ab5
fix(extractor): reject whole Devup namespace runtime escapes
owjs3901 Oct 4, 2026
81d9a56
fix(extractor): preserve exact lexical values and binding-aware require
owjs3901 Oct 4, 2026
d257aac
fix(extractor): preserve JavaScript props evaluation during lowering
owjs3901 Oct 4, 2026
d4e0187
fix(components): reorder compile-time style defaults after DOM spreads
owjs3901 Oct 4, 2026
58fe5a1
fix(landing): keep selector defaults after Card DOM props
owjs3901 Oct 4, 2026
955bf5e
test(extractor): cover direct literal selection and dynamic value paths
owjs3901 Oct 4, 2026
1e81fdf
test(extractor): cover native written class spread precedence
owjs3901 Oct 4, 2026
4b24361
test(extractor): cover scalar order and runtime CSS suffix branches
owjs3901 Oct 4, 2026
6749633
test(extractor): cover initialization and exact constant safety branches
owjs3901 Oct 4, 2026
5657601
test(extractor): cover native logical and suspension capture edges
owjs3901 Oct 4, 2026
f0969a0
refactor(extractor): make JSX ordering and pruning paths measurable
owjs3901 Oct 4, 2026
f2fc451
merge: update PR #756 with main
owjs3901 Oct 8, 2026
628f7c7
merge: update PR #756 with PR #750
owjs3901 Oct 16, 2026
d9cf389
fix(extractor): preserve explicit styles across parent merge
owjs3901 Oct 23, 2026
505e68a
fix(extractor): integrate settled main semantics
owjs3901 Oct 24, 2026
6e457e9
perf(extractor): avoid unused constant preparation
owjs3901 Oct 24, 2026
2b1087e
test(extractor): cover merged semantic edge paths
owjs3901 Oct 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_constant_read_work.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Avoid cloning unreferenced constant initializers and defer initialization analysis until constant reads need it. Extraction output and build errors are unchanged.",
"date": "2026-10-24T00:00:00.000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_extraction_coverage.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Add regression coverage for extraction safety, property pruning and source-order behavior; remove unreachable post-flatten spread handling and consolidate unknown property-shape rejection. Extraction output and build errors are unchanged.",
"date": "2026-10-24T00:00:00.000Z"
}
9 changes: 9 additions & 0 deletions .changepacks/changepack_log_js_semantics.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch",
"packages/components/package.json": "Patch",
"packages/reset-css/package.json": "Patch"
},
"note": "Keep unknown element-prop spreads wholly as native pass-through data: their keys never select, replace or remove separately authored styles, regardless of order. Preserve fully known ordinary spread composition, canonical aliases, source-ordered once-only values and native className/style merging without spread-key styling variables, fallbacks or conversion code. Preserve exact imported enum and Math values including negative zero, lexical constants, binding-aware CommonJS require, React createElement calls, type-only imports, and compiled reset CSS tests. Retain located errors for intrinsically unreadable explicit styles, runtime styleOrder, unmovable lazy logical suspension, whole Devup UI namespaces escaping to runtime, and immediate lexical temporal-dead-zone reads. Native Emotion css spread restrictions and separate styled css overlap restrictions remain unchanged.",
"date": "2026-10-01T00:00:00.000Z"
}
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_jsx_call_spread.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Attach extracted classes and authored CSS variables to jsx, jsxs and jsxDEV props wrapped in parentheses, as const or satisfies. Preserve source evaluation order, responsive and conditional styleOrder output, native className/style merging and whole unknown-spread pass-through. Syntax wrappers do not introduce spread replacement diagnostics or a styling runtime.",
"date": "2026-10-12T00:00:00.000Z"
}
35 changes: 32 additions & 3 deletions libs/extractor/src/barrel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,7 @@ impl Generated {

mod aliases;
mod gate;
mod namespace_aliases;
use aliases::{Reach, declarator_removal, export_edits, is_assigned_from};

fn unreadable(local: &str, code: &str) -> String {
Expand Down Expand Up @@ -792,7 +793,17 @@ impl Rewriter<'_, '_, '_, '_> {
self.replace(namespace, appended, member_span, &name, origin);
}
(None, AstKind::VariableDeclarator(declarator))
if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) => {}
if matches!(declarator.id, BindingPattern::BindingIdentifier(_))
&& declarator
.init
.as_ref()
.is_some_and(|init| init.span() == span)
&& nodes
.parent_kind(nodes.parent_id(node))
.as_variable_declaration()
.is_some_and(|declaration| {
declaration.kind == VariableDeclarationKind::Const
}) => {}
(None, AstKind::VariableDeclarator(declarator))
if declarator
.init
Expand All @@ -804,7 +815,11 @@ impl Rewriter<'_, '_, '_, '_> {
(None, AstKind::ComputedMemberExpression(member)) if member.object.span() == span => {
self.whole_error(local, member.span.start, member.span.end);
}
(None, _) => {}
(None, AstKind::ExportSpecifier(_))
if self.edits.iter().any(|(start, end, _)| {
*start <= span.start as usize && *end >= span.end as usize
}) => {}
(None, _) => self.whole_error(local, span.start, span.end),
}
}

Expand Down Expand Up @@ -1022,6 +1037,7 @@ pub(crate) fn rewrite(
}
}
let wants_semantic = !found.namespaces.is_empty()
|| found.bound.iter().any(|bound| bound.devup.1.is_none())
|| !found.opaque.is_empty()
|| (found
.bound
Expand All @@ -1042,7 +1058,7 @@ pub(crate) fn rewrite(
let locals: FxHashMap<SymbolId, Reach> = found
.bound
.iter()
.filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export))
.filter(|bound| bound.devup.1.as_deref().is_none_or(compiled_export))
.filter_map(|bound| {
Some((
bound.binding.symbol_id.get()?,
Expand Down Expand Up @@ -1085,6 +1101,19 @@ pub(crate) fn rewrite(
module: declared.module.clone(),
source: declared.source.clone(),
})
.chain(
found
.bound
.iter()
.filter(|bound| bound.devup.1.is_none())
.map(|bound| Namespace {
local: bound.binding.name.to_string(),
symbol: bound.binding.symbol_id.get(),
after: bound.after,
module: None,
source: bound.devup.0.clone(),
}),
)
.collect();
for namespace in &spaces {
rewriter.read_namespace(namespace);
Expand Down
21 changes: 7 additions & 14 deletions libs/extractor/src/barrel/aliases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,13 @@ impl Rewriter<'_, '_, '_, '_> {
Some((bound.binding.symbol_id.get()?, (bound, bound.devup.clone())))
})
.collect();
let mut spaces: FxHashMap<SymbolId, usize> = namespaces
let first_new = namespaces.len();
self.namespace_aliases(namespaces);
let spaces: FxHashMap<SymbolId, usize> = namespaces
.iter()
.enumerate()
.filter_map(|(index, namespace)| Some((namespace.symbol?, index)))
.collect();
let first_new = namespaces.len();
for node in nodes.iter() {
let AstKind::VariableDeclarator(declarator) = node.kind() else {
continue;
Expand All @@ -249,18 +250,10 @@ impl Rewriter<'_, '_, '_, '_> {
if declaration.kind != VariableDeclarationKind::Const {
continue;
}
if let Some(&index) = spaces.get(&origin) {
let origin = &namespaces[index];
let namespace = Namespace {
local: id.name.to_string(),
symbol: Some(alias),
after: origin.after,
module: origin.module.clone(),
source: origin.source.clone(),
};
spaces.insert(alias, namespaces.len());
namespaces.push(namespace);
} else if let Some((origin, reach)) = named.get(&origin).cloned() {
if spaces.contains_key(&origin) {
continue;
}
if let Some((origin, reach)) = named.get(&origin).cloned() {
named.insert(alias, (origin, reach.clone()));
let exported = matches!(
nodes.parent_kind(declaration_id),
Expand Down
58 changes: 58 additions & 0 deletions libs/extractor/src/barrel/namespace_aliases.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//! Whole namespace aliases, followed by semantic identity regardless of declaration order.

use oxc_ast::AstKind;
use oxc_ast::ast::{BindingPattern, Expression, VariableDeclarationKind};
use rustc_hash::FxHashMap;

use super::{Namespace, Rewriter, reference_symbol};

impl Rewriter<'_, '_, '_, '_> {
/// Collects every immutable namespace alias before checking its runtime reads.
pub(super) fn namespace_aliases(&self, namespaces: &mut Vec<Namespace>) {
let mut spaces: FxHashMap<_, _> = namespaces
.iter()
.enumerate()
.filter_map(|(index, namespace)| Some((namespace.symbol?, index)))
.collect();
let nodes = self.semantic.nodes();
loop {
let before = spaces.len();
for node in nodes.iter() {
let AstKind::VariableDeclarator(declarator) = node.kind() else {
continue;
};
let (BindingPattern::BindingIdentifier(id), Some(Expression::Identifier(init))) =
(&declarator.id, &declarator.init)
else {
continue;
};
let (Some(alias), Some(origin), AstKind::VariableDeclaration(declaration)) = (
id.symbol_id.get(),
reference_symbol(init, self.semantic),
nodes.parent_kind(node.id()),
) else {
continue;
};
if declaration.kind != VariableDeclarationKind::Const || spaces.contains_key(&alias)
{
continue;
}
if let Some(&index) = spaces.get(&origin) {
let origin = &namespaces[index];
let namespace = Namespace {
local: id.name.to_string(),
symbol: Some(alias),
after: origin.after,
module: origin.module.clone(),
source: origin.source.clone(),
};
spaces.insert(alias, namespaces.len());
namespaces.push(namespace);
}
}
if spaces.len() == before {
break;
}
}
}
}
133 changes: 133 additions & 0 deletions libs/extractor/src/barrel/namespace_escape_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
use super::{NS, Project, namespace_code, namespace_error};
use crate::barrel::{Barreled, rewrite};
use serial_test::serial;

#[test]
#[serial]
fn runtime_namespace_escapes_are_rejected_when_used_whole() {
for usage in [
"consume(Devup)",
"Object.keys(Devup)",
"function f() { return Devup }",
"const value = { ns: Devup }",
"const value = { Devup }",
"const value = [Devup]",
"export default Devup",
"export default { Devup }",
"const value = typeof Devup",
"let D = Devup; consume(D)",
"var D = Devup; consume(D)",
"const D = Devup; spyOn(D, 'css')",
"const D = Devup; const E = D; consume(E)",
"const D = Devup; export default D",
"function f() { const D = Devup; return D }",
"const E = D; const D = Devup; consume(E)",
"export { D }; const D = Devup",
"consume((Devup))",
"consume(Devup as object)",
] {
let source = format!("{NS}{usage}\n");

let error = namespace_error(&source);

assert!(error.contains("test.tsx:2:"), "{usage}: {error}");
assert!(
error.contains("cannot use") && error.contains("read its members by name"),
"{usage}: {error}"
);
}
}

#[test]
#[serial]
fn safe_namespace_reads_preserve_semantic_identity() {
let source = format!(
"{NS}const D = Devup; const E = D;\nconst {{ css: c }} = E;\nexport const a = c({{ color: 'red' }});\nexport const b = D.css({{ color: 'blue' }});\nexport type N = typeof Devup;\nexport type A = typeof D;\nexport function f(Devup) {{ return spyOn(Devup, 'css') }}\nexport function g(D) {{ return D }}\nexport {{ Devup as UI }};\n"
);

let code = namespace_code(&source);

assert!(!code.contains("c({") && !code.contains("D.css("), "{code}");
assert!(code.contains("spyOn(Devup, \"css\")"), "{code}");
assert!(code.contains("return D"), "{code}");
assert!(
code.contains("typeof Devup") && code.contains("typeof D"),
"{code}"
);
assert!(code.contains("export * as UI"), "{code}");
}

#[test]
#[serial]
fn type_only_and_unrelated_namespaces_remain_unchanged() {
for source in [
"import type * as Devup from '@devup-ui/react'; export type N = typeof Devup;",
"import * as Devup from 'other'; spyOn(Devup, 'css');",
"import * as Devup from '@devup-ui/react'; function f() { const Devup = {}; return Devup }",
] {
let result = rewrite(source, "test.tsx", "@devup-ui/react", None);

assert!(matches!(result, Barreled::Unchanged));
}
}

#[test]
#[serial]
fn reexported_namespaces_are_rejected_when_their_runtime_value_escapes() {
for (index, (barrel, import)) in [
(
"export { css } from '@devup-ui/react'",
"import * as Devup from './ui'",
),
(
"export * as Devup from '@devup-ui/react'",
"import { Devup } from './ui'",
),
(
"import * as D from '@devup-ui/react'; export default D",
"import Devup from './ui'",
),
(
"import * as D from '@devup-ui/react'; export const Devup = D",
"import { Devup } from './ui'",
),
(
"export * as Devup from './inner'",
"import { Devup } from './ui'",
),
]
.into_iter()
.enumerate()
{
let source = format!("{import}\nspyOn(Devup, 'css')\n");
let project = Project::new(
&format!("escape-{index}"),
&[
("ui.ts", barrel),
("inner.ts", "export { css } from '@devup-ui/react'"),
("app.tsx", &source),
],
);

let error = project.error("app.tsx");

assert!(error.contains("app.tsx:2:7:"), "{error}");
assert!(
error.contains("cannot use") && error.contains("import"),
"{error}"
);
}
}

#[test]
#[serial]
fn default_namespace_import_is_rejected_when_passed_to_runtime() {
let source = "import Devup from '@devup-ui/react'\nspyOn(Devup, 'css')\n";

let error = namespace_error(source);

assert!(
error.contains("test.tsx:2:7: `Devup` cannot use `Devup`"),
"{error}"
);
}
19 changes: 11 additions & 8 deletions libs/extractor/src/barrel/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ use crate::{
has_devup_ui_with,
};

#[path = "namespace_escape_tests.rs"]
mod namespace_escape_tests;

/// A project of real files on disk, removed when dropped
struct Project {
root: PathBuf,
Expand Down Expand Up @@ -838,12 +841,13 @@ fn long_code_in_an_error_is_shortened() {

#[test]
#[serial]
fn namespace_passed_whole_is_left_to_the_runtime() {
let code = namespace_code(&format!(
"{NS}export const a = Object.keys(Devup)\nexport const b = ((fn) => fn)(Devup)\nexport default Devup\nexport const c = typeof Devup\n"
));
assert!(code.contains("Object.keys(Devup)"), "{code}");
assert!(code.contains("export default Devup"), "{code}");
fn namespace_passed_whole_is_a_located_build_error() {
let error = namespace_error(&format!("{NS}spyOn(Devup, 'css')\n"));
assert!(
error.contains("test.tsx:2:7: `Devup` cannot use `Devup` at build time"),
"{error}"
);
assert!(error.contains("read its members by name"), "{error}");
}

#[test]
Expand Down Expand Up @@ -983,11 +987,10 @@ fn barrel_module_exports_what_it_aliases_as_re_exports() {
#[serial]
fn namespace_aliases_read_members() {
let code = namespace_code(&format!(
"{NS}const D = Devup\nexport const a = <D.Box bg=\"red\" />\nexport const b = D.css({{ color: 'blue' }})\nlet L = Devup\nexport const c = L\n"
"{NS}const D = Devup\nexport const a = <D.Box bg=\"red\" />\nexport const b = D.css({{ color: 'blue' }})\n"
));
assert!(code.contains("<div className="), "{code}");
assert!(!code.contains("css("), "{code}");
assert!(code.contains("export const c = L"), "{code}");
}

#[test]
Expand Down
Loading
Loading