sinwan-compiler 0.3.0 → 0.4.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.
@@ -3,16 +3,18 @@
3
3
  import * as path3 from "path";
4
4
 
5
5
  // src/analyze.ts
6
- import { parse } from "@babel/parser";
6
+ import { parse as parse2 } from "@babel/parser";
7
7
  import * as t3 from "@babel/types";
8
8
  import _traverse3 from "@babel/traverse";
9
- import * as fs from "fs";
9
+ import * as fs2 from "fs";
10
10
  import * as path2 from "path";
11
11
 
12
12
  // src/reactive-wrap.ts
13
13
  import * as t from "@babel/types";
14
14
  import _traverse from "@babel/traverse";
15
15
  import * as path from "path";
16
+ import { parse } from "@babel/parser";
17
+ import * as fs from "fs";
16
18
  var traverse = typeof _traverse === "function" ? _traverse : _traverse.default ?? _traverse;
17
19
  var REACTIVE_SOURCE_MODULES = {
18
20
  "sinwan/store": new Set(["createMutable", "createStore"]),
@@ -87,6 +89,282 @@ function trackReactiveImports2(ast) {
87
89
  });
88
90
  return names;
89
91
  }
92
+ var pureImportCache = new Map;
93
+ var PURE_EXTENSIONS = [
94
+ ".ts",
95
+ ".tsx",
96
+ ".js",
97
+ ".jsx",
98
+ ".mjs",
99
+ ".cjs",
100
+ ".mts",
101
+ ".cts"
102
+ ];
103
+ function tryResolveFile(base) {
104
+ if (PURE_EXTENSIONS.some((ext) => base.endsWith(ext))) {
105
+ try {
106
+ if (fs.existsSync(base))
107
+ return base;
108
+ } catch {}
109
+ return null;
110
+ }
111
+ for (const ext of PURE_EXTENSIONS) {
112
+ try {
113
+ if (fs.existsSync(base + ext))
114
+ return base + ext;
115
+ } catch {}
116
+ }
117
+ return null;
118
+ }
119
+ function tryResolveIndex(dir) {
120
+ for (const ext of PURE_EXTENSIONS) {
121
+ try {
122
+ const candidate = path.join(dir, "index") + ext;
123
+ if (fs.existsSync(candidate))
124
+ return candidate;
125
+ } catch {}
126
+ }
127
+ return null;
128
+ }
129
+ function tryResolveRelative(source, fromFile) {
130
+ const base = path.resolve(path.dirname(fromFile), source);
131
+ return tryResolveFile(base) ?? tryResolveIndex(base);
132
+ }
133
+ function tryResolveBare(source, fromFile) {
134
+ const parts = source.split("/");
135
+ const pkgName = source.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
136
+ const subpath = parts.slice(source.startsWith("@") ? 2 : 1).join("/");
137
+ let dir = path.dirname(fromFile);
138
+ for (let i = 0;i < 30; i++) {
139
+ const nodeModules = path.join(dir, "node_modules", pkgName);
140
+ if (fs.existsSync(nodeModules)) {
141
+ return resolvePackageEntry(nodeModules, subpath);
142
+ }
143
+ const parent = path.dirname(dir);
144
+ if (parent === dir)
145
+ break;
146
+ dir = parent;
147
+ }
148
+ return null;
149
+ }
150
+ function resolvePackageEntry(pkgDir, subpath) {
151
+ let pkgJson;
152
+ try {
153
+ pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf-8"));
154
+ } catch {
155
+ return null;
156
+ }
157
+ if (typeof pkgJson.exports === "object" && pkgJson.exports) {
158
+ const exports = pkgJson.exports;
159
+ const exportKey = subpath ? `./${subpath}` : ".";
160
+ const entry = exports[exportKey];
161
+ if (typeof entry === "string") {
162
+ const resolved = tryResolveFile(path.resolve(pkgDir, entry));
163
+ if (resolved)
164
+ return resolved;
165
+ } else if (entry && typeof entry === "object") {
166
+ for (const condValue of Object.values(entry)) {
167
+ if (typeof condValue === "string") {
168
+ const resolved = tryResolveFile(path.resolve(pkgDir, condValue));
169
+ if (resolved)
170
+ return resolved;
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (!subpath) {
176
+ for (const field of ["source", "module", "main", "types"]) {
177
+ const entry = pkgJson[field];
178
+ if (typeof entry === "string") {
179
+ const resolved = tryResolveFile(path.resolve(pkgDir, entry));
180
+ if (resolved)
181
+ return resolved;
182
+ }
183
+ }
184
+ const indexResolved = tryResolveIndex(pkgDir);
185
+ if (indexResolved)
186
+ return indexResolved;
187
+ }
188
+ if (subpath) {
189
+ for (const srcDir of ["src", "dist", "."]) {
190
+ const base = path.join(pkgDir, srcDir, subpath);
191
+ const resolved = tryResolveFile(base) ?? tryResolveIndex(base);
192
+ if (resolved)
193
+ return resolved;
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+ function resolveAnyImport(source, fromFile, resolveImport) {
199
+ if (resolveImport) {
200
+ try {
201
+ const resolved = resolveImport(source, fromFile);
202
+ if (resolved)
203
+ return resolved;
204
+ } catch {}
205
+ }
206
+ if (source.startsWith(".")) {
207
+ return tryResolveRelative(source, fromFile);
208
+ }
209
+ return tryResolveBare(source, fromFile);
210
+ }
211
+ function scanPureExports(filePath, resolveImport, depth = 0) {
212
+ const pure = new Map;
213
+ if (depth > 10)
214
+ return pure;
215
+ let code;
216
+ try {
217
+ code = fs.readFileSync(filePath, "utf-8");
218
+ } catch {
219
+ return pure;
220
+ }
221
+ let ast;
222
+ try {
223
+ ast = parse(code, {
224
+ sourceType: "module",
225
+ plugins: ["jsx", "typescript"],
226
+ sourceFilename: filePath
227
+ });
228
+ } catch {
229
+ return pure;
230
+ }
231
+ const hasPureJSDoc = (node) => node?.leadingComments?.some((c) => c.type === "CommentBlock" && c.value.includes("@sinwan-pure")) ?? false;
232
+ traverse(ast, {
233
+ ExportNamedDeclaration(p) {
234
+ if (hasPureJSDoc(p.node)) {
235
+ const decl = p.node.declaration;
236
+ if (t.isFunctionDeclaration(decl) && decl.id) {
237
+ pure.set(decl.id.name, "function");
238
+ } else if (t.isClassDeclaration(decl) && decl.id) {
239
+ pure.set(decl.id.name, "class");
240
+ } else if (t.isVariableDeclaration(decl)) {
241
+ for (const d of decl.declarations) {
242
+ if (!t.isIdentifier(d.id))
243
+ continue;
244
+ const init = d.init;
245
+ if (t.isObjectExpression(init)) {
246
+ pure.set(d.id.name, "object");
247
+ } else {
248
+ pure.set(d.id.name, "function");
249
+ }
250
+ }
251
+ }
252
+ }
253
+ const source = p.node.source?.value;
254
+ if (source && p.node.specifiers) {
255
+ const resolved = resolveAnyImport(source, filePath, resolveImport);
256
+ if (resolved) {
257
+ let reExportPure = pureImportCache.get(resolved);
258
+ if (!reExportPure) {
259
+ reExportPure = scanPureExports(resolved, resolveImport, depth + 1);
260
+ pureImportCache.set(resolved, reExportPure);
261
+ }
262
+ for (const spec of p.node.specifiers) {
263
+ if (!t.isExportSpecifier(spec))
264
+ continue;
265
+ const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value;
266
+ const original = spec.local.name;
267
+ const kind = reExportPure.get(original);
268
+ if (kind) {
269
+ pure.set(exported, kind);
270
+ }
271
+ }
272
+ }
273
+ }
274
+ },
275
+ ExportDefaultDeclaration(p) {
276
+ if (!hasPureJSDoc(p.node))
277
+ return;
278
+ const decl = p.node.declaration;
279
+ if (t.isFunctionDeclaration(decl) || t.isArrowFunctionExpression(decl) || t.isFunctionExpression(decl)) {
280
+ pure.set("default", "function");
281
+ } else if (t.isClassDeclaration(decl)) {
282
+ pure.set("default", "class");
283
+ }
284
+ },
285
+ ExportAllDeclaration(p) {
286
+ const source = p.node.source?.value;
287
+ if (!source)
288
+ return;
289
+ const resolved = resolveAnyImport(source, filePath, resolveImport);
290
+ if (!resolved)
291
+ return;
292
+ let reExportPure = pureImportCache.get(resolved);
293
+ if (!reExportPure) {
294
+ reExportPure = scanPureExports(resolved, resolveImport, depth + 1);
295
+ pureImportCache.set(resolved, reExportPure);
296
+ }
297
+ for (const [name, kind] of reExportPure) {
298
+ if (name !== "default") {
299
+ pure.set(name, kind);
300
+ }
301
+ }
302
+ }
303
+ });
304
+ return pure;
305
+ }
306
+ function collectPureImports(ast, filename, resolveImport) {
307
+ const result = {
308
+ direct: new Set,
309
+ namespace: new Map,
310
+ member: new Set
311
+ };
312
+ if (!filename)
313
+ return result;
314
+ const absoluteFilename = path.resolve(filename);
315
+ traverse(ast, {
316
+ ImportDeclaration(p) {
317
+ const source = p.node.source.value;
318
+ if (p.node.importKind === "type")
319
+ return;
320
+ const resolved = resolveAnyImport(source, absoluteFilename, resolveImport);
321
+ if (!resolved)
322
+ return;
323
+ let filePure = pureImportCache.get(resolved);
324
+ if (!filePure) {
325
+ filePure = scanPureExports(resolved, resolveImport);
326
+ pureImportCache.set(resolved, filePure);
327
+ }
328
+ for (const spec of p.node.specifiers) {
329
+ if (t.isImportSpecifier(spec)) {
330
+ const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value;
331
+ const kind = filePure.get(imported);
332
+ if (!kind)
333
+ continue;
334
+ if (kind === "function") {
335
+ result.direct.add(spec.local.name);
336
+ } else {
337
+ result.member.add(spec.local.name);
338
+ }
339
+ } else if (t.isImportDefaultSpecifier(spec)) {
340
+ const kind = filePure.get("default");
341
+ if (!kind)
342
+ continue;
343
+ if (kind === "function") {
344
+ result.direct.add(spec.local.name);
345
+ } else {
346
+ result.member.add(spec.local.name);
347
+ }
348
+ } else if (t.isImportNamespaceSpecifier(spec)) {
349
+ const pureMembers = new Set;
350
+ for (const [name, kind] of filePure) {
351
+ if (name === "default")
352
+ continue;
353
+ if (kind === "function") {
354
+ pureMembers.add(name);
355
+ } else {
356
+ pureMembers.add(name);
357
+ }
358
+ }
359
+ if (pureMembers.size > 0) {
360
+ result.namespace.set(spec.local.name, pureMembers);
361
+ }
362
+ }
363
+ }
364
+ }
365
+ });
366
+ return result;
367
+ }
90
368
  function isReactiveSourceCall(expr, names) {
91
369
  if (!t.isCallExpression(expr))
92
370
  return null;
@@ -136,8 +414,16 @@ function isUseFetchCall(expr, names) {
136
414
  }
137
415
  return false;
138
416
  }
139
- function trackLocalScopeBindings(path, names, scope) {
140
- path.traverse({
417
+ function trackLocalScopeBindings(path, names, scope, shallow = false) {
418
+ const collect = (visitors) => {
419
+ path.traverse(shallow ? {
420
+ ...visitors,
421
+ Function(p) {
422
+ p.skip();
423
+ }
424
+ } : visitors);
425
+ };
426
+ collect({
141
427
  VariableDeclarator(p) {
142
428
  const id = p.node.id;
143
429
  const init = p.node.init;
@@ -187,7 +473,7 @@ function trackLocalScopeBindings(path, names, scope) {
187
473
  }
188
474
  }
189
475
  });
190
- path.traverse({
476
+ collect({
191
477
  VariableDeclarator(p) {
192
478
  const id = p.node.id;
193
479
  const init = p.node.init;
@@ -213,7 +499,7 @@ function trackLocalScopeBindings(path, names, scope) {
213
499
  }
214
500
  }
215
501
  });
216
- path.traverse({
502
+ collect({
217
503
  "FunctionDeclaration|VariableDeclarator"(p) {
218
504
  const node = p.node;
219
505
  let fn = null;
@@ -924,7 +1210,184 @@ function propagateReactiveProps2(localScopes, callGraph, componentFunctions) {
924
1210
  }
925
1211
  return reactiveProps;
926
1212
  }
1213
+ function hasSnapshotPragma(node) {
1214
+ return node.leadingComments?.some((comment) => /\bsinwan-snapshot\b/.test(comment.value)) ?? false;
1215
+ }
1216
+ function canRewriteDerivedReference(ref) {
1217
+ if (!ref.isIdentifier() || !ref.isReferencedIdentifier() || ref.findParent((p) => p.isTSType())) {
1218
+ return false;
1219
+ }
1220
+ let target = ref;
1221
+ while ((target.parentPath?.isMemberExpression() || target.parentPath?.isOptionalMemberExpression()) && target.key === "object" || (target.parentPath?.isTSNonNullExpression() || target.parentPath?.isTSAsExpression() || target.parentPath?.isTSTypeAssertion()) && target.key === "expression") {
1222
+ target = target.parentPath;
1223
+ }
1224
+ const parent = target.parentPath;
1225
+ return !(parent?.isAssignmentExpression() && target.key === "left" || parent?.isUpdateExpression() || parent?.isUnaryExpression({ operator: "delete" }) || (parent?.isForInStatement() || parent?.isForOfStatement()) && target.key === "left" || target !== ref && (parent?.isCallExpression() || parent?.isOptionalCallExpression()) && target.key === "callee");
1226
+ }
1227
+ function isPureDerivedCall(path, names, pureImports) {
1228
+ if (!path.isCallExpression() && !path.isOptionalCallExpression())
1229
+ return false;
1230
+ const callee = path.node.callee;
1231
+ if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) {
1232
+ const props = [];
1233
+ let node = callee;
1234
+ while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) {
1235
+ const prop = node.property;
1236
+ if (t.isIdentifier(prop)) {
1237
+ props.unshift(prop.name);
1238
+ } else if (t.isStringLiteral(prop)) {
1239
+ props.unshift(prop.value);
1240
+ } else {
1241
+ return false;
1242
+ }
1243
+ node = node.object;
1244
+ }
1245
+ if (!t.isIdentifier(node))
1246
+ return false;
1247
+ const root = node.name;
1248
+ if (pureImports.member.has(root)) {
1249
+ return true;
1250
+ }
1251
+ const nsMembers = pureImports.namespace.get(root);
1252
+ if (nsMembers && props.some((p) => nsMembers.has(p))) {
1253
+ return true;
1254
+ }
1255
+ return false;
1256
+ }
1257
+ if (!t.isIdentifier(callee))
1258
+ return false;
1259
+ const binding = path.scope.getBinding(callee.name);
1260
+ if (!binding) {
1261
+ return ["Boolean", "Number", "String"].includes(callee.name) && path.node.arguments.length === 1;
1262
+ }
1263
+ if ((binding.path.isImportSpecifier() || binding.path.isImportDefaultSpecifier()) && pureImports.direct.has(callee.name)) {
1264
+ return true;
1265
+ }
1266
+ if (path.node.arguments.length !== 0 || !binding.path.isVariableDeclarator())
1267
+ return false;
1268
+ const { id, init } = binding.path.node;
1269
+ return t.isArrayPattern(id) && id.elements[0] === binding.identifier && t.isCallExpression(init) && t.isIdentifier(init.callee) && names.useState.has(init.callee.name) && binding.path.scope.getBinding(init.callee.name)?.path.isImportSpecifier() === true;
1270
+ }
1271
+ function isDerivedInitializer(declaration, scope, names, derived, pureImports) {
1272
+ const init = declaration.get("init");
1273
+ init.assertExpression();
1274
+ let safe = true;
1275
+ let reactive = false;
1276
+ const inspect = (path) => {
1277
+ if (!safe || path.isTSType()) {
1278
+ path.skip();
1279
+ return;
1280
+ }
1281
+ const node = path.node;
1282
+ if (path.isReferencedIdentifier()) {
1283
+ const binding = path.scope.getBinding(path.node.name);
1284
+ if (binding && (!binding.constant || binding.kind === "let" || binding.kind === "var" || binding.path.isVariableDeclarator() && (binding.path.node.start ?? -1) >= (declaration.node.start ?? Infinity))) {
1285
+ safe = false;
1286
+ return;
1287
+ }
1288
+ if (binding && derived.has(binding))
1289
+ reactive = true;
1290
+ if (isReactiveRead(node, scope))
1291
+ reactive = true;
1292
+ } else if (path.isMemberExpression() || path.isOptionalMemberExpression()) {
1293
+ if (isReactiveRead(node, scope))
1294
+ reactive = true;
1295
+ } else if (path.isCallExpression() || path.isOptionalCallExpression()) {
1296
+ if (!isPureDerivedCall(path, names, pureImports))
1297
+ safe = false;
1298
+ if (isGetterCall(path.node, scope))
1299
+ reactive = true;
1300
+ } else if (path.isSpreadElement()) {} else if (!(path.isIdentifier() || path.isLiteral() || path.isTemplateLiteral() || path.isTemplateElement() || path.isBinaryExpression() || path.isLogicalExpression() || path.isConditionalExpression() || path.isObjectExpression() || path.isObjectProperty() || path.isArrayExpression() || path.isUnaryExpression() && path.node.operator !== "delete" || path.isTSAsExpression() || path.isTSSatisfiesExpression() || path.isTSNonNullExpression() || path.isTSTypeAssertion())) {
1301
+ safe = false;
1302
+ }
1303
+ };
1304
+ inspect(init);
1305
+ if (safe)
1306
+ init.traverse({ enter: inspect });
1307
+ return safe && reactive;
1308
+ }
1309
+ function promoteReactiveConstInitializers(ast, options) {
1310
+ if (!options.derivedLocals && !options.dev)
1311
+ return;
1312
+ const names = trackReactiveImports2(ast);
1313
+ const pureImports = collectPureImports(ast, options.filename, options.resolveImport);
1314
+ const { functions } = collectComponentFunctions2(ast, names.cc);
1315
+ traverse(ast, {
1316
+ Program(p) {
1317
+ p.scope.crawl();
1318
+ }
1319
+ });
1320
+ traverse(ast, {
1321
+ Function(fnPath) {
1322
+ if (!functions.has(fnPath.node))
1323
+ return;
1324
+ const factory = fnPath.parentPath;
1325
+ if (!factory.isCallExpression() || !t.isIdentifier(factory.node.callee) || factory.scope.getBinding(factory.node.callee.name)?.kind !== "module")
1326
+ return;
1327
+ const body = fnPath.get("body");
1328
+ if (!body.isBlockStatement())
1329
+ return;
1330
+ let hasDirectEval = false;
1331
+ fnPath.traverse({
1332
+ CallExpression(p) {
1333
+ if (t.isIdentifier(p.node.callee, { name: "eval" }) && !p.scope.getBinding("eval"))
1334
+ hasDirectEval = true;
1335
+ }
1336
+ });
1337
+ if (hasDirectEval)
1338
+ return;
1339
+ const scope = { bindings: new Map };
1340
+ trackPropBindings(scope, fnPath.node.params[0], getAllPropNames2(fnPath.node.params[0]));
1341
+ trackLocalScopeBindings(fnPath, names, scope, true);
1342
+ const derived = new Map;
1343
+ for (const statement of body.get("body")) {
1344
+ if (!statement.isVariableDeclaration({ kind: "const" }) || hasSnapshotPragma(statement.node))
1345
+ continue;
1346
+ for (const declaration of statement.get("declarations")) {
1347
+ const { id, init } = declaration.node;
1348
+ if (!t.isIdentifier(id) || !init || hasSnapshotPragma(declaration.node) || hasSnapshotPragma(init))
1349
+ continue;
1350
+ const binding = declaration.scope.getBinding(id.name);
1351
+ if (!binding.constant || !binding.referenced || !binding.referencePaths.every(canRewriteDerivedReference))
1352
+ continue;
1353
+ if (isDerivedInitializer(declaration, scope, names, derived, pureImports)) {
1354
+ derived.set(binding, { declaration, binding });
1355
+ }
1356
+ }
1357
+ }
1358
+ for (const { declaration, binding } of derived.values()) {
1359
+ const { id, init } = declaration.node;
1360
+ if (!t.isIdentifier(id) || !t.isExpression(init))
1361
+ continue;
1362
+ const location = `${options.filename ?? "<module>"}:${id.loc?.start.line ?? 0}`;
1363
+ if (!options.derivedLocals) {
1364
+ if (binding.referencePaths.some((ref) => ref.findParent((p) => p.isJSXExpressionContainer()))) {
1365
+ console.warn(`[Sinwan] ${location}: derived local "${id.name}" snapshots a reactive read used in JSX. Enable derivedLocals or use a getter/computed; use /* sinwan-snapshot */ for an intentional snapshot.`);
1366
+ }
1367
+ continue;
1368
+ }
1369
+ const getter = t.arrowFunctionExpression([], init);
1370
+ if (t.isTSTypeAnnotation(id.typeAnnotation)) {
1371
+ getter.returnType = id.typeAnnotation;
1372
+ id.typeAnnotation = null;
1373
+ }
1374
+ declaration.node.init = getter;
1375
+ for (const ref of binding.referencePaths) {
1376
+ if (options.dev && ref.parentPath?.isIfStatement() && ref.key === "test" && ref.getFunctionParent()?.node === fnPath.node) {
1377
+ console.warn(`[Sinwan] ${location}: derived local "${id.name}" in a setup if is read once, not a reactive branch. The compiler calls its getter to preserve boolean semantics; use Show for reactive control flow.`);
1378
+ }
1379
+ if (ref.parentPath?.isObjectProperty({ shorthand: true })) {
1380
+ ref.parentPath.node.shorthand = false;
1381
+ }
1382
+ ref.replaceWith(t.callExpression(t.identifier(id.name), []));
1383
+ }
1384
+ }
1385
+ fnPath.scope.crawl();
1386
+ }
1387
+ });
1388
+ }
927
1389
  function wrapReactiveExpressions2(ast, options = {}) {
1390
+ promoteReactiveConstInitializers(ast, options);
928
1391
  let metadata = null;
929
1392
  if (options.analyzeMetadata) {
930
1393
  metadata = options.analyzeMetadata;
@@ -974,7 +1437,7 @@ function wrapReactiveExpressions2(ast, options = {}) {
974
1437
  }
975
1438
  if (source === "sinwan/reactivity") {
976
1439
  for (const spec of p.node.specifiers) {
977
- if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === "unwrap") {
1440
+ if (t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) && spec.imported.name === "unwrap" && spec.local.name === RESOLVE_HELPER) {
978
1441
  hasUnwrapImport = true;
979
1442
  }
980
1443
  }
@@ -1054,7 +1517,10 @@ function wrapReactiveExpressions2(ast, options = {}) {
1054
1517
  },
1055
1518
  Function(p) {
1056
1519
  const scope = { bindings: new Map };
1057
- trackLocalScopeBindings(p, names, scope);
1520
+ if (options.derivedLocals && componentFunctions.has(p.node)) {
1521
+ trackPropBindings(scope, p.node.params[0], getAllPropNames2(p.node.params[0]));
1522
+ }
1523
+ trackLocalScopeBindings(p, names, scope, options.derivedLocals === true && componentFunctions.has(p.node));
1058
1524
  localScopes.set(p.node, scope);
1059
1525
  },
1060
1526
  JSXElement(p) {
@@ -1441,11 +1907,11 @@ function serializeModule(mod) {
1441
1907
  function restoreModule(filePath, serialized) {
1442
1908
  let code;
1443
1909
  try {
1444
- code = fs.readFileSync(filePath, "utf-8");
1910
+ code = fs2.readFileSync(filePath, "utf-8");
1445
1911
  } catch {
1446
1912
  return null;
1447
1913
  }
1448
- const ast = parse(code, {
1914
+ const ast = parse2(code, {
1449
1915
  sourceType: "module",
1450
1916
  plugins: ["jsx", "typescript"]
1451
1917
  });
@@ -1622,7 +2088,7 @@ function collectImportedCallSites(ast, componentNames, imports) {
1622
2088
  return sites;
1623
2089
  }
1624
2090
  function analyzeModule2(code, filePath) {
1625
- const ast = parse(code, {
2091
+ const ast = parse2(code, {
1626
2092
  sourceType: "module",
1627
2093
  plugins: ["jsx", "typescript"]
1628
2094
  });
@@ -1675,7 +2141,7 @@ function analyzeModule2(code, filePath) {
1675
2141
  }
1676
2142
  function loadTsConfigPaths(tsConfigPath) {
1677
2143
  try {
1678
- const raw = JSON.parse(fs.readFileSync(tsConfigPath, "utf-8"));
2144
+ const raw = JSON.parse(fs2.readFileSync(tsConfigPath, "utf-8"));
1679
2145
  const paths = raw.compilerOptions?.paths;
1680
2146
  if (!paths)
1681
2147
  return null;
@@ -1718,7 +2184,7 @@ function resolveRelativeImport(source, fromFile, extensions) {
1718
2184
  ...extensions.map((ext) => path2.join(base, "index" + ext))
1719
2185
  ];
1720
2186
  for (const candidate of candidates) {
1721
- if (fs.existsSync(candidate))
2187
+ if (fs2.existsSync(candidate))
1722
2188
  return candidate;
1723
2189
  }
1724
2190
  return null;
@@ -1763,7 +2229,7 @@ function parseAliasObject(content, aliases) {
1763
2229
  }
1764
2230
  function loadBunfigAliases(bunfigPath) {
1765
2231
  try {
1766
- const content = fs.readFileSync(bunfigPath, "utf-8");
2232
+ const content = fs2.readFileSync(bunfigPath, "utf-8");
1767
2233
  const aliases = new Map;
1768
2234
  const lines = content.split(`
1769
2235
  `);
@@ -1816,14 +2282,14 @@ function resolveWithExtensions(mapped, extensions) {
1816
2282
  ...extensions.map((ext) => path2.join(mapped, "index" + ext))
1817
2283
  ];
1818
2284
  for (const candidate of candidates) {
1819
- if (fs.existsSync(candidate))
2285
+ if (fs2.existsSync(candidate))
1820
2286
  return candidate;
1821
2287
  }
1822
2288
  return null;
1823
2289
  }
1824
2290
  function readPackageName(pkgDir) {
1825
2291
  try {
1826
- const pkgJson = JSON.parse(fs.readFileSync(path2.join(pkgDir, "package.json"), "utf-8"));
2292
+ const pkgJson = JSON.parse(fs2.readFileSync(path2.join(pkgDir, "package.json"), "utf-8"));
1827
2293
  return pkgJson.name ?? null;
1828
2294
  } catch {
1829
2295
  return null;
@@ -1852,7 +2318,7 @@ function resolveWorkspacePackage(source, packages, extensions) {
1852
2318
  const subpath = source.slice(matchedName.length + 1);
1853
2319
  let sourceDir;
1854
2320
  try {
1855
- const pkgJson = JSON.parse(fs.readFileSync(path2.join(matchedPkg, "package.json"), "utf-8"));
2321
+ const pkgJson = JSON.parse(fs2.readFileSync(path2.join(matchedPkg, "package.json"), "utf-8"));
1856
2322
  if (pkgJson.source) {
1857
2323
  sourceDir = path2.dirname(path2.resolve(matchedPkg, pkgJson.source));
1858
2324
  } else {
@@ -1866,7 +2332,7 @@ function resolveWorkspacePackage(source, packages, extensions) {
1866
2332
  }
1867
2333
  function loadPackageJsonWorkspaces(filePath) {
1868
2334
  try {
1869
- const pkg = JSON.parse(fs.readFileSync(filePath, "utf-8"));
2335
+ const pkg = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
1870
2336
  return pkg.workspaces ?? [];
1871
2337
  } catch {
1872
2338
  return [];
@@ -1874,7 +2340,7 @@ function loadPackageJsonWorkspaces(filePath) {
1874
2340
  }
1875
2341
  function loadPnpmWorkspaces(filePath) {
1876
2342
  try {
1877
- const content = fs.readFileSync(filePath, "utf-8");
2343
+ const content = fs2.readFileSync(filePath, "utf-8");
1878
2344
  const lines = content.split(`
1879
2345
  `);
1880
2346
  const patterns = [];
@@ -1907,14 +2373,14 @@ function expandGlob(pattern, cwd) {
1907
2373
  const parts = fullPattern.split(path2.sep);
1908
2374
  const wildcardIndex = parts.findIndex((p) => p.includes("*"));
1909
2375
  if (wildcardIndex === -1) {
1910
- return fs.existsSync(fullPattern) ? [fullPattern] : [];
2376
+ return fs2.existsSync(fullPattern) ? [fullPattern] : [];
1911
2377
  }
1912
2378
  const prefix = parts.slice(0, wildcardIndex).join(path2.sep);
1913
2379
  const suffixParts = parts.slice(wildcardIndex + 1);
1914
2380
  const suffix = suffixParts.join(path2.sep);
1915
2381
  let entries;
1916
2382
  try {
1917
- entries = fs.readdirSync(prefix, { withFileTypes: true });
2383
+ entries = fs2.readdirSync(prefix, { withFileTypes: true });
1918
2384
  } catch {
1919
2385
  return [];
1920
2386
  }
@@ -1931,8 +2397,8 @@ function loadWorkspacePackages(workspaces, root) {
1931
2397
  const patternEntries = [];
1932
2398
  if (typeof workspaces === "string") {
1933
2399
  const resolved = path2.resolve(root, workspaces);
1934
- if (fs.existsSync(resolved)) {
1935
- const stat = fs.statSync(resolved);
2400
+ if (fs2.existsSync(resolved)) {
2401
+ const stat = fs2.statSync(resolved);
1936
2402
  if (stat.isDirectory()) {
1937
2403
  patternEntries.push({ pattern: workspaces, base: root });
1938
2404
  } else if (resolved.endsWith("package.json")) {
@@ -1980,7 +2446,7 @@ function loadWorkspacePackages(workspaces, root) {
1980
2446
  for (const { pattern, base } of patternEntries) {
1981
2447
  for (const dir of expandGlob(pattern, base)) {
1982
2448
  const pkgJson = path2.join(dir, "package.json");
1983
- if (fs.existsSync(pkgJson)) {
2449
+ if (fs2.existsSync(pkgJson)) {
1984
2450
  packages.add(dir);
1985
2451
  }
1986
2452
  }
@@ -2019,7 +2485,7 @@ function findFiles(root, extensions) {
2019
2485
  function walk(dir) {
2020
2486
  let entries;
2021
2487
  try {
2022
- entries = fs.readdirSync(dir, { withFileTypes: true });
2488
+ entries = fs2.readdirSync(dir, { withFileTypes: true });
2023
2489
  } catch {
2024
2490
  return;
2025
2491
  }
@@ -2049,7 +2515,7 @@ function analyzeProject2(options) {
2049
2515
  } else {
2050
2516
  const files = findFiles(options.root, extensions);
2051
2517
  for (const file of files) {
2052
- const code = fs.readFileSync(file, "utf-8");
2518
+ const code = fs2.readFileSync(file, "utf-8");
2053
2519
  modules.set(file, analyzeModule2(code, file));
2054
2520
  }
2055
2521
  }
@@ -2057,7 +2523,7 @@ function analyzeProject2(options) {
2057
2523
  if (modules.has(filePath))
2058
2524
  return modules.get(filePath);
2059
2525
  try {
2060
- const code = fs.readFileSync(filePath, "utf-8");
2526
+ const code = fs2.readFileSync(filePath, "utf-8");
2061
2527
  const mod = analyzeModule2(code, filePath);
2062
2528
  modules.set(filePath, mod);
2063
2529
  return mod;
@@ -2214,17 +2680,17 @@ class AnalyzerCache2 {
2214
2680
  reactiveProps: serializeReactiveProps(this.reactiveProps)
2215
2681
  };
2216
2682
  const dir = path2.dirname(this.cachePath);
2217
- if (!fs.existsSync(dir)) {
2218
- fs.mkdirSync(dir, { recursive: true });
2683
+ if (!fs2.existsSync(dir)) {
2684
+ fs2.mkdirSync(dir, { recursive: true });
2219
2685
  }
2220
- fs.writeFileSync(this.cachePath, JSON.stringify(data, null, 2));
2686
+ fs2.writeFileSync(this.cachePath, JSON.stringify(data, null, 2));
2221
2687
  }
2222
2688
  restore() {
2223
- if (!this.cachePath || !fs.existsSync(this.cachePath))
2689
+ if (!this.cachePath || !fs2.existsSync(this.cachePath))
2224
2690
  return;
2225
2691
  let data;
2226
2692
  try {
2227
- data = JSON.parse(fs.readFileSync(this.cachePath, "utf-8"));
2693
+ data = JSON.parse(fs2.readFileSync(this.cachePath, "utf-8"));
2228
2694
  } catch {
2229
2695
  return;
2230
2696
  }
@@ -2274,7 +2740,7 @@ class AnalyzerCache2 {
2274
2740
  if (this.modules.has(filePath))
2275
2741
  return this.modules.get(filePath);
2276
2742
  try {
2277
- const code = fs.readFileSync(filePath, "utf-8");
2743
+ const code = fs2.readFileSync(filePath, "utf-8");
2278
2744
  const mod = analyzeModule2(code, filePath);
2279
2745
  this.modules.set(filePath, mod);
2280
2746
  return mod;
@@ -2351,8 +2817,8 @@ function analyze2(options) {
2351
2817
  root: options.root,
2352
2818
  extensions: options.extensions,
2353
2819
  resolve: options.resolve,
2354
- tsConfigPath: fs.existsSync(tsConfigPath) ? tsConfigPath : undefined,
2355
- bunfigPath: fs.existsSync(bunfigPath) ? bunfigPath : undefined,
2820
+ tsConfigPath: fs2.existsSync(tsConfigPath) ? tsConfigPath : undefined,
2821
+ bunfigPath: fs2.existsSync(bunfigPath) ? bunfigPath : undefined,
2356
2822
  workspaces: options.workspaces
2357
2823
  });
2358
2824
  const serialized = {};
@@ -2364,16 +2830,16 @@ function analyze2(options) {
2364
2830
  serialized[filePath] = entry;
2365
2831
  }
2366
2832
  const dir = path2.dirname(options.outFile);
2367
- if (!fs.existsSync(dir)) {
2368
- fs.mkdirSync(dir, { recursive: true });
2833
+ if (!fs2.existsSync(dir)) {
2834
+ fs2.mkdirSync(dir, { recursive: true });
2369
2835
  }
2370
- fs.writeFileSync(options.outFile, JSON.stringify(serialized, null, 2));
2836
+ fs2.writeFileSync(options.outFile, JSON.stringify(serialized, null, 2));
2371
2837
  }
2372
2838
  function loadMetadata2(filePath) {
2373
- if (!fs.existsSync(filePath)) {
2839
+ if (!fs2.existsSync(filePath)) {
2374
2840
  return new Map;
2375
2841
  }
2376
- const raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
2842
+ const raw = JSON.parse(fs2.readFileSync(filePath, "utf-8"));
2377
2843
  const result = new Map;
2378
2844
  for (const [filePath, fileMap] of Object.entries(raw)) {
2379
2845
  const map = new Map;
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  runAnalyzeCli2,
5
5
  runCliIfMain2
6
- } from "./chunk-3e981ff7.js";
6
+ } from "./chunk-sy6qf73w.js";
7
7
  export {
8
8
  runAnalyzeCli2 as runAnalyzeCli,
9
9
  runCliIfMain2 as runCliIfMain
package/dist/exports.d.ts CHANGED
@@ -4,10 +4,11 @@
4
4
  * Shared utility for detecting top-level exported component bindings in a
5
5
  * module. Used by the Vite and Bun plugins for HMR boundary injection.
6
6
  *
7
- * Detection is intentionally conservative: only top-level exports whose name
8
- * starts with an uppercase letter (the component naming convention) are
9
- * returned. The runtime safely ignores anything that is not used as a
10
- * component, so over-matching is harmless.
7
+ * Detection is intentionally conservative: only top-level *value* exports
8
+ * whose name starts with an uppercase letter (the component naming
9
+ * convention) are returned. Type-only exports (`export type { Foo }`,
10
+ * `export { type Foo }`) must be skipped: HMR footers reference the local
11
+ * binding, and a type has no runtime binding (`ReferenceError`).
11
12
  */
12
13
  export interface ExportedComponent {
13
14
  /** Local binding name in the module (referenced in the accept callback). */
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  propagateReactiveProps2,
18
18
  wrapReactiveExpressions2,
19
19
  runAnalyzeCli2
20
- } from "./chunk-3e981ff7.js";
20
+ } from "./chunk-sy6qf73w.js";
21
21
 
22
22
  // src/transform.ts
23
23
  import { parse } from "@babel/parser";
@@ -619,6 +619,8 @@ function transformJSX(code, filename, options = {}) {
619
619
  autoWrapComponents2(ast, filename);
620
620
  rewriteLiveCcDestructure(ast);
621
621
  wrapReactiveExpressions2(ast, {
622
+ derivedLocals: options.derivedLocals,
623
+ dev: options.dev,
622
624
  explicitBindings: options.explicitBindings,
623
625
  analyze: options.analyze,
624
626
  analyzeMetadata: options.analyzeMetadata,
@@ -666,7 +668,9 @@ function transformJSX(code, filename, options = {}) {
666
668
  t2.importSpecifier(t2.identifier("_$bindStyle"), t2.identifier("_$bindStyle")),
667
669
  t2.importSpecifier(t2.identifier("_$bindClass"), t2.identifier("_$bindClass"))
668
670
  ], t2.stringLiteral("sinwan/renderer"));
669
- ast.program.body.unshift(bindingImport);
671
+ bindingImport.specifiers = bindingImport.specifiers.filter((candidate) => !ast.program.body.some((statement) => t2.isImportDeclaration(statement) && statement.source.value === "sinwan/renderer" && statement.specifiers.some((specifier) => t2.isImportSpecifier(specifier) && t2.isIdentifier(specifier.imported) && specifier.local.name === candidate.local.name && specifier.imported.name === candidate.local.name)));
672
+ if (bindingImport.specifiers.length > 0)
673
+ ast.program.body.unshift(bindingImport);
670
674
  }
671
675
  const result = generate(ast, {
672
676
  sourceMaps: true,
@@ -702,6 +706,9 @@ import { parse as parse2 } from "@babel/parser";
702
706
  function isComponentName(name) {
703
707
  return !!name && name[0] === name[0].toUpperCase() && /^[A-Z]/.test(name);
704
708
  }
709
+ function isTypeOnlyExport(node) {
710
+ return node.exportKind === "type";
711
+ }
705
712
  function collectExportedComponents(code, filename) {
706
713
  let ast;
707
714
  try {
@@ -724,6 +731,8 @@ function collectExportedComponents(code, filename) {
724
731
  };
725
732
  for (const node of ast.program.body) {
726
733
  if (node.type === "ExportNamedDeclaration") {
734
+ if (isTypeOnlyExport(node))
735
+ continue;
727
736
  if (node.declaration) {
728
737
  const decl = node.declaration;
729
738
  if (decl.type === "FunctionDeclaration" && decl.id && isComponentName(decl.id.name)) {
@@ -737,6 +746,8 @@ function collectExportedComponents(code, filename) {
737
746
  }
738
747
  } else if (node.specifiers && !node.source) {
739
748
  for (const spec of node.specifiers) {
749
+ if (isTypeOnlyExport(spec))
750
+ continue;
740
751
  if (spec.type === "ExportSpecifier" && spec.local?.type === "Identifier" && spec.exported?.type === "Identifier" && isComponentName(spec.exported.name)) {
741
752
  add(spec.local.name, spec.exported.name);
742
753
  }
@@ -98,6 +98,8 @@ export declare function collectComponentCallGraph(ast: t.Node, componentNames: M
98
98
  export declare function buildFullScope(localScopes: Map<t.Function, ReactiveScope>, fn: t.Function, reactiveProps: Set<string>): ReactiveScope | null;
99
99
  export declare function propagateReactiveProps(localScopes: Map<t.Function, ReactiveScope>, callGraph: Map<t.Function, CallSite[]>, componentFunctions: Set<t.Function>): Map<t.Function, Set<string>>;
100
100
  export declare function wrapReactiveExpressions(ast: t.Node, options?: {
101
+ derivedLocals?: boolean;
102
+ dev?: boolean;
101
103
  explicitBindings?: boolean;
102
104
  analyze?: string;
103
105
  analyzeMetadata?: Map<string, Map<string, Set<string>>>;
@@ -6,6 +6,7 @@
6
6
  * and replaced with optimized template creation calls.
7
7
  */
8
8
  export interface TransformOptions {
9
+ derivedLocals?: boolean;
9
10
  /** Enable template hoisting (default: true) */
10
11
  hoist?: boolean;
11
12
  /** Enable dev mode (source maps, etc.) */
package/docs/api.md CHANGED
@@ -122,7 +122,7 @@ Parse `analyze [root] [outFile] [--tsconfig] [--bunfig] [--workspaces]` and call
122
122
 
123
123
  | Name | Role |
124
124
  | ---- | ---- |
125
- | `collectExportedComponents(code)` | File-local exported component names (no `cc` required) |
125
+ | `collectExportedComponents(code)` | File-local exported component names (no `cc` required). Skips type-only exports. |
126
126
  | `wrapReactiveExpressions(ast, options?)` | Reactive wrap without hoisting |
127
127
  | `trackReactiveImports(ast)` | Import-name sets for signals, `cc`, `useFetch`, … |
128
128
  | `collectComponentFunctions(ast, ccNames)` | `cc(...)` functions, names, and exports |
@@ -23,6 +23,8 @@ transformJSX
23
23
  autoWrapComponents
24
24
  rewriteLiveCcDestructure ← flat destructure stays live; raw JSX spreads
25
25
  wrapReactiveExpressions ← analyze / analyzeMetadata / resolveImport
26
+ promoteReactiveConstInitializers ← opt-in derivedLocals, snapshot diagnostics
27
+ rebuild scopes / call graph / JSX wrapping
26
28
  hoist JSXElement roots ← _$createTemplate + slots
27
29
 
28
30
  analyze / AnalyzerCache.update
package/docs/plugins.md CHANGED
@@ -48,14 +48,15 @@ The Vite plugin sets `resolve.dedupe: ["sinwan"]` for the same duplicate-runtime
48
48
 
49
49
  ## Shared plugin options
50
50
 
51
- | Option | Default | Role |
52
- | ------ | ------- | ---- |
53
- | `hoist` | `true` | Template hoisting |
54
- | `dev` | Bun: `NODE_ENV !== "production"` at `sinwan()`; Vite: `config.mode !== "production"` | Warn when hoisting is skipped. Pass `false` for library/`Bun.build` production bundles. |
55
- | `explicitBindings` | `false` | `_$bind*` helpers instead of bare `() =>` |
56
- | `analyze` | unset | Path to CLI JSON (production) |
57
- | `cache` | `true` | Incremental analyzer in dev. `false` turns it off. An object sets `root`, `tsConfigPath`, `bunfigPath`, `workspaces`, `cachePath` |
58
- | `fastRefresh` | Bun: same as `dev`; Vite: `true` in `serve` | Per-component `import.meta.hot.accept` so routed/looked-up pages hot-update |
51
+ | Option | Default | Role |
52
+ | ------------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
53
+ | `hoist` | `true` | Template hoisting |
54
+ | `derivedLocals` | `false` | Promote eligible component-local reactive constants to live getters; see [Transform](transform.md#derived-locals-opt-in). Supports `/** @sinwan-pure */` JSDoc-annotated imports; see [`@sinwan-pure` JSDoc contract](transform.md#sinwan-pure-jsdoc-contract). Requires compiler, plugin and runtime builds with this feature. |
55
+ | `dev` | Bun: `NODE_ENV !== "production"` at `sinwan()`; Vite: `config.mode !== "production"` | Warn when hoisting is skipped. Pass `false` for library/`Bun.build` production bundles. |
56
+ | `explicitBindings` | `false` | `_$bind*` helpers instead of bare `() =>` |
57
+ | `analyze` | unset | Path to CLI JSON (production) |
58
+ | `cache` | `true` | Incremental analyzer in dev. `false` turns it off. An object sets `root`, `tsConfigPath`, `bunfigPath`, `workspaces`, `cachePath` |
59
+ | `fastRefresh` | Bun: same as `dev`; Vite: `true` in `serve` | Per-component `import.meta.hot.accept` so routed/looked-up pages hot-update |
59
60
 
60
61
  ## Dev vs production
61
62
 
package/docs/transform.md CHANGED
@@ -76,12 +76,12 @@ export const Child = cc((props) => <span>{() => props.value}</span>);
76
76
 
77
77
  Tracked imports:
78
78
 
79
- | Module | Names |
80
- | ------ | ----- |
81
- | `sinwan/store` | `createMutable`, `createStore` |
82
- | `sinwan/reactivity` | `signal`, `computed` |
83
- | `sinwan/react` | `useState` |
84
- | `sinwan/hook` | `useFetch` |
79
+ | Module | Names |
80
+ | ------------------- | ------------------------------ |
81
+ | `sinwan/store` | `createMutable`, `createStore` |
82
+ | `sinwan/reactivity` | `signal`, `computed` |
83
+ | `sinwan/react` | `useState` |
84
+ | `sinwan/hook` | `useFetch` |
85
85
 
86
86
  Reads the compiler wraps:
87
87
 
@@ -98,11 +98,311 @@ Not wrapped:
98
98
 
99
99
  - Literals and plain identifiers
100
100
  - Event-handler functions (`onClick={fn}` or `onClick={() => …}`)
101
- - Nested function *values* passed as props (`title={() => s.value}` stays a render prop)
101
+ - Nested function _values_ passed as props (`title={() => s.value}` stays a render prop)
102
102
  - Functions stored as object/array values (`{ onClick: () => s.value }`) or returned from a factory (`const make = () => () => s.value`)
103
103
  - `useFetch` methods such as `abort` / `execute`
104
104
  - Static values the [analyzer](analyzer.md) proved never change
105
105
 
106
+ ## Derived locals (opt-in)
107
+
108
+ JSX expressions containing reactive reads already stay live without an author-written arrow. By default, moving that read into `const open = count.value > 0` still takes a **setup snapshot**. Inline one-off expressions in JSX; use a getter or `computed()` for shared derivations when promotion is disabled.
109
+
110
+ Enable `derivedLocals: true` in `transformJSX` or either plugin to promote eligible component-local constants:
111
+
112
+ ```tsx
113
+ export function App() {
114
+ const count = signal(0);
115
+ const positive = count.value > 0;
116
+ const label = positive ? "positive" : "zero";
117
+ return (
118
+ <Show when={positive}>
119
+ <span title={label}>{label}</span>
120
+ </Show>
121
+ );
122
+ }
123
+ ```
124
+
125
+ The derived declarations become zero-argument getters, and references become value reads:
126
+
127
+ ```tsx
128
+ const positive = () => count.value > 0;
129
+ const label = () => (positive() ? "positive" : "zero");
130
+ return (
131
+ <Show when={() => positive()}>
132
+ <span title={() => label()}>{() => label()}</span>
133
+ </Show>
134
+ );
135
+ ```
136
+
137
+ ### Eligibility and semantics
138
+
139
+ - Only referenced, identifier-named `const` declarations directly in a `cc` setup body (including auto-`cc` components) are candidates. Dependencies are processed in declaration order, including multiple declarators in one statement.
140
+ - Pure read expressions are supported: member/index/optional access, arithmetic, comparisons, boolean/conditional expressions, template strings, object/array literals, and TypeScript expression annotations. Direct imported `useState` getter calls and unshadowed single-argument `Boolean`, `Number`, and `String` coercions are also supported. Property reads and coercions must be free of user-defined side effects.
141
+ - Calls to arbitrary helpers, hooks, factories or methods, assignments, updates, allocations, destructuring, `let`/`var`, mutable captures, and forward/TDZ references remain eager — **unless** the callee is an imported function annotated with `/** @sinwan-pure */` (see [`@sinwan-pure` JSDoc contract](#sinwan-pure-jsdoc-contract) below). Explicit getter and `computed` initializers are unchanged. Module, event-handler, render-callback, loop and nested-block declarations are not promoted.
142
+ - Bare signal/store containers still pass through unchanged. Bare component prop-bag aliases retain their ordinary JavaScript value semantics. References are resolved by lexical binding, so shadowed locals and object keys are not rewritten.
143
+ - Every ordinary value use is called, including chained initializers, closures, event bodies, shorthand object values and setup `if` tests. A boolean does **not** become an accidentally truthy function. Setup control flow still runs once; use `Show`/`Switch` for reactive branches.
144
+ - Variables used as JSX tag names, in type queries, or as mutation/method-call receivers are left eager. Components containing direct `eval` are excluded.
145
+ - Getters are lazy, not cached. Use explicit `computed()` when caching matters. This is not whole-program dataflow, and does not turn arbitrary setup side effects into effects.
146
+
147
+ ### Intentional snapshots and diagnostics
148
+
149
+ Put `/* sinwan-snapshot */` immediately before a declaration or its initializer to retain a one-shot read:
150
+
151
+ ```tsx
152
+ /* sinwan-snapshot */ const initial = count.value;
153
+ const alsoInitial = /* sinwan-snapshot */ count.value;
154
+ ```
155
+
156
+ A declaration-level pragma applies to all its declarators. A snapshot can read another promoted local: the compiler calls that dependency once at the snapshot declaration.
157
+
158
+ With `dev: true`, the compiler warns when an eligible unpromoted derivation is used in JSX while promotion is disabled. It also warns about a promoted local used directly as a setup `if` test, explaining that the value is read once even though the compiler preserves boolean semantics. Intentional snapshot pragmas suppress the snapshot diagnostic; production (`dev: false`) is quiet.
159
+
160
+ ### `@sinwan-pure` JSDoc contract
161
+
162
+ The compiler can promote derived `const` initializers that call imported utility functions when those functions are annotated with `/** @sinwan-pure */` in their source file. This eliminates the need for manual `() =>` wrappers around common utilities like className mergers.
163
+
164
+ **Annotation:**
165
+
166
+ ```ts
167
+ /** @sinwan-pure */
168
+ export function cn(...inputs: ClassValue[]): string {
169
+ return twMerge(clsx(inputs));
170
+ }
171
+ ```
172
+
173
+ Both `export function` and `export const` declarations are recognized. The annotation must be a block comment (`/** */`) immediately before the `export` keyword.
174
+
175
+ **What the compiler guarantees:**
176
+
177
+ - When `derivedLocals: true` is enabled, calls to `@sinwan-pure`-annotated imported functions are treated as safe expressions. The containing `const` initializer is promoted to a zero-argument getter, and references inside the function arguments are rewritten (e.g. `seamInactive` → `seamInactive()`).
178
+ - The compiler resolves the import path, reads the source file, parses it, and checks for `/** @sinwan-pure */` on exported declarations. Results are cached per resolved file path, so each file is read at most once per compilation process.
179
+ - All import types are scanned: relative imports (`./`, `../`), bare specifiers (`clsx`, `sinwan/reactivity`), scoped packages (`@scope/utils`), and subpath imports (`@scope/utils/merge`). The compiler walks up the directory tree to find `node_modules/<pkg>`, reads `package.json`, and resolves the entry point via `exports`, `module`, `main`, `source`, or `index` files.
180
+ - If the `resolveImport` callback is provided by the plugin, it is used for path resolution (handles tsconfig paths, bunfig aliases, workspace packages, and Bun/Vite module resolution). Otherwise, the compiler falls back to direct filesystem resolution with common extensions (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, `.mts`, `.cts`) and `index` files.
181
+
182
+ **Supported export forms (annotate with `/** @sinwan-pure \*/`):\*\*
183
+
184
+ | Export form | Example | Call shape |
185
+ | ---------------------- | ----------------------------------------------------------- | -------------------- |
186
+ | Named function | `/** @sinwan-pure */ export function cn()` | `cn(...)` |
187
+ | Named const (function) | `/** @sinwan-pure */ export const merge = () =>` | `merge(...)` |
188
+ | Named const (object) | `/** @sinwan-pure */ export const helpers = { ... }` | `helpers.merge(...)` |
189
+ | Named class | `/** @sinwan-pure */ export class Utils { static merge() }` | `Utils.merge(...)` |
190
+ | Default function | `/** @sinwan-pure */ export default function helper()` | `helper(...)` |
191
+ | Default arrow | `/** @sinwan-pure */ export default () =>` | `helper(...)` |
192
+ | Default class | `/** @sinwan-pure */ export default class {}` | `helper.merge(...)` |
193
+
194
+ **Supported import forms:**
195
+
196
+ | Import form | Example | Call shape |
197
+ | ---------------------- | --------------------------------------- | ------------- |
198
+ | Named import | `import { cn } from "./utils"` | `cn(...)` |
199
+ | Named import (aliased) | `import { cn as merge } from "./utils"` | `merge(...)` |
200
+ | Default import | `import helper from "./utils"` | `helper(...)` |
201
+ | Namespace import | `import * as ns from "./utils"` | `ns.cn(...)` |
202
+
203
+ **Supported re-export forms:**
204
+
205
+ | Re-export form | Example |
206
+ | ------------------------- | -------------------------------------------- |
207
+ | Named re-export | `export { cn } from "./utils"` |
208
+ | Aliased re-export | `export { cn as merge } from "./utils"` |
209
+ | Default re-export | `export { default } from "./utils"` |
210
+ | Aliased default re-export | `export { default as merge } from "./utils"` |
211
+ | Namespace re-export | `export * from "./utils"` |
212
+
213
+ For class and object exports, any static method or property call on the imported binding is treated as pure (e.g. `Utils.merge(...)`, `Utils.format(...)`, `helpers.merge(...)`, `helpers.format(...)`).
214
+
215
+ **What the annotation author is responsible for:**
216
+
217
+ - **Purity:** The function must not produce side effects — no signal writes, no DOM mutations, no I/O, no global state changes. Calling the function with the same arguments must always return the same value. The compiler does not verify this; it trusts the annotation.
218
+ - **No hidden reactivity:** The function must not read signals, stores, or other reactive sources internally. If it does, those reads will not be tracked because the function is called inside a getter that the runtime evaluates in an effect — but only the arguments passed to the function are tracked, not reads inside the function body.
219
+ - **Export visibility:** The annotation must be on an exported declaration (`export function`, `export const`, `export class`, `export default`). Re-exports are followed recursively — the compiler resolves the re-export source and scans it for the annotation. Named, aliased, default, and `export *` re-exports are all supported. Circular re-exports are handled with a depth limit to prevent infinite loops.
220
+ - **Import resolvability:** The annotated file must be reachable via any import (relative, bare specifier, scoped package, or subpath). The compiler resolves `node_modules` packages via `package.json` `exports`/`module`/`main`/`source` fields and walks up the directory tree.
221
+
222
+ **What happens when the annotation is wrong:**
223
+
224
+ - **Annotated a non-pure function:** The compiler will promote the containing `const` to a getter. If the function has side effects, those side effects will run on every re-evaluation of the getter (whenever its tracked dependencies change), not just once at setup. This will not crash, but may cause unexpected repeated side effects.
225
+ - **Annotated a function that reads signals internally:** Those internal signal reads will not be tracked as dependencies of the promoted getter. The getter will not re-evaluate when those internal signals change, producing stale values. No crash, but incorrect reactivity.
226
+ - **Forgot to annotate a pure function:** The compiler will reject the containing `const` initializer as unsafe. The value will be a one-shot setup snapshot. No crash, but the value will not update when its dependencies change. The `dev: true` diagnostic will warn about eligible unpromoted derivations used in JSX.
227
+ - **Annotation on a non-exported function:** The annotation is ignored. Only exported declarations are scanned.
228
+ - **Import path unresolvable:** The annotation is not found. The function is treated as a non-pure call. No crash, but the containing `const` will not be promoted. This can happen if the package's `package.json` entry points don't lead to a source file, or if the `node_modules` directory is not in the expected location.
229
+
230
+ **Plugin forwarding:**
231
+
232
+ Both `bun-plugin-sinwan` and `vite-plugin-sinwan` forward the `resolveImport` callback to the compiler when the analyzer cache is enabled (`cache: true`, the default). This uses the plugin's module resolver, which handles workspace packages, tsconfig paths, and Bun/Vite resolution. When the cache is disabled or the minimal preload is used, the compiler falls back to direct filesystem resolution, which works for relative imports in a standard project layout.
233
+
234
+ **What is not scanned (by design):**
235
+
236
+ - **Dynamic imports** (`const { cn } = await import("./utils")`) — the binding is established at runtime, not compile time. The compiler cannot statically resolve which module a dynamic import loads, so it cannot scan the source for `@sinwan-pure` annotations. Use a static import instead.
237
+ - **`require()`** (`const { cn } = require("./utils")`) — CommonJS, not used in Sinwan's ESM target. Bun and Vite both use ESM for application code. Annotate the function and import it via a static ESM import.
238
+ - **Computed member access with non-literal keys** (`ns[key]()` where `key` is a variable, or `ns[\`cn`]()` with template literals) — the compiler cannot statically determine which property is being accessed. Use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead.
239
+
240
+ ### Workarounds for unsupported patterns
241
+
242
+ When the compiler cannot auto-promote a derived local (dynamic import, `require()`, or computed member access), use one of these workarounds to preserve reactivity.
243
+
244
+ #### 1. Replace dynamic import with static import (recommended)
245
+
246
+ If the module is available at startup, use a static ESM import instead of `await import()`. The compiler can then scan the source file for `@sinwan-pure` annotations and auto-promote the derived local.
247
+
248
+ ```ts
249
+ // ❌ Does NOT work — dynamic import, binding is runtime
250
+ const { cn } = await import("./utils");
251
+ const cls = cn("base", size.value > 50 && "big");
252
+
253
+ // ✅ Works — static import, compiler auto-promotes
254
+ import { cn } from "./utils"; // /** @sinwan-pure */ export function cn(...)
255
+ const cls = cn("base", size.value > 50 && "big");
256
+
257
+ // Compiled output:
258
+ // const cls = () => cn("base", size.value > 50 && "big");
259
+ ```
260
+
261
+ #### 2. Replace `require()` with static ESM import (recommended)
262
+
263
+ Sinwan targets ESM. Use a static ESM import instead of `require()`.
264
+
265
+ ```ts
266
+ // ❌ Does NOT work — CommonJS require(), not ESM
267
+ const { cn } = require("./utils");
268
+ const cls = cn("base", size.value > 50 && "big");
269
+
270
+ // ✅ Works — static ESM import, compiler auto-promotes
271
+ import { cn } from "./utils";
272
+ const cls = cn("base", size.value > 50 && "big");
273
+ ```
274
+
275
+ #### 3. Replace computed member access with direct or string literal access (recommended)
276
+
277
+ If you control the call site, use direct member access (`ns.cn()`) or string literal access (`ns["cn"]()`) instead of a variable key.
278
+
279
+ ```ts
280
+ import * as ns from "./utils";
281
+
282
+ // ❌ Does NOT work — variable key, compiler can't resolve
283
+ const key = "cn";
284
+ const cls = ns[key]("base", size.value > 50 && "big");
285
+
286
+ // ❌ Does NOT work — template literal key, compiler can't resolve
287
+ const cls = ns[`cn`]("base", size.value > 50 && "big");
288
+
289
+ // ✅ Works — direct member access, compiler auto-promotes
290
+ const cls = ns.cn("base", size.value > 50 && "big");
291
+
292
+ // ✅ Works — string literal key, compiler auto-promotes
293
+ const cls = ns["cn"]("base", size.value > 50 && "big");
294
+ ```
295
+
296
+ #### 4. Use a manual `() =>` getter (fallback for truly dynamic patterns)
297
+
298
+ If you genuinely need dynamic import (e.g. for code-splitting) or computed member access with a variable key, wrap the expression in a manual `() =>` getter. The runtime will re-evaluate it in an effect whenever its tracked dependencies change.
299
+
300
+ ```ts
301
+ import * as ns from "./utils";
302
+ import { computed } from "sinwan/reactivity";
303
+
304
+ // Manual getter — the compiler does not promote it, but the runtime
305
+ // wraps it in an effect so it stays reactive.
306
+ const cls = () => {
307
+ const key = "cn";
308
+ return ns[key]("base", size.value > 50 && "big");
309
+ };
310
+
311
+ // Or use computed() for a memoized version (re-evaluates only when
312
+ // size.value changes, not on every access):
313
+ const clsMemo = computed(() => {
314
+ const key = "cn";
315
+ return ns[key]("base", size.value > 50 && "big");
316
+ });
317
+ ```
318
+
319
+ #### 5. Use `computed()` for expensive dynamic patterns
320
+
321
+ If the dynamic expression is expensive (e.g. involves an `await import()`), use `computed()` to memoize the result. The computation runs once and caches until a dependency changes.
322
+
323
+ ```ts
324
+ import { computed } from "sinwan/reactivity";
325
+
326
+ // computed() runs the async import lazily and caches the result.
327
+ // The compiler does not promote this (it's already a getter),
328
+ // but the runtime tracks size.value as a dependency.
329
+ const cls = computed(() => {
330
+ // For dynamic import, you would need to resolve the module
331
+ // outside the computed() and only use the dynamic key inside:
332
+ const key = "cn";
333
+ return ns[key]("base", size.value > 50 && "big");
334
+ });
335
+ ```
336
+
337
+ #### Complete demo
338
+
339
+ ```ts
340
+ // lib/utils.ts
341
+ /** @sinwan-pure */
342
+ export function cn(...inputs: unknown[]): string {
343
+ return String(inputs);
344
+ }
345
+
346
+ /** @sinwan-pure */
347
+ export const helpers = {
348
+ merge: (...inputs: unknown[]): string => String(inputs),
349
+ };
350
+
351
+ // comp.tsx
352
+ import { cc } from "sinwan/component";
353
+ import { signal, computed } from "sinwan/reactivity";
354
+ import { cn, helpers } from "../lib/utils";
355
+ import * as ns from "../lib/utils";
356
+
357
+ export const App = cc(() => {
358
+ const size = signal(30);
359
+
360
+ // ✅ Auto-promoted — static import + @sinwan-pure
361
+ const a = cn("base", size.value > 50 && "big");
362
+
363
+ // ✅ Auto-promoted — namespace import + direct member access
364
+ const b = ns.cn("base", size.value > 50 && "big");
365
+
366
+ // ✅ Auto-promoted — string literal key
367
+ const c = ns["cn"]("base", size.value > 50 && "big");
368
+
369
+ // ✅ Auto-promoted — object member call
370
+ const d = helpers.merge("base", size.value > 50 && "big");
371
+
372
+ // ✅ Manual getter — for computed member access with variable key
373
+ const e = () => {
374
+ const key = "cn";
375
+ return ns[key]("base", size.value > 50 && "big");
376
+ };
377
+
378
+ // ✅ computed() — memoized, re-evaluates only when size.value changes
379
+ const f = computed(() => ns.cn("base", size.value > 50 && "big"));
380
+
381
+ return <div class={a} data-b={b} data-c={c} data-d={d} data-e={e} data-f={f} />;
382
+ });
383
+ ```
384
+
385
+ Compiled output:
386
+
387
+ ```ts
388
+ const a = () => cn("base", size.value > 50 && "big");
389
+ const b = () => ns.cn("base", size.value > 50 && "big");
390
+ const c = () => ns["cn"]("base", size.value > 50 && "big");
391
+ const d = () => helpers.merge("base", size.value > 50 && "big");
392
+ const e = () => {
393
+ // manual getter — preserved as-is
394
+ const key = "cn";
395
+ return ns[key]("base", size.value > 50 && "big");
396
+ };
397
+ const f = computed(() => ns.cn("base", size.value > 50 && "big")); // already a getter
398
+ ```
399
+
400
+ ### Client, SSR and hydration
401
+
402
+ Generated getter calls use the existing JSX wrapping and explicit-binding rules; manual getter identifiers do not need a new blanket wrapping policy. Native attributes must use a runtime build with plain zero-argument getter hydration support. Text, attributes and `Show` remain reactive after hydration; event handlers and nonzero-arity callbacks are not invoked as getters.
403
+
404
+ The runtime's existing complex-template hydration fallback can replace a hoisted server subtree during initial hydration. Promotion does not change that policy; subsequent signal updates do not remount the native shell. Non-hoisted attribute hydration binds to the existing DOM in place.
405
+
106
406
  ## DOM vs component JSX
107
407
 
108
408
  **Native elements** (`div`, `p`, `button`, …): wrap reactive children and attributes.
@@ -110,18 +410,18 @@ Not wrapped:
110
410
  **User components** (`<Label>`, `<Child title={…} />`):
111
411
 
112
412
  - **Children:** wrap reactive reads the same way as DOM text (`{count.value}`, `{checked.value ? "On" : "Off"}`) so `cc()` parents do not snapshot the value. Use a getter (`() => …`), never `_$bindText`, because the child may render nodes as well as strings. Render-prop functions (`{(v) => …}`) stay untouched.
113
- - **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For *unknown* imported components, still wrap **derived reads** (`htmlFor={id.value}`, `disabled={off.value}`) and leave **container pass-through** alone (`checked={checked}`, `user={user}`) so the child receives the signal or store proxy. With `explicitBindings`, component props stay **getters** (`each={() => …}`), never `_$bindAttr` — `For` / `Show` / `Label` call `resolve(prop)`, which does not unwrap bind descriptors.
413
+ - **Props:** wrap a prop if it is known-reactive for that component (built-in registry, local call graph, analyzer metadata). For _unknown_ imported components, still wrap **derived reads** (`htmlFor={id.value}`, `disabled={off.value}`) and leave **container pass-through** alone (`checked={checked}`, `user={user}`) so the child receives the signal or store proxy. With `explicitBindings`, component props stay **getters** (`each={() => …}`), never `_$bindAttr` — `For` / `Show` / `Label` call `resolve(prop)`, which does not unwrap bind descriptors.
114
414
 
115
415
  **Built-in control-flow** components wrap specific props, and they wrap reactive expression children (those children render directly):
116
416
 
117
- | Component | Reactive props |
118
- | --------- | -------------- |
119
- | `For`, `Index`, `Virtual` | `each` |
120
- | `Show`, `Switch`, `Match`, `Key` | `when` |
121
- | `Dynamic` | `component` |
122
- | `Visible` | `when`, `style` |
123
- | `Portal` | `mount` |
124
- | `Activity` | `mode` |
417
+ | Component | Reactive props |
418
+ | -------------------------------- | --------------- |
419
+ | `For`, `Index`, `Virtual` | `each` |
420
+ | `Show`, `Switch`, `Match`, `Key` | `when` |
421
+ | `Dynamic` | `component` |
422
+ | `Visible` | `when`, `style` |
423
+ | `Portal` | `mount` |
424
+ | `Activity` | `mode` |
125
425
 
126
426
  `Suspense` is not in this list; its props are left alone.
127
427
 
@@ -131,12 +431,12 @@ Static native trees become a module-level template plus `_$createTemplate(tmpl,
131
431
 
132
432
  Slots:
133
433
 
134
- | Type | When |
135
- | ---- | ---- |
434
+ | Type | When |
435
+ | ------- | ------------------------------------------ |
136
436
  | `child` | `{expr}`, or a capitalized child component |
137
- | `attr` | Dynamic attribute (not `on*`) |
138
- | `event` | `onClick={…}` and other `on*` handlers |
139
- | `ref` | `ref={fn}` or `ref={object}` |
437
+ | `attr` | Dynamic attribute (not `on*`) |
438
+ | `event` | `onClick={…}` and other `on*` handlers |
439
+ | `ref` | `ref={fn}` or `ref={object}` |
140
440
 
141
441
  Static `style` objects and strings are written into the HTML (`backgroundColor` → `background-color`). Quoted style strings that contain `${…}` log a warning in the compiler: use a template literal, `style={\`…${x}\`}`.
142
442
 
@@ -150,6 +450,7 @@ The slot marker format is `COMPILER_TEMPLATE_SLOT_PROTOCOL` (`s:0`, `s:1`, …).
150
450
 
151
451
  ```ts
152
452
  transformJSX(code, "src/App.tsx", {
453
+ derivedLocals: false,
153
454
  hoist: true, // default true
154
455
  dev: false, // warn when hoisting is skipped
155
456
  explicitBindings: false, // emit _$bindText / _$bindAttr / …
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sinwan-compiler",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Shared compiler core for Sinwan plugins",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,13 +42,13 @@
42
42
  "@types/bun": "latest"
43
43
  },
44
44
  "dependencies": {
45
- "@babel/core": "^7.24.0",
46
- "@babel/generator": "^7.24.0",
47
- "@babel/parser": "^7.24.0",
48
- "@babel/traverse": "^7.24.0",
49
- "@babel/types": "^7.24.0"
45
+ "@babel/core": "^7.29.7",
46
+ "@babel/generator": "^7.29.8",
47
+ "@babel/parser": "^7.29.8",
48
+ "@babel/traverse": "^7.29.8",
49
+ "@babel/types": "^7.29.8"
50
50
  },
51
51
  "peerDependencies": {
52
- "typescript": "^5"
52
+ "typescript": ">=5.0.0 <8.0.0"
53
53
  }
54
54
  }