@sigil-dev/compiler 0.8.0 → 0.8.2

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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@sigil-dev/compiler",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.8.0",
5
+ "version": "0.8.2",
6
6
  "private": false,
7
7
  "description": "Compiler for the Sigil framework",
8
8
  "peerDependencies": {
@@ -23,6 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@babel/generator": "^7.29.7",
26
+ "@babel/parser": "^7.29.7",
26
27
  "@babel/plugin-transform-typescript": "^8.0.0-rc.6",
27
28
  "@babel/preset-typescript": "^8.0.0-rc.6"
28
29
  }
@@ -4,6 +4,7 @@ export function handleDerivedSSR(
4
4
  declaration: NodePath<t.VariableDeclaration>,
5
5
  declarator: NodePath<t.VariableDeclarator>,
6
6
  init: NodePath<t.CallExpression>,
7
+ _signals?: Set<string>, // SSR has no reactivity, intentionally unused
7
8
  ): void {
8
9
  const { id } = declarator.node;
9
10
  if (!t.isIdentifier(id))
@@ -2,8 +2,9 @@ import type { NodePath, types as t } from "@babel/core";
2
2
 
3
3
  export function handleStateSSR(
4
4
  declaration: NodePath<t.VariableDeclaration>,
5
- declarator: NodePath<t.VariableDeclarator>,
5
+ _declarator: NodePath<t.VariableDeclarator>,
6
6
  init: NodePath<t.CallExpression>,
7
+ _signals?: Set<string>, // SSR has no reactivity. Left in for future reference
7
8
  ): void {
8
9
  init.replaceWith(init.node.arguments[0] as t.Expression);
9
10
  declaration.node.kind = "let";
@@ -115,12 +115,6 @@ export default function sigilPlugin(): PluginObject {
115
115
  "createInspectEffect",
116
116
  "withEffectScope",
117
117
  ];
118
- console.log(
119
- "[sigil exit] didTransform:",
120
- didTransform,
121
- "withEffectScope in required:",
122
- requiredSpecifiers.includes("withEffectScope"),
123
- );
124
118
  const runtimeImport = path.node.body.find(
125
119
  (n): n is t.ImportDeclaration =>
126
120
  t.isImportDeclaration(n) &&
@@ -128,10 +122,10 @@ export default function sigilPlugin(): PluginObject {
128
122
  );
129
123
 
130
124
  if (runtimeImport) {
131
- console.log(
132
- "[sigil exit] found existing import, specifier count:",
133
- runtimeImport.specifiers.length,
134
- );
125
+ // console.log(
126
+ // "[sigil exit] found existing import, specifier count:",
127
+ // runtimeImport.specifiers.length,
128
+ // );
135
129
  const existingNames = new Set(
136
130
  runtimeImport.specifiers
137
131
  .filter(t.isImportSpecifier)
@@ -299,11 +293,13 @@ export default function sigilPlugin(): PluginObject {
299
293
  const innerCallee = object.get("callee");
300
294
  if (innerCallee.isIdentifier({ name: "$inspect" })) {
301
295
  didTransform = true;
296
+ //@ts-expect-error Bizzare but it works if you read LSP
302
297
  const args = object.node.arguments.map(wrapSignal);
303
298
  const cb = path.node.arguments[0];
304
299
  path.replaceWith(
305
300
  t.callExpression(t.identifier(MAGIC.createInspectEffect), [
306
301
  t.arrowFunctionExpression([], t.arrayExpression(args)),
302
+ //@ts-expect-error Bizzare but it works if you read LSP
307
303
  cb,
308
304
  ]),
309
305
  );
@@ -348,6 +344,7 @@ export default function sigilPlugin(): PluginObject {
348
344
  // Handle standalone $inspect(...)
349
345
  if (callee.isIdentifier({ name: "$inspect" })) {
350
346
  didTransform = true;
347
+ //@ts-expect-error Bizzare but it works if you read LSP
351
348
  const args = path.node.arguments.map(wrapSignal);
352
349
  path.replaceWith(
353
350
  t.callExpression(t.identifier(MAGIC.createInspectEffect), [
@@ -8,7 +8,7 @@ import { processFragment } from "./fragment";
8
8
  */
9
9
  export function collectChildren(
10
10
  node: t.JSXElement,
11
- statements: t.Statement[],
11
+ _statements: t.Statement[],
12
12
  genId: () => string,
13
13
  signals: Set<string>,
14
14
  hash?: string,
@@ -1,4 +1,5 @@
1
1
  import { types as t } from "@babel/core";
2
+ //@ts-expect-error Not our problem babel forgot tp export types.
2
3
  import { generate } from "@babel/generator";
3
4
  import {
4
5
  handleGlobalComponent,
@@ -294,14 +295,14 @@ export function processElement(
294
295
  if (bindProp === "group") {
295
296
  // Scan element attributes for type and value
296
297
  let inputType = "";
297
- let inputValue: t.StringLiteral | undefined;
298
+ let _inputValue: t.StringLiteral | undefined;
298
299
  for (const a of node.openingElement.attributes) {
299
300
  if (t.isJSXAttribute(a) && t.isJSXIdentifier(a.name)) {
300
301
  if (a.name.name === "type" && t.isStringLiteral(a.value)) {
301
302
  inputType = a.value.value;
302
303
  }
303
304
  if (a.name.name === "value" && t.isStringLiteral(a.value)) {
304
- inputValue = a.value;
305
+ _inputValue = a.value;
305
306
  }
306
307
  }
307
308
  }
@@ -1,101 +1,106 @@
1
1
  import { types as t } from "@babel/core";
2
2
 
3
3
  export const ATTR_MAP: Record<string, string> = {
4
- class: "className",
5
- for: "htmlFor",
4
+ class: "className",
5
+ for: "htmlFor",
6
6
  };
7
7
 
8
8
  export function containsSignal(
9
- expr: t.Expression | t.Node,
10
- signals: Set<string>,
9
+ expr: t.Expression | t.Node,
10
+ signals: Set<string>,
11
11
  ): boolean {
12
- if (!expr) return false;
13
- if (t.isIdentifier(expr)) return signals.has(expr.name);
14
- if (t.isCallExpression(expr)) {
15
- return (
16
- containsSignal(expr.callee as t.Expression, signals) ||
17
- expr.arguments.some((a) => containsSignal(a as t.Expression, signals))
18
- );
19
- }
20
- if (t.isBinaryExpression(expr)) {
21
- return (
22
- containsSignal(expr.left as t.Expression, signals) ||
23
- containsSignal(expr.right, signals)
24
- );
25
- }
26
- if (t.isMemberExpression(expr)) {
27
- return containsSignal(expr.object as t.Expression, signals);
28
- }
29
- if (t.isLogicalExpression(expr)) {
30
- return (
31
- containsSignal(expr.left as t.Expression, signals) ||
32
- containsSignal(expr.right as t.Expression, signals)
33
- );
34
- }
35
- if (t.isConditionalExpression(expr)) {
36
- return (
37
- containsSignal(expr.test as t.Expression, signals) ||
38
- containsSignal(expr.consequent as t.Expression, signals) ||
39
- containsSignal(expr.alternate as t.Expression, signals)
40
- );
41
- }
42
- if (t.isUnaryExpression(expr)) {
43
- return containsSignal(expr.argument as t.Expression, signals);
44
- }
45
- if (t.isTemplateLiteral(expr)) {
46
- return expr.expressions.some((e) =>
47
- containsSignal(e as t.Expression, signals),
48
- );
49
- }
50
- // ── additions ──
51
- if (t.isArrayExpression(expr)) {
52
- return expr.elements.some(
53
- (e) => e !== null && containsSignal(e as t.Expression, signals),
54
- );
55
- }
56
- if (t.isObjectExpression(expr)) {
57
- return expr.properties.some((p) => {
58
- if (t.isObjectProperty(p)) return containsSignal(p.value as t.Expression, signals);
59
- if (t.isSpreadElement(p)) return containsSignal(p.argument, signals);
60
- return false;
61
- });
62
- }
63
- if (t.isSequenceExpression(expr)) {
64
- return expr.expressions.some((e) => containsSignal(e, signals));
65
- }
66
- if (t.isAssignmentExpression(expr)) {
67
- return containsSignal(expr.right, signals);
68
- }
69
- if (t.isSpreadElement(expr)) {
70
- return containsSignal(expr.argument, signals);
71
- }
72
- // ArrowFunction/Function — don't traverse, signals inside are captured
73
- // not reactive at the call site (onclick={() => count()} is fine as-is)
74
- if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
75
- return false;
76
- }
77
- // Babel represents ?. as distinct node types not in t.is* guards
78
- if ((expr as any).type === "OptionalMemberExpression") {
79
- return containsSignal((expr as any).object, signals);
80
- }
81
- if ((expr as any).type === "OptionalCallExpression") {
82
- const n = expr as any;
83
- return (
84
- containsSignal(n.callee, signals) ||
85
- n.arguments.some((a: t.Node) => containsSignal(a, signals))
86
- );
87
- }
88
- return false;
12
+ if (!expr) return false;
13
+ if (t.isIdentifier(expr)) return signals.has(expr.name);
14
+ if (t.isCallExpression(expr)) {
15
+ return (
16
+ containsSignal(expr.callee as t.Expression, signals) ||
17
+ expr.arguments.some((a) => containsSignal(a as t.Expression, signals))
18
+ );
19
+ }
20
+ if (t.isBinaryExpression(expr)) {
21
+ return (
22
+ containsSignal(expr.left as t.Expression, signals) ||
23
+ containsSignal(expr.right, signals)
24
+ );
25
+ }
26
+ if (t.isMemberExpression(expr)) {
27
+ return containsSignal(expr.object as t.Expression, signals);
28
+ }
29
+ if (t.isLogicalExpression(expr)) {
30
+ return (
31
+ containsSignal(expr.left as t.Expression, signals) ||
32
+ containsSignal(expr.right as t.Expression, signals)
33
+ );
34
+ }
35
+ if (t.isConditionalExpression(expr)) {
36
+ return (
37
+ containsSignal(expr.test as t.Expression, signals) ||
38
+ containsSignal(expr.consequent as t.Expression, signals) ||
39
+ containsSignal(expr.alternate as t.Expression, signals)
40
+ );
41
+ }
42
+ if (t.isUnaryExpression(expr)) {
43
+ return containsSignal(expr.argument as t.Expression, signals);
44
+ }
45
+ if (t.isTemplateLiteral(expr)) {
46
+ return expr.expressions.some((e) =>
47
+ containsSignal(e as t.Expression, signals),
48
+ );
49
+ }
50
+ // ── additions ──
51
+ if (t.isArrayExpression(expr)) {
52
+ return expr.elements.some(
53
+ (e) => e !== null && containsSignal(e as t.Expression, signals),
54
+ );
55
+ }
56
+ if (t.isObjectExpression(expr)) {
57
+ return expr.properties.some((p) => {
58
+ if (t.isObjectProperty(p)) {
59
+ const computedKey = p.computed
60
+ ? containsSignal(p.key as t.Expression, signals)
61
+ : false;
62
+ return computedKey || containsSignal(p.value as t.Expression, signals);
63
+ }
64
+ if (t.isSpreadElement(p)) return containsSignal(p.argument, signals);
65
+ return false;
66
+ });
67
+ }
68
+ if (t.isSequenceExpression(expr)) {
69
+ return expr.expressions.some((e) => containsSignal(e, signals));
70
+ }
71
+ if (t.isAssignmentExpression(expr)) {
72
+ return containsSignal(expr.right, signals);
73
+ }
74
+ if (t.isSpreadElement(expr)) {
75
+ return containsSignal(expr.argument, signals);
76
+ }
77
+ // ArrowFunction/Function don't traverse, signals inside are captured
78
+ // not reactive at the call site (onclick={() => count()} is fine as-is)
79
+ if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
80
+ return false;
81
+ }
82
+ // Babel represents ?. as distinct node types not in t.is* guards
83
+ if (t.isOptionalMemberExpression(expr)) {
84
+ return containsSignal((expr as any).object, signals);
85
+ }
86
+ if (t.isOptionalCallExpression(expr)) {
87
+ const n = expr as any;
88
+ return (
89
+ containsSignal(n.callee, signals) ||
90
+ n.arguments.some((a: t.Node) => containsSignal(a, signals))
91
+ );
92
+ }
93
+ return false;
89
94
  }
90
95
 
91
96
  export function isPrimitive(expr: t.Expression): boolean {
92
- if (t.isCallExpression(expr) && t.isIdentifier(expr.callee)) return true;
93
- if (t.isBinaryExpression(expr)) return true;
94
- if (t.isTemplateLiteral(expr)) return true;
95
- if (t.isIdentifier(expr)) return true;
96
- // user().name is a MemberExpression after state handler rewrites user → user()
97
- if (t.isMemberExpression(expr)) return true;
98
- return false;
97
+ if (t.isCallExpression(expr) && t.isIdentifier(expr.callee)) return true;
98
+ if (t.isBinaryExpression(expr)) return true;
99
+ if (t.isTemplateLiteral(expr)) return true;
100
+ if (t.isIdentifier(expr)) return true;
101
+ // user().name is a MemberExpression after state handler rewrites user → user()
102
+ if (t.isMemberExpression(expr)) return true;
103
+ return false;
99
104
  }
100
105
 
101
106
  /**
@@ -103,22 +108,22 @@ export function isPrimitive(expr: t.Expression): boolean {
103
108
  * When parentVar is provided, passes it to claim() for SPA fallback (append on create).
104
109
  */
105
110
  export function getCreateElement(
106
- tag: string,
107
- hydrate: boolean,
108
- nodesVar = "__nodes",
109
- parentVar?: string,
111
+ tag: string,
112
+ hydrate: boolean,
113
+ nodesVar = "__nodes",
114
+ parentVar?: string,
110
115
  ): t.Expression {
111
- if (hydrate) {
112
- const args: t.Expression[] = [t.identifier(nodesVar), t.stringLiteral(tag)];
113
- if (parentVar) {
114
- args.push(t.identifier(parentVar));
115
- }
116
- return t.callExpression(t.identifier("claim"), args);
117
- }
118
- return t.callExpression(
119
- t.memberExpression(t.identifier("document"), t.identifier("createElement")),
120
- [t.stringLiteral(tag)],
121
- );
116
+ if (hydrate) {
117
+ const args: t.Expression[] = [t.identifier(nodesVar), t.stringLiteral(tag)];
118
+ if (parentVar) {
119
+ args.push(t.identifier(parentVar));
120
+ }
121
+ return t.callExpression(t.identifier("claim"), args);
122
+ }
123
+ return t.callExpression(
124
+ t.memberExpression(t.identifier("document"), t.identifier("createElement")),
125
+ [t.stringLiteral(tag)],
126
+ );
122
127
  }
123
128
 
124
129
  /**
@@ -126,24 +131,24 @@ export function getCreateElement(
126
131
  * When parentVar is provided, passes it to claimText() for SPA fallback.
127
132
  */
128
133
  export function getCreateText(
129
- hydrate: boolean,
130
- nodesVar = "__nodes",
131
- parentVar?: string,
134
+ hydrate: boolean,
135
+ nodesVar = "__nodes",
136
+ parentVar?: string,
132
137
  ): t.Expression {
133
- if (hydrate) {
134
- const args: t.Expression[] = [t.identifier(nodesVar)];
135
- if (parentVar) {
136
- args.push(t.identifier(parentVar));
137
- }
138
- return t.callExpression(t.identifier("claimText"), args);
139
- }
140
- return t.callExpression(
141
- t.memberExpression(
142
- t.identifier("document"),
143
- t.identifier("createTextNode"),
144
- ),
145
- [t.stringLiteral("")],
146
- );
138
+ if (hydrate) {
139
+ const args: t.Expression[] = [t.identifier(nodesVar)];
140
+ if (parentVar) {
141
+ args.push(t.identifier(parentVar));
142
+ }
143
+ return t.callExpression(t.identifier("claimText"), args);
144
+ }
145
+ return t.callExpression(
146
+ t.memberExpression(
147
+ t.identifier("document"),
148
+ t.identifier("createTextNode"),
149
+ ),
150
+ [t.stringLiteral("")],
151
+ );
147
152
  }
148
153
 
149
154
  /**
@@ -151,24 +156,24 @@ export function getCreateText(
151
156
  * Returns the generated variable name for use by children.
152
157
  */
153
158
  export function buildHydrationScope(
154
- parentVar: string,
155
- statements: t.Statement[],
156
- nodesVar: string,
159
+ parentVar: string,
160
+ statements: t.Statement[],
161
+ nodesVar: string,
157
162
  ): void {
158
- statements.push(
159
- t.variableDeclaration("const", [
160
- t.variableDeclarator(
161
- t.identifier(nodesVar),
162
- t.callExpression(
163
- t.memberExpression(t.identifier("Array"), t.identifier("from")),
164
- [
165
- t.memberExpression(
166
- t.identifier(parentVar),
167
- t.identifier("childNodes"),
168
- ),
169
- ],
170
- ),
171
- ),
172
- ]),
173
- );
163
+ statements.push(
164
+ t.variableDeclaration("const", [
165
+ t.variableDeclarator(
166
+ t.identifier(nodesVar),
167
+ t.callExpression(
168
+ t.memberExpression(t.identifier("Array"), t.identifier("from")),
169
+ [
170
+ t.memberExpression(
171
+ t.identifier(parentVar),
172
+ t.identifier("childNodes"),
173
+ ),
174
+ ],
175
+ ),
176
+ ),
177
+ ]),
178
+ );
174
179
  }
@@ -75,7 +75,7 @@ export function buildBind(
75
75
  export function buildProxyBind(
76
76
  varName: string,
77
77
  bindProp: string,
78
- attr: t.JSXAttribute,
78
+ _attr: t.JSXAttribute,
79
79
  statements: t.Statement[],
80
80
  signal: t.MemberExpression, // already form().name
81
81
  ): void {
@@ -1,4 +1,4 @@
1
- import { createHash } from "crypto";
1
+ import { createHash } from "node:crypto";
2
2
 
3
3
  /**
4
4
  * Compute a deterministic hash from a file path.
@@ -6,7 +6,7 @@ import { createHash } from "crypto";
6
6
  * 's' prefix for Sigil.
7
7
  */
8
8
  export function computeHash(filePath: string): string {
9
- return "s" + createHash("md5").update(filePath).digest("hex").slice(0, 8);
9
+ return `s${createHash("md5").update(filePath).digest("hex").slice(0, 8)}`;
10
10
  }
11
11
 
12
12
  /**
@@ -141,10 +141,10 @@ function rewriteSelector(selector: string, hash: string): string {
141
141
  // Check for placeholder
142
142
  const globalMatch = trimmed.match(/^__GLOBAL_(\d+)__$/);
143
143
  if (globalMatch) {
144
- return globals[parseInt(globalMatch[1]!)];
144
+ return globals[parseInt(globalMatch[1]!, 10)];
145
145
  }
146
146
 
147
- return trimmed + "." + hash;
147
+ return `${trimmed}.${hash}`;
148
148
  });
149
149
 
150
150
  return parts.join(", ");
@@ -1,4 +1,4 @@
1
- import { type NodePath, types as t, traverse } from "@babel/core";
1
+ import { type NodePath, types as t } from "@babel/core";
2
2
 
3
3
  export function warnDeadReactivity(
4
4
  path: NodePath,
@@ -19,7 +19,7 @@ export function warnDeadReactivity(
19
19
  for (const name of signals) {
20
20
  if (storeSignals.has(name)) continue; // exported, used across files
21
21
  if (!used.has(name)) {
22
- const tag = filename ? `[sigil:${filename}]` : "[sigil]";
22
+ const tag = filename ? `[${filename}]` : "[sigil]";
23
23
  console.warn(
24
24
  `${tag} unused reactive declaration: "${name}" is declared with $state or $derived but never read.`,
25
25
  );
package/src/bun-plugin.ts CHANGED
@@ -73,14 +73,13 @@ export function sigil(options?: {
73
73
  });
74
74
  let out = transpiler.transformSync(res?.code ?? "");
75
75
  if (scopedCSS && options?.mode !== "ssr") {
76
- out =
77
- `
76
+ out = `
78
77
  if (typeof document !== 'undefined' && !document.getElementById('sigil-${hash}')) {
79
78
  const __style = document.createElement('style');
80
79
  __style.id = 'sigil-${hash}';
81
80
  __style.textContent = ${JSON.stringify(scopedCSS)};
82
81
  document.head.appendChild(__style);
83
- }` + out;
82
+ }${out}`;
84
83
  }
85
84
  return { contents: out, loader: "js" };
86
85
  });
@@ -0,0 +1,288 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { parseExpression } from "@babel/parser";
3
+ import type { Expression } from "@babel/types";
4
+ import { containsSignal } from "../src/babel/jsx/utils";
5
+
6
+ // Our containsSignal is so jank it needs a dedicated test suite.
7
+
8
+ // ── helpers ──────────────────────────────────────────────────────────────────
9
+
10
+ function parse(code: string): Expression {
11
+ return parseExpression(code, {
12
+ plugins: ["typescript", "jsx"],
13
+ strictMode: false,
14
+ }) as Expression;
15
+ }
16
+
17
+ /** Signals in scope for all tests */
18
+ const SIG = new Set(["count", "name", "items", "user", "flag"]);
19
+
20
+ function yes(code: string, msg?: string) {
21
+ it(msg ?? `contains signal: ${code}`, () => {
22
+ expect(containsSignal(parse(code), SIG)).toBe(true);
23
+ });
24
+ }
25
+
26
+ function no(code: string, msg?: string) {
27
+ it(msg ?? `no signal: ${code}`, () => {
28
+ expect(containsSignal(parse(code), SIG)).toBe(false);
29
+ });
30
+ }
31
+
32
+ // ── Identifier ───────────────────────────────────────────────────────────────
33
+
34
+ describe("Identifier", () => {
35
+ yes("count");
36
+ yes("name");
37
+ no("foo");
38
+ no("bar");
39
+ no("unrelated");
40
+ });
41
+
42
+ // ── CallExpression ───────────────────────────────────────────────────────────
43
+
44
+ describe("CallExpression — callee", () => {
45
+ yes("count()");
46
+ yes("name()");
47
+ no("foo()");
48
+ no("Math.random()");
49
+ });
50
+
51
+ describe("CallExpression — arguments", () => {
52
+ yes("foo(count())");
53
+ yes("foo(bar, count())");
54
+ yes("foo(1, 2, count())");
55
+ yes("foo(name(), bar)");
56
+ no("foo(bar, baz)");
57
+ no("foo(1, 2, 3)");
58
+ });
59
+
60
+ describe("CallExpression — nested callee", () => {
61
+ yes("count()()"); // count is in SIG, callee of outer call
62
+ yes("foo(bar(count()))"); // signal buried in nested argument
63
+ no("foo(bar(baz()))");
64
+ no("getSignal()()"); // getSignal not in SIG — correctly false
65
+ });
66
+
67
+ // ── MemberExpression ─────────────────────────────────────────────────────────
68
+
69
+ describe("MemberExpression", () => {
70
+ yes("count().value");
71
+ yes("count().nested.deep");
72
+ yes("name().length");
73
+ no("foo.bar");
74
+ no("obj.prop");
75
+ no("Math.PI");
76
+ no("foo.bar.baz");
77
+ });
78
+
79
+ // ── OptionalMemberExpression ─────────────────────────────────────────────────
80
+
81
+ describe("OptionalMemberExpression", () => {
82
+ yes("count()?.value");
83
+ yes("count()?.foo?.bar");
84
+ yes("name()?.length");
85
+ no("foo?.bar");
86
+ no("obj?.prop");
87
+ no("foo?.bar?.baz");
88
+ });
89
+
90
+ // ── OptionalCallExpression ───────────────────────────────────────────────────
91
+
92
+ describe("OptionalCallExpression", () => {
93
+ yes("count?.()");
94
+ yes("foo?.(count())");
95
+ yes("foo?.(bar, count())");
96
+ no("foo?.()");
97
+ no("foo?.(bar, baz)");
98
+ });
99
+
100
+ // ── BinaryExpression ─────────────────────────────────────────────────────────
101
+
102
+ describe("BinaryExpression", () => {
103
+ yes("count() + 1");
104
+ yes("1 + count()");
105
+ yes("count() > 0");
106
+ yes("count() === name()");
107
+ yes("foo + count()");
108
+ no("1 + 2");
109
+ no("foo + bar");
110
+ no("'a' + 'b'");
111
+ });
112
+
113
+ // ── LogicalExpression ────────────────────────────────────────────────────────
114
+
115
+ describe("LogicalExpression", () => {
116
+ yes("count() && foo");
117
+ yes("foo && count()");
118
+ yes("count() || name()");
119
+ yes("foo ?? count()");
120
+ no("foo && bar");
121
+ no("a || b");
122
+ no("null ?? 'default'");
123
+ });
124
+
125
+ // ── ConditionalExpression ────────────────────────────────────────────────────
126
+
127
+ describe("ConditionalExpression", () => {
128
+ yes("count() ? 'a' : 'b'", "signal in test");
129
+ yes("foo ? count() : 'b'", "signal in consequent");
130
+ yes("foo ? 'a' : count()", "signal in alternate");
131
+ yes("count() ? name() : 'b'", "signal in test and consequent");
132
+ no("foo ? 'a' : 'b'");
133
+ no("true ? 1 : 0");
134
+ });
135
+
136
+ // ── UnaryExpression ──────────────────────────────────────────────────────────
137
+
138
+ describe("UnaryExpression", () => {
139
+ yes("!count()");
140
+ yes("-count()");
141
+ yes("typeof count()");
142
+ no("!foo");
143
+ no("-1");
144
+ no("typeof foo");
145
+ });
146
+
147
+ // ── TemplateLiteral ──────────────────────────────────────────────────────────
148
+
149
+ describe("TemplateLiteral", () => {
150
+ yes("`${count()}`");
151
+ yes("`hello ${name()}`");
152
+ yes("`${foo} ${count()}`");
153
+ yes("`${count()} and ${name()}`");
154
+ no("`hello world`");
155
+ no("`${foo}`");
156
+ no("`${foo} ${bar}`");
157
+ });
158
+
159
+ // ── ArrayExpression ──────────────────────────────────────────────────────────
160
+
161
+ describe("ArrayExpression", () => {
162
+ yes("[count()]");
163
+ yes("[foo, count()]");
164
+ yes("[1, 2, count()]");
165
+ yes("[...count()]");
166
+ no("[]");
167
+ no("[1, 2, 3]");
168
+ no("[foo, bar]");
169
+ // sparse arrays have null holes
170
+ no("[,,,]", "sparse array holes");
171
+ });
172
+
173
+ // ── ObjectExpression ─────────────────────────────────────────────────────────
174
+
175
+ describe("ObjectExpression", () => {
176
+ yes("({ a: count() })");
177
+ yes("({ a: foo, b: count() })");
178
+ yes("({ ...count() })");
179
+ yes("({ [name()]: 'val' })", "computed key with signal");
180
+ no("({})");
181
+ no("({ a: 1 })");
182
+ no("({ a: foo, b: bar })");
183
+ no("({ ...foo })");
184
+ });
185
+
186
+ // ── SequenceExpression ───────────────────────────────────────────────────────
187
+
188
+ describe("SequenceExpression", () => {
189
+ yes("(foo, count())");
190
+ yes("(count(), foo)");
191
+ yes("(a, b, count())");
192
+ no("(a, b)");
193
+ no("(1, 2, 3)");
194
+ });
195
+
196
+ // ── AssignmentExpression ─────────────────────────────────────────────────────
197
+
198
+ describe("AssignmentExpression", () => {
199
+ // only RHS matters for reactivity — LHS is where you're writing to
200
+ yes("x = count()");
201
+ yes("x += count()");
202
+ no("x = foo");
203
+ no("x = 1");
204
+ });
205
+
206
+ // ── SpreadElement ────────────────────────────────────────────────────────────
207
+
208
+ describe("SpreadElement — inside array/call", () => {
209
+ yes("[...count()]");
210
+ yes("foo(...count())");
211
+ no("[...foo]");
212
+ no("foo(...bar)");
213
+ });
214
+
215
+ // ── ArrowFunctionExpression ──────────────────────────────────────────────────
216
+ // signals inside a function body are captured at call time, not bind time —
217
+ // making the attribute reactive would re-assign the handler on every signal change
218
+
219
+ describe("ArrowFunctionExpression — must NOT traverse body", () => {
220
+ no("() => count()");
221
+ no("(x) => count() + x");
222
+ no("() => { return count(); }");
223
+ no("(e) => { count(); }");
224
+ });
225
+
226
+ // ── FunctionExpression ───────────────────────────────────────────────────────
227
+
228
+ describe("FunctionExpression — must NOT traverse body", () => {
229
+ no("function() { return count(); }");
230
+ no("function handler() { count(); }");
231
+ });
232
+
233
+ // ── Literals (always false) ──────────────────────────────────────────────────
234
+
235
+ describe("Literals", () => {
236
+ no("1");
237
+ no("'hello'");
238
+ no("true");
239
+ no("null");
240
+ no("/regex/");
241
+ });
242
+
243
+ // ── Deep nesting ─────────────────────────────────────────────────────────────
244
+
245
+ describe("Deep nesting", () => {
246
+ yes("foo(bar(baz(count())))");
247
+ yes("a && b ? c + count() : d");
248
+ yes("`prefix_${foo ? count() : bar}`");
249
+ yes("[{ a: count() }]");
250
+ yes("({ arr: [1, count()] })");
251
+ yes("foo?.bar?.(count())");
252
+ no("foo(bar(baz(qux())))");
253
+ no("a && b ? c + d : e");
254
+ });
255
+
256
+ // ── Multiple signals ─────────────────────────────────────────────────────────
257
+
258
+ describe("Multiple signals", () => {
259
+ yes("count() + name()");
260
+ yes("[count(), name(), items()]");
261
+ yes("user()?.profile ?? name()");
262
+ yes("flag() ? count() : name()");
263
+ });
264
+
265
+ // ── Non-signal identifiers that look similar ─────────────────────────────────
266
+
267
+ describe("Near-miss identifiers", () => {
268
+ no("counter"); // not "count"
269
+ no("counted");
270
+ no("names"); // not "name"
271
+ no("userCount");
272
+ no("flagged");
273
+ no("itemsList");
274
+ });
275
+
276
+ // ── null/undefined safety ────────────────────────────────────────────────────
277
+
278
+ describe("null/undefined guard", () => {
279
+ it("returns false for null input", () => {
280
+ expect(containsSignal(null as any, SIG)).toBe(false);
281
+ });
282
+ it("returns false for undefined input", () => {
283
+ expect(containsSignal(undefined as any, SIG)).toBe(false);
284
+ });
285
+ it("empty signal set never matches", () => {
286
+ expect(containsSignal(parse("count()"), new Set())).toBe(false);
287
+ });
288
+ });