@ape-egg/vibe 2.1.22 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +112 -5
  2. package/boot.js +4 -4
  3. package/component.js +27 -29
  4. package/hot-module-refresh.js +4 -4
  5. package/index.js +26 -17
  6. package/llms.txt +36 -5
  7. package/package.json +20 -14
  8. package/runtime/affected.js +159 -36
  9. package/runtime/cleanup.js +45 -1
  10. package/runtime/component.js +360 -98
  11. package/runtime/conditionals.js +111 -14
  12. package/runtime/debug.js +24 -0
  13. package/runtime/dispatch.js +172 -0
  14. package/runtime/hydrate.js +277 -110
  15. package/runtime/index.js +189 -65
  16. package/runtime/iterate.js +125 -50
  17. package/runtime/iteration-utils.js +59 -8
  18. package/runtime/manifest.js +77 -2
  19. package/runtime/parse.js +81 -11
  20. package/runtime/pre-compiled-iterations.js +19 -6
  21. package/runtime/pre-compiled-manifest.js +13 -4
  22. package/runtime/staging.js +153 -0
  23. package/runtime/state.js +31 -0
  24. package/runtime/tracking.js +173 -0
  25. package/runtime/utils.js +155 -78
  26. package/spa.js +206 -0
  27. package/vibe.css +8 -4
  28. package/CHANGELOG.md +0 -1159
  29. package/ROADMAP.md +0 -397
  30. package/compiler/bin/vibe-compile.js +0 -121
  31. package/compiler/native/.gitkeep +0 -0
  32. package/compiler/native/vibe-compiler-darwin-arm64 +0 -0
  33. package/compiler/native/vibe-compiler-linux-x64 +0 -0
  34. package/compiler/src/Cargo.lock +0 -2023
  35. package/compiler/src/Cargo.toml +0 -38
  36. package/compiler/src/compiler/PRE-RENDERING-IMPLEMENTATION.md +0 -241
  37. package/compiler/src/compiler/binding_case.rs +0 -88
  38. package/compiler/src/compiler/compile.rs +0 -2522
  39. package/compiler/src/compiler/component_tagger.rs +0 -469
  40. package/compiler/src/compiler/iteration_optimizer.rs +0 -455
  41. package/compiler/src/compiler/js_analyzer.rs +0 -715
  42. package/compiler/src/compiler/manifest_builder.rs +0 -693
  43. package/compiler/src/compiler/mod.rs +0 -15
  44. package/compiler/src/compiler/name_binding_protect.rs +0 -207
  45. package/compiler/src/compiler/reassignment_analyzer.rs +0 -456
  46. package/compiler/src/compiler/state_extractor.rs +0 -263
  47. package/compiler/src/compiler/value_stamper.rs +0 -921
  48. package/compiler/src/compiler/watcher.rs +0 -1147
  49. package/compiler/src/config.rs +0 -239
  50. package/compiler/src/main.rs +0 -347
  51. package/compiler/src/parser/element.rs +0 -96
  52. package/compiler/src/parser/html.rs +0 -1004
  53. package/compiler/src/parser/mod.rs +0 -8
  54. package/runtime/pre-compiled-manifest.test.mjs +0 -58
  55. package/runtime/scope.js +0 -50
  56. package/test-results/.last-run.json +0 -4
@@ -1,15 +0,0 @@
1
- pub mod compile;
2
- mod state_extractor;
3
- mod manifest_builder;
4
- mod value_stamper;
5
- mod iteration_optimizer;
6
- mod js_analyzer;
7
- mod component_tagger;
8
- mod binding_case;
9
- mod name_binding_protect;
10
- mod reassignment_analyzer;
11
- pub mod watcher;
12
-
13
- pub use compile::Compiler;
14
- #[allow(unused_imports)]
15
- pub use compile::{CompileStats, ManifestStats};
@@ -1,207 +0,0 @@
1
- use regex::Regex;
2
- use std::sync::OnceLock;
3
-
4
- /// The attribute a relocated name-binding is parked in. Mirrors the runtime
5
- /// constant of the same name (runtime/constants.js) — the manifest builder reads
6
- /// the binding back out of it, and the runtime consumes + strips it on hydrate.
7
- pub const NAME_BIND_ATTR: &str = "data-vibe-namebind";
8
-
9
- /// A name-binding lives in attribute-NAME position: `<icon @[expr]>`. HTML parsers
10
- /// split an attribute token on whitespace, so once a prop is inlined into one and the
11
- /// expression gains spaces — `<icon @[element]>` becoming `@[EQUIPMENT(item, true).element]`
12
- /// — html5ever (and the browser) tear it into broken attributes and the binding is lost.
13
- ///
14
- /// We relocate every whitespace-bearing name-binding into a single verbatim
15
- /// value-attribute `data-vibe-namebind="@[expr]@[expr2]…"`. Value attributes are kept
16
- /// byte-for-byte through parsing, so the manifest builder recovers the exact expression
17
- /// and the runtime evaluates it through the same path it uses in non-compiled mode —
18
- /// identical behaviour, no expression rewriting. Whitespace-free name-bindings already
19
- /// survive as-is and are left untouched.
20
- pub fn protect(html: &str) -> String {
21
- // <script>/<style> bodies are raw text — a `<` inside them must not be read as a
22
- // tag. Park them behind comment placeholders for the tag scan, restore after.
23
- let (masked, raw_blocks) = mask_raw_text(html);
24
-
25
- let out = tag_open_regex()
26
- .replace_all(&masked, |caps: &regex::Captures| {
27
- let name = &caps[1];
28
- let attrs = &caps[2];
29
- let (kept, relocated) = relocate_unsafe(attrs);
30
- if relocated.is_empty() {
31
- return caps[0].to_string();
32
- }
33
- // Preserve a self-closing slash as the final token.
34
- let kept_end_trimmed = kept.trim_end();
35
- let (body, self_close) = match kept_end_trimmed.strip_suffix('/') {
36
- Some(b) => (b.trim_end(), " /"),
37
- None => (kept_end_trimmed, ""),
38
- };
39
- format!(
40
- "<{}{} {}=\"{}\"{}>",
41
- name, body, NAME_BIND_ATTR, relocated, self_close
42
- )
43
- })
44
- .to_string();
45
-
46
- restore_raw_text(&out, &raw_blocks)
47
- }
48
-
49
- /// Matches an opening tag and captures (name, attribute-section). Quoted values may
50
- /// hold anything but their own quote; bare attribute chars are anything but `<>"'`.
51
- fn tag_open_regex() -> &'static Regex {
52
- static RE: OnceLock<Regex> = OnceLock::new();
53
- RE.get_or_init(|| {
54
- Regex::new(r#"<([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^<>"'])*)>"#).unwrap()
55
- })
56
- }
57
-
58
- /// Walk a tag's attribute section, moving each whitespace-bearing name-binding out of
59
- /// the kept attributes and into the concatenated `relocated` string. Quoted attribute
60
- /// values (and string literals inside an expression) are skipped so value-bindings and
61
- /// `@[fn('a b')]`-style literals are never mistaken for a name-binding.
62
- fn relocate_unsafe(attrs: &str) -> (String, String) {
63
- let bytes = attrs.as_bytes();
64
- let n = bytes.len();
65
- let mut kept = String::with_capacity(n);
66
- let mut relocated = String::new();
67
- let mut i = 0;
68
- let mut seg = 0;
69
-
70
- while i < n {
71
- let b = bytes[i];
72
-
73
- // Skip a quoted attribute value verbatim (it may legally contain `@[…]`).
74
- if b == b'"' || b == b'\'' {
75
- i += 1;
76
- while i < n && bytes[i] != b {
77
- i += 1;
78
- }
79
- if i < n {
80
- i += 1; // consume closing quote
81
- }
82
- continue;
83
- }
84
-
85
- // A bare name-binding in attribute-name position.
86
- if b == b'@' && i + 1 < n && bytes[i + 1] == b'[' {
87
- kept.push_str(&attrs[seg..i]);
88
- let start = i;
89
- i += 2;
90
- let mut depth = 1;
91
- while i < n && depth > 0 {
92
- let c = bytes[i];
93
- if c == b'\'' || c == b'"' {
94
- i += 1;
95
- while i < n && bytes[i] != c {
96
- i += 1;
97
- }
98
- if i < n {
99
- i += 1;
100
- }
101
- continue;
102
- }
103
- match c {
104
- b'[' => depth += 1,
105
- b']' => depth -= 1,
106
- _ => {}
107
- }
108
- i += 1;
109
- }
110
- let binding = &attrs[start..i];
111
- let inner = &binding[2..binding.len() - 1];
112
- if inner.bytes().any(|x| x.is_ascii_whitespace()) {
113
- relocated.push_str(binding);
114
- } else {
115
- kept.push_str(binding);
116
- }
117
- seg = i;
118
- continue;
119
- }
120
-
121
- i += 1;
122
- }
123
- kept.push_str(&attrs[seg..]);
124
- (kept, relocated)
125
- }
126
-
127
- fn raw_text_regex() -> &'static Regex {
128
- static RE: OnceLock<Regex> = OnceLock::new();
129
- RE.get_or_init(|| Regex::new(r"(?is)<(script|style)\b[^>]*>.*?</(?:script|style)>").unwrap())
130
- }
131
-
132
- fn mask_raw_text(html: &str) -> (String, Vec<String>) {
133
- let mut blocks = Vec::new();
134
- let mut out = String::with_capacity(html.len());
135
- let mut last = 0;
136
- for mat in raw_text_regex().find_iter(html) {
137
- out.push_str(&html[last..mat.start()]);
138
- out.push_str(&format!("<!--VIBE_RAWTEXT_{}-->", blocks.len()));
139
- blocks.push(html[mat.start()..mat.end()].to_string());
140
- last = mat.end();
141
- }
142
- out.push_str(&html[last..]);
143
- (out, blocks)
144
- }
145
-
146
- fn restore_raw_text(html: &str, blocks: &[String]) -> String {
147
- let mut out = html.to_string();
148
- for (i, block) in blocks.iter().enumerate() {
149
- out = out.replace(&format!("<!--VIBE_RAWTEXT_{}-->", i), block);
150
- }
151
- out
152
- }
153
-
154
- #[cfg(test)]
155
- mod tests {
156
- use super::*;
157
-
158
- #[test]
159
- fn relocates_whitespace_name_binding() {
160
- let html = r#"<icon @[EQUIPMENT(item, true).element]></icon>"#;
161
- let out = protect(html);
162
- assert!(
163
- out.contains(r#"data-vibe-namebind="@[EQUIPMENT(item, true).element]""#),
164
- "got: {}",
165
- out
166
- );
167
- // The raw whitespace binding must no longer sit in attribute-name position.
168
- assert!(!out.contains("@[EQUIPMENT(item, true).element]>"));
169
- }
170
-
171
- #[test]
172
- fn leaves_whitespace_free_name_binding_untouched() {
173
- let html = r#"<icon @[(selectedEquipProps(uuid)).element]></icon>"#;
174
- assert_eq!(protect(html), html);
175
- }
176
-
177
- #[test]
178
- fn leaves_value_binding_untouched() {
179
- let html = r#"<card tinted="@[fn(a, b).element]"></card>"#;
180
- assert_eq!(protect(html), html);
181
- }
182
-
183
- #[test]
184
- fn leaves_text_binding_untouched() {
185
- let html = r#"<label>@[fn(a, b)]</label>"#;
186
- assert_eq!(protect(html), html);
187
- }
188
-
189
- #[test]
190
- fn ignores_at_bracket_inside_script() {
191
- let html = r#"<script>const x = "@[fn(a, b)]";</script><icon @[fn(a, b)]></icon>"#;
192
- let out = protect(html);
193
- assert!(out.contains(r#"const x = "@[fn(a, b)]";"#), "script mangled: {}", out);
194
- assert!(out.contains(r#"data-vibe-namebind="@[fn(a, b)]""#), "got: {}", out);
195
- }
196
-
197
- #[test]
198
- fn relocates_multiple_on_one_element() {
199
- let html = r#"<el @[fn(a, b)] @[gn(c, d)]></el>"#;
200
- let out = protect(html);
201
- assert!(
202
- out.contains(r#"data-vibe-namebind="@[fn(a, b)]@[gn(c, d)]""#),
203
- "got: {}",
204
- out
205
- );
206
- }
207
- }
@@ -1,456 +0,0 @@
1
- //! Reassignment analysis: decide which global `$` state keys are compile-time
2
- //! constants (safe to value-stamp into pre-rendered HTML) versus runtime-dynamic
3
- //! (must be left as live `@[...]` bindings for the runtime to fill).
4
- //!
5
- //! Soundness rule: a wrong "constant" bakes stale content into production HTML
6
- //! (a correctness bug), while a wrong "dynamic" merely forgoes the optimization
7
- //! (a brief FOUC, same as today). So every uncertain case resolves to **dynamic**.
8
- //!
9
- //! A key is reported constant only when ALL of these hold:
10
- //! 1. Its initial value is a primitive (string / number / bool / null). Objects
11
- //! and arrays are never constant — their contents can be mutated through a
12
- //! value alias (`const a = $.items; a.push(x)`) that a `$.key` scan cannot
13
- //! see, so they are conservatively excluded.
14
- //! 2. No write to `$.key` anywhere across the scanned sources — assignment,
15
- //! compound assignment, `++`/`--`, `delete`, or a nested `$.key.x = …`.
16
- //!
17
- //! Any of these "escape" signals discards the whole optimization (every key is
18
- //! treated dynamic), because they could write a key we cannot pin down:
19
- //! - aliasing `$` itself (`const x = $` / `x = $`),
20
- //! - reflective writes (`Object.assign($, …)` / `Object.defineProperty($, …)`),
21
- //! - a source that fails to parse (we cannot prove it is write-free).
22
- //!
23
- //! The `$` access split mirrors Vibe's own convention: `$.key` and `$['key']`
24
- //! (string literal) address GLOBAL state, while `$[expr]` with a non-literal key
25
- //! is the component-state-by-id pattern (`const id = component(s); $[id].x = …`)
26
- //! whose key is a generated component id, never a global key — so a non-literal
27
- //! computed write is ignored, not a bail.
28
- //!
29
- //! Passing `$` as a plain argument (`derive($, …)`) is treated as a read, not an
30
- //! escape — the vibe convention is to write state through direct `$.key = …`
31
- //! member assignments, and bailing on every `f($)` would disable the optimization
32
- //! for any real app.
33
-
34
- use swc_common::{sync::Lrc, FileName, SourceMap};
35
- use swc_ecma_ast::*;
36
- use swc_ecma_parser::{EsSyntax, Parser, StringInput, Syntax};
37
- use swc_ecma_visit::{Visit, VisitWith};
38
-
39
- use serde_json::{Map, Value};
40
- use std::collections::HashSet;
41
-
42
- /// Classify which `initial_state` keys are safe to treat as compile-time
43
- /// constants, given every JS source in the app (module files, inline `<script>`
44
- /// bodies, and HTML `on*` handler bodies). See module docs for the rules.
45
- pub fn classify_constant_keys(
46
- initial_state: &Map<String, Value>,
47
- sources: &[String],
48
- ) -> HashSet<String> {
49
- let mut written: HashSet<String> = HashSet::new();
50
-
51
- for src in sources {
52
- let module = match parse(src) {
53
- Some(m) => m,
54
- // Cannot prove a source is write-free → forfeit the whole optimization.
55
- None => return HashSet::new(),
56
- };
57
- let mut visitor = WriteVisitor::default();
58
- module.visit_with(&mut visitor);
59
- if visitor.bail {
60
- return HashSet::new();
61
- }
62
- written.extend(visitor.written);
63
- }
64
-
65
- initial_state
66
- .iter()
67
- .filter(|(key, value)| is_primitive(value) && !written.contains(key.as_str()))
68
- .map(|(key, _)| key.clone())
69
- .collect()
70
- }
71
-
72
- /// True if `src` parses as JavaScript. Used to drop heuristically-extracted
73
- /// HTML attribute values that aren't actually event-handler code, so that genuine
74
- /// unparseable JS sources can still trigger the conservative bail.
75
- pub fn parses_as_js(src: &str) -> bool {
76
- parse(src).is_some()
77
- }
78
-
79
- fn is_primitive(value: &Value) -> bool {
80
- matches!(
81
- value,
82
- Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null
83
- )
84
- }
85
-
86
- /// Parse a source as a module. Handler bodies can contain top-level `return`
87
- /// (`onclick="if (x) return; f()"`), which is illegal at module top level, so a
88
- /// failed parse is retried wrapped in a function before giving up — writes are
89
- /// still found inside the wrapper, while genuinely broken sources fall through.
90
- fn parse(src: &str) -> Option<Module> {
91
- parse_module(src).or_else(|| parse_module(&format!("function __vibe_wrap__(){{\n{}\n}}", src)))
92
- }
93
-
94
- fn parse_module(code: &str) -> Option<Module> {
95
- let cm: Lrc<SourceMap> = Default::default();
96
- let fm = cm.new_source_file(Lrc::new(FileName::Anon), code.to_string());
97
- let syntax = Syntax::Es(EsSyntax {
98
- jsx: false,
99
- decorators: true,
100
- ..Default::default()
101
- });
102
- let input = StringInput::new(&fm.src, fm.start_pos, fm.end_pos);
103
- let mut parser = Parser::new(syntax, input, None);
104
- parser.parse_module().ok()
105
- }
106
-
107
- fn is_dollar(expr: &Expr) -> bool {
108
- matches!(expr, Expr::Ident(id) if id.sym.to_string() == "$")
109
- }
110
-
111
- /// What a write target rooted at `$` resolves to.
112
- enum DollarTarget {
113
- /// `$.key`, `$.key.x…`, or `$['key']` — the top-level GLOBAL state key touched.
114
- Key(String),
115
- /// `$[expr]` with a non-literal key — Vibe's component-state-by-id access,
116
- /// never a global key, so irrelevant to global-constant analysis.
117
- ComponentState,
118
- /// Not rooted at `$`.
119
- None,
120
- }
121
-
122
- /// Walk a member chain to its base. If the base is the `$` identifier, return
123
- /// the FIRST property after it (the top-level state key). `$.a.b.c` → `a`.
124
- fn dollar_target(member: &MemberExpr) -> DollarTarget {
125
- match &*member.obj {
126
- Expr::Ident(id) if id.sym.to_string() == "$" => match &member.prop {
127
- MemberProp::Ident(name) => DollarTarget::Key(name.sym.to_string()),
128
- MemberProp::Computed(c) => match &*c.expr {
129
- Expr::Lit(Lit::Str(s)) => DollarTarget::Key(s.value.to_string()),
130
- _ => DollarTarget::ComponentState,
131
- },
132
- MemberProp::PrivateName(_) => DollarTarget::None,
133
- },
134
- Expr::Member(inner) => dollar_target(inner),
135
- Expr::Paren(p) => match &*p.expr {
136
- Expr::Member(inner) => dollar_target(inner),
137
- _ => DollarTarget::None,
138
- },
139
- _ => DollarTarget::None,
140
- }
141
- }
142
-
143
- #[derive(Default)]
144
- struct WriteVisitor {
145
- written: HashSet<String>,
146
- bail: bool,
147
- }
148
-
149
- impl WriteVisitor {
150
- /// Record a write whose target is (or descends from) `$`.
151
- fn record_member_write(&mut self, member: &MemberExpr) {
152
- match dollar_target(member) {
153
- DollarTarget::Key(k) => {
154
- self.written.insert(k);
155
- }
156
- // Component-state-by-id write — irrelevant to global keys.
157
- DollarTarget::ComponentState => {}
158
- DollarTarget::None => {}
159
- }
160
- }
161
- }
162
-
163
- /// Detects any use of the bare `$` identifier within a subtree.
164
- #[derive(Default)]
165
- struct DollarUseFinder {
166
- found: bool,
167
- }
168
- impl Visit for DollarUseFinder {
169
- fn visit_ident(&mut self, id: &Ident) {
170
- if id.sym.to_string() == "$" {
171
- self.found = true;
172
- }
173
- }
174
- }
175
-
176
- impl Visit for WriteVisitor {
177
- fn visit_assign_expr(&mut self, n: &AssignExpr) {
178
- match &n.left {
179
- AssignTarget::Simple(SimpleAssignTarget::Member(m)) => self.record_member_write(m),
180
- // `($.k) = …`
181
- AssignTarget::Simple(SimpleAssignTarget::Paren(p)) => {
182
- if let Expr::Member(m) = &*p.expr {
183
- self.record_member_write(m);
184
- }
185
- }
186
- // Reassigning `$` itself (`$ = …`) replaces the whole state object.
187
- AssignTarget::Simple(SimpleAssignTarget::Ident(id)) => {
188
- if id.id.sym.to_string() == "$" {
189
- self.bail = true;
190
- }
191
- }
192
- // Destructuring assignment that writes through `$` (`[$.k] = …`).
193
- AssignTarget::Pat(pat) => {
194
- let mut finder = DollarUseFinder::default();
195
- pat.visit_with(&mut finder);
196
- if finder.found {
197
- self.bail = true;
198
- }
199
- }
200
- _ => {}
201
- }
202
- // Aliasing: `x = $` lets later `x.key = …` writes escape our scan.
203
- if is_dollar(&n.right) {
204
- self.bail = true;
205
- }
206
- n.visit_children_with(self);
207
- }
208
-
209
- fn visit_var_declarator(&mut self, n: &VarDeclarator) {
210
- // `const x = $` — same aliasing escape as `x = $`.
211
- if let Some(init) = &n.init {
212
- if is_dollar(init) {
213
- self.bail = true;
214
- }
215
- }
216
- n.visit_children_with(self);
217
- }
218
-
219
- fn visit_update_expr(&mut self, n: &UpdateExpr) {
220
- // `$.key++`, `--$.key`
221
- if let Expr::Member(m) = &*n.arg {
222
- self.record_member_write(m);
223
- }
224
- n.visit_children_with(self);
225
- }
226
-
227
- fn visit_unary_expr(&mut self, n: &UnaryExpr) {
228
- // `delete $.key`
229
- if matches!(n.op, UnaryOp::Delete) {
230
- if let Expr::Member(m) = &*n.arg {
231
- self.record_member_write(m);
232
- }
233
- }
234
- n.visit_children_with(self);
235
- }
236
-
237
- fn visit_call_expr(&mut self, n: &CallExpr) {
238
- // Reflective writes: Object.assign($, …) / Object.defineProperty($, …) /
239
- // Reflect.set($, …) and friends could write any key.
240
- if let Callee::Expr(callee) = &n.callee {
241
- if let Expr::Member(m) = &**callee {
242
- if let Expr::Ident(obj) = &*m.obj {
243
- let obj = obj.sym.to_string();
244
- if let MemberProp::Ident(method) = &m.prop {
245
- let method = method.sym.to_string();
246
- let reflective_write = matches!(
247
- (obj.as_str(), method.as_str()),
248
- ("Object", "assign")
249
- | ("Object", "defineProperty")
250
- | ("Object", "defineProperties")
251
- | ("Object", "setPrototypeOf")
252
- | ("Reflect", "set")
253
- | ("Reflect", "defineProperty")
254
- | ("Reflect", "deleteProperty")
255
- | ("Reflect", "setPrototypeOf")
256
- );
257
- if reflective_write {
258
- if let Some(first) = n.args.first() {
259
- if is_dollar(&first.expr) {
260
- self.bail = true;
261
- }
262
- }
263
- }
264
- }
265
- }
266
- }
267
- }
268
- n.visit_children_with(self);
269
- }
270
- }
271
-
272
- #[cfg(test)]
273
- mod tests {
274
- use super::*;
275
- use serde_json::json;
276
-
277
- fn state(pairs: &[(&str, Value)]) -> Map<String, Value> {
278
- pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
279
- }
280
-
281
- #[test]
282
- fn primitive_never_written_is_constant() {
283
- let s = state(&[("version", json!("0.1.5"))]);
284
- let got = classify_constant_keys(&s, &["console.log($.version);".to_string()]);
285
- assert!(got.contains("version"));
286
- }
287
-
288
- #[test]
289
- fn direct_reassignment_is_dynamic() {
290
- let s = state(&[("coins", json!(400)), ("version", json!("0.1.5"))]);
291
- let got = classify_constant_keys(&s, &["$.coins = 500;".to_string()]);
292
- assert!(!got.contains("coins"));
293
- assert!(got.contains("version"));
294
- }
295
-
296
- #[test]
297
- fn compound_and_update_are_dynamic() {
298
- let s = state(&[("a", json!(1)), ("b", json!(1)), ("c", json!(1))]);
299
- let got = classify_constant_keys(&s, &["$.a += 1; $.b++; --$.c;".to_string()]);
300
- assert!(got.is_empty());
301
- }
302
-
303
- #[test]
304
- fn computed_string_literal_write_is_dynamic() {
305
- let s = state(&[("coins", json!(400)), ("version", json!("v"))]);
306
- let got = classify_constant_keys(&s, &["$['coins'] = 1;".to_string()]);
307
- assert!(!got.contains("coins"));
308
- assert!(got.contains("version"));
309
- }
310
-
311
- #[test]
312
- fn delete_is_dynamic() {
313
- let s = state(&[("x", json!(1))]);
314
- let got = classify_constant_keys(&s, &["delete $.x;".to_string()]);
315
- assert!(!got.contains("x"));
316
- }
317
-
318
- #[test]
319
- fn nested_property_write_marks_top_key_dynamic() {
320
- let s = state(&[("settings", json!({ "a": 1 })), ("version", json!("v"))]);
321
- let got = classify_constant_keys(&s, &["$.settings.theme = 'dark';".to_string()]);
322
- assert!(!got.contains("settings"));
323
- assert!(got.contains("version"));
324
- }
325
-
326
- #[test]
327
- fn objects_and_arrays_are_never_constant() {
328
- let s = state(&[
329
- ("characters", json!([])),
330
- ("clock", json!({ "now": 0 })),
331
- ("version", json!("v")),
332
- ]);
333
- let got = classify_constant_keys(&s, &["// no writes".to_string()]);
334
- assert!(!got.contains("characters"));
335
- assert!(!got.contains("clock"));
336
- assert!(got.contains("version"));
337
- }
338
-
339
- #[test]
340
- fn aliasing_dollar_bails_everything() {
341
- let s = state(&[("version", json!("v")), ("coins", json!(1))]);
342
- let got = classify_constant_keys(&s, &["const x = $; x.coins = 5;".to_string()]);
343
- assert!(got.is_empty());
344
- }
345
-
346
- #[test]
347
- fn reassigning_dollar_bails_everything() {
348
- let s = state(&[("version", json!("v"))]);
349
- let got = classify_constant_keys(&s, &["let y; y = $;".to_string()]);
350
- assert!(got.is_empty());
351
- }
352
-
353
- #[test]
354
- fn computed_component_state_write_is_ignored() {
355
- // `$[id]` / `$[id].x = …` is Vibe's component-state-by-id pattern (id is a
356
- // generated component id, never a global key), so it must not bail.
357
- let s = state(&[("version", json!("v"))]);
358
- let got = classify_constant_keys(
359
- &s,
360
- &["const id = component({ open: false }); $[id].open = true;".to_string()],
361
- );
362
- assert!(got.contains("version"));
363
- }
364
-
365
- #[test]
366
- fn computed_string_literal_global_write_is_dynamic() {
367
- // A *literal* computed key IS a global-key write and must mark it dynamic.
368
- let s = state(&[("coins", json!(1)), ("version", json!("v"))]);
369
- let got = classify_constant_keys(&s, &["$['coins'] = 5;".to_string()]);
370
- assert!(!got.contains("coins"));
371
- assert!(got.contains("version"));
372
- }
373
-
374
- #[test]
375
- fn object_assign_on_dollar_bails_everything() {
376
- let s = state(&[("version", json!("v"))]);
377
- let got = classify_constant_keys(&s, &["Object.assign($, { coins: 5 });".to_string()]);
378
- assert!(got.is_empty());
379
- }
380
-
381
- #[test]
382
- fn write_in_nested_scope_is_detected() {
383
- let s = state(&[("token", json!(null)), ("version", json!("v"))]);
384
- let src = "function f(){ if (true) { $.token = undefined; } } const g = () => $.version;";
385
- let got = classify_constant_keys(&s, &[src.to_string()]);
386
- assert!(!got.contains("token"));
387
- assert!(got.contains("version"));
388
- }
389
-
390
- #[test]
391
- fn write_across_multiple_sources() {
392
- let s = state(&[("a", json!(1)), ("b", json!(2))]);
393
- let srcs = ["$.a = 1;".to_string(), "console.log($.b);".to_string()];
394
- let got = classify_constant_keys(&s, &srcs);
395
- assert!(!got.contains("a"));
396
- assert!(got.contains("b"));
397
- }
398
-
399
- #[test]
400
- fn reads_do_not_mark_dynamic() {
401
- let s = state(&[("version", json!("v"))]);
402
- let src = "const x = $.version; if ($.version === 'v') foo($.version);";
403
- let got = classify_constant_keys(&s, &[src.to_string()]);
404
- assert!(got.contains("version"));
405
- }
406
-
407
- #[test]
408
- fn passing_dollar_as_argument_does_not_bail() {
409
- let s = state(&[("version", json!("v"))]);
410
- let src = "dataInspectorState($, paths); render($);";
411
- let got = classify_constant_keys(&s, &[src.to_string()]);
412
- assert!(got.contains("version"));
413
- }
414
-
415
- #[test]
416
- fn unparseable_source_bails_everything() {
417
- let s = state(&[("version", json!("v"))]);
418
- let got = classify_constant_keys(&s, &["this is (((not valid @@@".to_string()]);
419
- assert!(got.is_empty());
420
- }
421
-
422
- #[test]
423
- fn handler_body_write_is_detected() {
424
- let s = state(&[("overlay", json!({})), ("version", json!("v"))]);
425
- let got = classify_constant_keys(
426
- &s,
427
- &["event.preventDefault(); $.overlay = { name: 'x' };".to_string()],
428
- );
429
- assert!(got.contains("version"));
430
- assert!(!got.contains("overlay"));
431
- }
432
-
433
- #[test]
434
- fn realistic_app_state_subset() {
435
- // Mirrors the battle-brawlers global state: only `version` survives.
436
- let s = state(&[
437
- ("version", json!("0.1.5")),
438
- ("coins", json!(400)),
439
- ("characters", json!([])),
440
- ("token", json!(null)),
441
- ("settings", json!({ "darkMode": false })),
442
- ]);
443
- let srcs = [
444
- "if (email) $.email = email; $.canTest = false;".to_string(),
445
- "if (gameState.characters) $.characters = gameState.characters;".to_string(),
446
- "if (gameState.coins !== undefined) $.coins = gameState.coins;".to_string(),
447
- "$.token = undefined; const v = $.version;".to_string(),
448
- ];
449
- let got = classify_constant_keys(&s, &srcs);
450
- assert!(got.contains("version"));
451
- assert!(!got.contains("coins"));
452
- assert!(!got.contains("characters"));
453
- assert!(!got.contains("token"));
454
- assert!(!got.contains("settings"));
455
- }
456
- }