tova 0.3.5 → 0.3.6

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/bin/tova.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import { resolve, basename, dirname, join, relative } from 'path';
4
4
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync, watch as fsWatch } from 'fs';
5
5
  import { spawn } from 'child_process';
6
- import { createHash } from 'crypto';
6
+ // Bun.hash used instead of crypto.createHash for faster hashing
7
7
  import { Lexer } from '../src/lexer/lexer.js';
8
8
  import { Parser } from '../src/parser/parser.js';
9
9
  import { Analyzer } from '../src/analyzer/analyzer.js';
@@ -12,7 +12,7 @@ import { Program } from '../src/parser/ast.js';
12
12
  import { CodeGenerator } from '../src/codegen/codegen.js';
13
13
  import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from '../src/diagnostics/formatter.js';
14
14
  import { getExplanation, lookupCode } from '../src/diagnostics/error-codes.js';
15
- import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '../src/stdlib/inline.js';
15
+ import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE, NATIVE_INIT } from '../src/stdlib/inline.js';
16
16
  import { Formatter } from '../src/formatter/formatter.js';
17
17
  import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
18
18
  import '../src/runtime/string-proto.js';
@@ -225,7 +225,7 @@ function compileTova(source, filename, options = {}) {
225
225
  }
226
226
  }
227
227
 
228
- const codegen = new CodeGenerator(ast, filename);
228
+ const codegen = new CodeGenerator(ast, filename, { sourceMaps: options.sourceMaps !== false });
229
229
  return codegen.generate();
230
230
  }
231
231
 
@@ -396,6 +396,7 @@ async function runTests(args) {
396
396
  });
397
397
  } else {
398
398
  // Clean up temp dir on exit (non-watch mode)
399
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
399
400
  process.exitCode = exitCode;
400
401
  }
401
402
  }
@@ -454,6 +455,9 @@ async function runBench(args) {
454
455
  console.error(` Error compiling ${relative('.', file)}: ${err.message}`);
455
456
  }
456
457
  }
458
+
459
+ // Clean up bench temp dir
460
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
457
461
  }
458
462
 
459
463
  async function generateDocs(args) {
@@ -629,11 +633,18 @@ async function buildProject(args) {
629
633
  const isVerbose = args.includes('--verbose');
630
634
  const isQuiet = args.includes('--quiet');
631
635
  const isWatch = args.includes('--watch');
632
- const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
636
+ const binaryIdx = args.indexOf('--binary');
637
+ const binaryName = binaryIdx >= 0 ? args[binaryIdx + 1] : null;
638
+ const explicitSrc = args.filter(a => !a.startsWith('--') && a !== binaryName)[0];
633
639
  const srcDir = resolve(explicitSrc || config.project.entry || '.');
634
640
  const outIdx = args.indexOf('--output');
635
641
  const outDir = resolve(outIdx >= 0 ? args[outIdx + 1] : (config.build.output || '.tova-out'));
636
642
 
643
+ // Binary compilation: compile to single standalone executable
644
+ if (binaryName) {
645
+ return await binaryBuild(srcDir, binaryName, outDir);
646
+ }
647
+
637
648
  // Production build uses a separate optimized pipeline
638
649
  if (isProduction) {
639
650
  return await productionBuild(srcDir, outDir);
@@ -658,7 +669,7 @@ async function buildProject(args) {
658
669
 
659
670
  let errorCount = 0;
660
671
  const buildStart = Date.now();
661
- compilationCache.clear();
672
+ compilationCache.clear(); moduleTypeCache.clear();
662
673
 
663
674
  // Load incremental build cache
664
675
  const noCache = args.includes('--no-cache');
@@ -842,7 +853,8 @@ async function buildProject(args) {
842
853
  if (!filename || !filename.endsWith('.tova')) return;
843
854
  if (debounceTimer) clearTimeout(debounceTimer);
844
855
  debounceTimer = setTimeout(async () => {
845
- compilationCache.clear();
856
+ const changedPath = resolve(srcDir, filename);
857
+ invalidateFile(changedPath);
846
858
  if (!isQuiet) console.log(` Rebuilding (${filename} changed)...`);
847
859
  await buildProject(args.filter(a => a !== '--watch'));
848
860
  if (!isQuiet) console.log(' Watching for changes...\n');
@@ -1028,7 +1040,7 @@ async function devServer(args) {
1028
1040
  let hasClient = false;
1029
1041
 
1030
1042
  // Clear import caches for fresh compilation
1031
- compilationCache.clear();
1043
+ compilationCache.clear(); moduleTypeCache.clear();
1032
1044
  compilationInProgress.clear();
1033
1045
  moduleExports.clear();
1034
1046
 
@@ -1181,10 +1193,8 @@ async function devServer(args) {
1181
1193
  const currentFiles = findFiles(srcDir, '.tova');
1182
1194
  const newServerFiles = [];
1183
1195
 
1184
- // Clear import caches for fresh compilation
1185
- compilationCache.clear();
1196
+ // invalidateFile() was already called by startWatcher — just clear transient state
1186
1197
  compilationInProgress.clear();
1187
- moduleExports.clear();
1188
1198
 
1189
1199
  try {
1190
1200
  // Merge each directory group, collect client HTML
@@ -2162,8 +2172,8 @@ async function migrateStatus(args) {
2162
2172
  function getStdlibForRuntime() {
2163
2173
  return getFullStdlib(); // Full stdlib for REPL
2164
2174
  }
2165
- function getRunStdlib() { // Only PROPAGATE — codegen tree-shakes stdlib into output.shared
2166
- return PROPAGATE;
2175
+ function getRunStdlib() { // NATIVE_INIT + PROPAGATE — codegen tree-shakes stdlib into output.shared
2176
+ return NATIVE_INIT + '\n' + PROPAGATE;
2167
2177
  }
2168
2178
 
2169
2179
  // ─── npm Interop Utilities ───────────────────────────────────
@@ -2387,7 +2397,8 @@ async function startRepl() {
2387
2397
  const context = {};
2388
2398
  const stdlib = getStdlibForRuntime();
2389
2399
  // Use authoritative BUILTIN_NAMES + runtime names (Ok, Err, Some, None, __propagate)
2390
- const stdlibNames = [...BUILTIN_NAMES, 'Ok', 'Err', 'Some', 'None', '__propagate'];
2400
+ // Filter out internal __ prefixed names that don't define a same-named variable
2401
+ const stdlibNames = [...BUILTIN_NAMES].filter(n => !n.startsWith('__')).concat(['Ok', 'Err', 'Some', 'None', '__propagate']);
2391
2402
  const initFn = new Function(stdlib + '\nObject.assign(this, {' + stdlibNames.join(',') + '});');
2392
2403
  initFn.call(context);
2393
2404
 
@@ -2419,7 +2430,7 @@ async function startRepl() {
2419
2430
  if (trimmed.startsWith(':type ')) {
2420
2431
  const expr = trimmed.slice(6).trim();
2421
2432
  try {
2422
- const output = compileTova(expr, '<repl>');
2433
+ const output = compileTova(expr, '<repl>', { sourceMaps: false });
2423
2434
  const code = output.shared || '';
2424
2435
  const ctxKeys = Object.keys(context).filter(k => k !== '__mutable');
2425
2436
  const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;\n` : '';
@@ -2527,7 +2538,7 @@ async function startRepl() {
2527
2538
  buffer = '';
2528
2539
 
2529
2540
  try {
2530
- const output = compileTova(input, '<repl>', { suppressWarnings: true });
2541
+ const output = compileTova(input, '<repl>', { suppressWarnings: true, sourceMaps: false });
2531
2542
  const code = output.shared || '';
2532
2543
  if (code.trim()) {
2533
2544
  // Extract function/const/let names from compiled code
@@ -2619,17 +2630,23 @@ async function startRepl() {
2619
2630
 
2620
2631
  function startWatcher(srcDir, callback) {
2621
2632
  let debounceTimer = null;
2633
+ let pendingChanges = new Set();
2622
2634
 
2623
2635
  console.log(` Watching for changes in ${srcDir}...`);
2624
2636
 
2625
2637
  const watcher = fsWatch(srcDir, { recursive: true }, (eventType, filename) => {
2626
2638
  if (!filename || !filename.endsWith('.tova')) return;
2639
+ pendingChanges.add(resolve(srcDir, filename));
2627
2640
  // Debounce rapid file changes
2628
2641
  if (debounceTimer) clearTimeout(debounceTimer);
2629
2642
  debounceTimer = setTimeout(() => {
2630
- console.log(`\n File changed: ${filename}`);
2643
+ const changed = pendingChanges;
2644
+ pendingChanges = new Set();
2645
+ console.log(`\n File changed: ${[...changed].map(f => basename(f)).join(', ')}`);
2631
2646
  try {
2632
- compilationCache.clear();
2647
+ for (const changedPath of changed) {
2648
+ invalidateFile(changedPath);
2649
+ }
2633
2650
  callback();
2634
2651
  } catch (err) {
2635
2652
  console.error(` Rebuild error: ${err.message}`);
@@ -2740,6 +2757,88 @@ class SourceMapBuilder {
2740
2757
  }
2741
2758
  }
2742
2759
 
2760
+ // ─── Binary Build ───────────────────────────────────────────
2761
+
2762
+ async function binaryBuild(srcDir, outputName, outDir) {
2763
+ const tovaFiles = findFiles(srcDir, '.tova');
2764
+ if (tovaFiles.length === 0) {
2765
+ console.error('No .tova files found');
2766
+ process.exit(1);
2767
+ }
2768
+
2769
+ const tmpDir = join(outDir, '.tova-binary-tmp');
2770
+ mkdirSync(tmpDir, { recursive: true });
2771
+
2772
+ console.log(`\n Compiling to binary: ${outputName}\n`);
2773
+
2774
+ // Step 1: Compile all .tova files to JS
2775
+ const sharedParts = [];
2776
+ const serverParts = [];
2777
+ const clientParts = [];
2778
+
2779
+ for (const file of tovaFiles) {
2780
+ try {
2781
+ const source = readFileSync(file, 'utf-8');
2782
+ const output = compileTova(source, file);
2783
+ if (output.shared) sharedParts.push(output.shared);
2784
+ if (output.server) serverParts.push(output.server);
2785
+ if (output.client) clientParts.push(output.client);
2786
+ } catch (err) {
2787
+ console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
2788
+ process.exit(1);
2789
+ }
2790
+ }
2791
+
2792
+ // Step 2: Bundle into a single JS file
2793
+ const stdlib = getRunStdlib();
2794
+ const allShared = sharedParts.join('\n');
2795
+ const allServer = serverParts.join('\n');
2796
+
2797
+ let bundledCode;
2798
+ if (allServer.trim()) {
2799
+ // Server app
2800
+ bundledCode = stdlib + '\n' + allShared + '\n' + allServer;
2801
+ } else {
2802
+ // Script/shared-only app
2803
+ bundledCode = stdlib + '\n' + allShared;
2804
+ // Auto-call main() if it exists
2805
+ if (/\bfunction\s+main\s*\(/.test(bundledCode)) {
2806
+ bundledCode += '\nconst __tova_exit = await main(process.argv.slice(2)); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
2807
+ }
2808
+ }
2809
+
2810
+ // Strip import/export statements (everything is inlined)
2811
+ bundledCode = bundledCode.replace(/^export /gm, '');
2812
+ bundledCode = bundledCode.replace(/^\s*import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
2813
+
2814
+ const entryPath = join(tmpDir, 'entry.js');
2815
+ writeFileSync(entryPath, bundledCode);
2816
+ console.log(` Compiled ${tovaFiles.length} file(s) to JS`);
2817
+
2818
+ // Step 3: Use Bun to compile to standalone binary
2819
+ const outputPath = resolve(outputName);
2820
+ try {
2821
+ const { execFileSync } = await import('child_process');
2822
+ execFileSync('bun', ['build', '--compile', entryPath, '--outfile', outputPath], {
2823
+ stdio: ['pipe', 'pipe', 'pipe'],
2824
+ env: process.env,
2825
+ });
2826
+
2827
+ // Get file size
2828
+ const stat = statSync(outputPath);
2829
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
2830
+ console.log(` Created binary: ${outputPath} (${sizeMB}MB)`);
2831
+ } finally {
2832
+ // Clean up temp files
2833
+ try {
2834
+ rmSync(tmpDir, { recursive: true });
2835
+ } catch {}
2836
+ }
2837
+
2838
+ const displayPath = outputPath.startsWith(process.cwd()) ? './' + relative(process.cwd(), outputPath) : outputPath;
2839
+ console.log(`\n Done! Run with: ${displayPath}\n`);
2840
+ }
2841
+
2743
2842
  // ─── Production Build ────────────────────────────────────────
2744
2843
 
2745
2844
  async function productionBuild(srcDir, outDir) {
@@ -2753,9 +2852,9 @@ async function productionBuild(srcDir, outDir) {
2753
2852
 
2754
2853
  console.log(`\n Production build...\n`);
2755
2854
 
2756
- let allClientCode = '';
2757
- let allServerCode = '';
2758
- let allSharedCode = '';
2855
+ const clientParts = [];
2856
+ const serverParts = [];
2857
+ const sharedParts = [];
2759
2858
  let cssContent = '';
2760
2859
 
2761
2860
  for (const file of tovaFiles) {
@@ -2763,17 +2862,21 @@ async function productionBuild(srcDir, outDir) {
2763
2862
  const source = readFileSync(file, 'utf-8');
2764
2863
  const output = compileTova(source, file);
2765
2864
 
2766
- if (output.shared) allSharedCode += output.shared + '\n';
2767
- if (output.server) allServerCode += output.server + '\n';
2768
- if (output.client) allClientCode += output.client + '\n';
2865
+ if (output.shared) sharedParts.push(output.shared);
2866
+ if (output.server) serverParts.push(output.server);
2867
+ if (output.client) clientParts.push(output.client);
2769
2868
  } catch (err) {
2770
2869
  console.error(` Error in ${relative(srcDir, file)}: ${err.message}`);
2771
2870
  process.exit(1);
2772
2871
  }
2773
2872
  }
2774
2873
 
2874
+ const allClientCode = clientParts.join('\n');
2875
+ const allServerCode = serverParts.join('\n');
2876
+ const allSharedCode = sharedParts.join('\n');
2877
+
2775
2878
  // Generate content hash for cache busting
2776
- const hashCode = (s) => createHash('sha256').update(s).digest('hex').slice(0, 12);
2879
+ const hashCode = (s) => Bun.hash(s).toString(16).slice(0, 12);
2777
2880
 
2778
2881
  // Write server bundle
2779
2882
  if (allServerCode.trim()) {
@@ -2846,20 +2949,16 @@ async function productionBuild(srcDir, outDir) {
2846
2949
  const filePath = join(outDir, f);
2847
2950
  const minPath = join(outDir, f.replace('.js', '.min.js'));
2848
2951
  try {
2849
- // Use Bun.build for proper minification with tree-shaking
2850
- const result = await Bun.build({
2851
- entrypoints: [filePath],
2852
- outdir: outDir,
2853
- minify: true,
2854
- naming: f.replace('.js', '.min.js'),
2855
- });
2856
- if (result.success) {
2857
- const originalSize = statSync(filePath).size;
2858
- const minSize = statSync(minPath).size;
2859
- const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
2860
- console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
2861
- minified++;
2862
- }
2952
+ // Use Bun.Transpiler for minification without bundling (preserves imports)
2953
+ const source = readFileSync(filePath, 'utf-8');
2954
+ const transpiler = new Bun.Transpiler({ minifyWhitespace: true, trimUnusedImports: true });
2955
+ const minCode = transpiler.transformSync(source);
2956
+ writeFileSync(minPath, minCode);
2957
+ const originalSize = Buffer.byteLength(source);
2958
+ const minSize = Buffer.byteLength(minCode);
2959
+ const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
2960
+ console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
2961
+ minified++;
2863
2962
  } catch {
2864
2963
  // Bun.build not available — fall back to simple whitespace stripping
2865
2964
  try {
@@ -2884,17 +2983,221 @@ async function productionBuild(srcDir, outDir) {
2884
2983
  console.log(`\n Production build complete.\n`);
2885
2984
  }
2886
2985
 
2887
- // Simple minification fallback: strip comments and collapse whitespace
2986
+ // Fallback JS minifier string/regex-aware, no AST required
2888
2987
  function _simpleMinify(code) {
2889
- // Strip single-line comments (but not URLs with //)
2890
- let result = code.replace(/(?<![:"'])\/\/[^\n]*/g, '');
2891
- // Strip multi-line comments
2892
- result = result.replace(/\/\*[\s\S]*?\*\//g, '');
2893
- // Collapse multiple blank lines
2894
- result = result.replace(/\n{3,}/g, '\n\n');
2895
- // Trim trailing whitespace from each line
2896
- result = result.replace(/[ \t]+$/gm, '');
2897
- return result.trim();
2988
+ // Phase 1: Strip comments while respecting strings and regexes
2989
+ let stripped = '';
2990
+ let i = 0;
2991
+ const len = code.length;
2992
+ while (i < len) {
2993
+ const ch = code[i];
2994
+ // String literals pass through unchanged
2995
+ if (ch === '"' || ch === "'" || ch === '`') {
2996
+ const quote = ch;
2997
+ stripped += ch;
2998
+ i++;
2999
+ while (i < len) {
3000
+ const c = code[i];
3001
+ stripped += c;
3002
+ if (c === '\\') { i++; if (i < len) { stripped += code[i]; i++; } continue; }
3003
+ if (quote === '`' && c === '$' && code[i + 1] === '{') {
3004
+ // Template literal expression — track brace depth
3005
+ stripped += code[++i]; // '{'
3006
+ i++;
3007
+ let depth = 1;
3008
+ while (i < len && depth > 0) {
3009
+ const tc = code[i];
3010
+ if (tc === '{') depth++;
3011
+ else if (tc === '}') depth--;
3012
+ if (depth > 0) { stripped += tc; i++; }
3013
+ }
3014
+ if (i < len) { stripped += code[i]; i++; } // closing '}'
3015
+ continue;
3016
+ }
3017
+ if (c === quote) { i++; break; }
3018
+ i++;
3019
+ }
3020
+ continue;
3021
+ }
3022
+ // Single-line comment
3023
+ if (ch === '/' && code[i + 1] === '/') {
3024
+ while (i < len && code[i] !== '\n') i++;
3025
+ continue;
3026
+ }
3027
+ // Multi-line comment
3028
+ if (ch === '/' && code[i + 1] === '*') {
3029
+ i += 2;
3030
+ while (i < len - 1 && !(code[i] === '*' && code[i + 1] === '/')) i++;
3031
+ i += 2;
3032
+ continue;
3033
+ }
3034
+ stripped += ch;
3035
+ i++;
3036
+ }
3037
+
3038
+ // Phase 2: Process line by line
3039
+ const lines = stripped.split('\n');
3040
+ const out = [];
3041
+ for (let j = 0; j < lines.length; j++) {
3042
+ let line = lines[j].trim();
3043
+ if (!line) continue; // remove blank lines
3044
+ // Strip console.log/debug/warn/info statements (production only)
3045
+ if (/^\s*console\.(log|debug|warn|info)\s*\(/.test(line)) {
3046
+ // Simple balanced-paren check for single-line console calls
3047
+ let parens = 0, inStr = false, sq = '';
3048
+ for (let k = 0; k < line.length; k++) {
3049
+ const c = line[k];
3050
+ if (inStr) { if (c === '\\') k++; else if (c === sq) inStr = false; continue; }
3051
+ if (c === '"' || c === "'" || c === '`') { inStr = true; sq = c; continue; }
3052
+ if (c === '(') parens++;
3053
+ if (c === ')') { parens--; if (parens === 0) break; }
3054
+ }
3055
+ if (parens === 0) continue; // balanced — safe to strip
3056
+ }
3057
+ out.push(line);
3058
+ }
3059
+
3060
+ // Phase 3: Collapse whitespace — protect string literals with placeholders
3061
+ let joined = out.join('\n');
3062
+ const strings = [];
3063
+ // Extract all string literals into placeholders so regexes don't mangle them
3064
+ let prot = '';
3065
+ let pi = 0;
3066
+ const plen = joined.length;
3067
+ while (pi < plen) {
3068
+ const pc = joined[pi];
3069
+ if (pc === '"' || pc === "'" || pc === '`') {
3070
+ const q = pc;
3071
+ let s = pc;
3072
+ pi++;
3073
+ while (pi < plen) {
3074
+ const c = joined[pi];
3075
+ s += c;
3076
+ if (c === '\\') { pi++; if (pi < plen) { s += joined[pi]; pi++; } continue; }
3077
+ if (q === '`' && c === '$' && joined[pi + 1] === '{') {
3078
+ s += joined[++pi]; pi++;
3079
+ let d = 1;
3080
+ while (pi < plen && d > 0) {
3081
+ const tc = joined[pi];
3082
+ if (tc === '{') d++; else if (tc === '}') d--;
3083
+ if (d > 0) { s += tc; pi++; }
3084
+ }
3085
+ if (pi < plen) { s += joined[pi]; pi++; }
3086
+ continue;
3087
+ }
3088
+ if (c === q) { pi++; break; }
3089
+ pi++;
3090
+ }
3091
+ strings.push(s);
3092
+ prot += `\x01${strings.length - 1}\x01`;
3093
+ } else {
3094
+ prot += pc;
3095
+ pi++;
3096
+ }
3097
+ }
3098
+
3099
+ // Collapse runs of spaces/tabs to single space
3100
+ prot = prot.replace(/[ \t]+/g, ' ');
3101
+ // Remove spaces around braces, brackets, parens, semicolons, commas
3102
+ prot = prot.replace(/ ?([{}[\]();,]) ?/g, '$1');
3103
+ // Restore space after keywords that need it
3104
+ prot = prot.replace(/\b(return|const|let|var|if|else|for|while|do|switch|case|throw|new|typeof|instanceof|in|of|yield|await|export|import|from|function|class|extends|async|delete|void)\b(?=[^\s;,})\]])/g, '$1 ');
3105
+ // Add space after colon in object literals (key: value)
3106
+ prot = prot.replace(/([a-zA-Z0-9_$]):([^\s}])/g, '$1: $2');
3107
+
3108
+ // Restore string literals
3109
+ let result = prot.replace(/\x01(\d+)\x01/g, (_, idx) => strings[idx]);
3110
+
3111
+ // Phase 4: Dead function elimination — remove unused top-level functions
3112
+ result = _eliminateDeadFunctions(result);
3113
+
3114
+ return result;
3115
+ }
3116
+
3117
+ // Remove top-level function declarations that are never reachable from non-function code.
3118
+ // Uses reachability analysis to handle mutual recursion (foo↔bar both dead).
3119
+ function _eliminateDeadFunctions(code) {
3120
+ const funcDeclRe = /^function\s+([\w$]+)\s*\(/gm;
3121
+ const allDecls = []; // [{ name, start, end }]
3122
+ let m;
3123
+
3124
+ // First pass: find all top-level function declarations and their full extent
3125
+ while ((m = funcDeclRe.exec(code)) !== null) {
3126
+ const name = m[1];
3127
+ const start = m.index;
3128
+ let depth = 0, i = start, inStr = false, strCh = '', foundOpen = false;
3129
+ while (i < code.length) {
3130
+ const ch = code[i];
3131
+ if (inStr) { if (ch === '\\') { i += 2; continue; } if (ch === strCh) inStr = false; i++; continue; }
3132
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = true; strCh = ch; i++; continue; }
3133
+ if (ch === '{') { depth++; foundOpen = true; }
3134
+ else if (ch === '}') { depth--; if (foundOpen && depth === 0) { i++; break; } }
3135
+ i++;
3136
+ }
3137
+ allDecls.push({ name, start, end: i });
3138
+ }
3139
+
3140
+ if (allDecls.length === 0) return code;
3141
+
3142
+ // Build a set of all declared function names
3143
+ const declaredNames = new Set(allDecls.map(d => d.name));
3144
+
3145
+ // Helper: find which declared names are referenced in a text region
3146
+ function findRefs(text) {
3147
+ const refs = new Set();
3148
+ for (const name of declaredNames) {
3149
+ const escaped = name.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
3150
+ if (new RegExp('\\b' + escaped + '\\b').test(text)) refs.add(name);
3151
+ }
3152
+ return refs;
3153
+ }
3154
+
3155
+ // Build "root" code — everything outside function declarations
3156
+ const sortedDecls = [...allDecls].sort((a, b) => a.start - b.start);
3157
+ let rootCode = '';
3158
+ let pos = 0;
3159
+ for (const decl of sortedDecls) {
3160
+ rootCode += code.slice(pos, decl.start);
3161
+ pos = decl.end;
3162
+ }
3163
+ rootCode += code.slice(pos);
3164
+
3165
+ // Find which functions are directly reachable from root code
3166
+ const rootRefs = findRefs(rootCode);
3167
+
3168
+ // Build dependency graph: for each function, which other declared functions does it reference?
3169
+ const deps = new Map();
3170
+ for (const decl of allDecls) {
3171
+ const body = code.slice(decl.start, decl.end);
3172
+ const bodyRefs = findRefs(body);
3173
+ bodyRefs.delete(decl.name); // ignore self-references
3174
+ deps.set(decl.name, bodyRefs);
3175
+ }
3176
+
3177
+ // BFS from root refs to find all transitively reachable functions
3178
+ const reachable = new Set();
3179
+ const queue = [...rootRefs];
3180
+ while (queue.length > 0) {
3181
+ const name = queue.pop();
3182
+ if (reachable.has(name)) continue;
3183
+ reachable.add(name);
3184
+ const fnDeps = deps.get(name);
3185
+ if (fnDeps) for (const dep of fnDeps) queue.push(dep);
3186
+ }
3187
+
3188
+ // Remove unreachable functions
3189
+ const toRemove = allDecls.filter(d => !reachable.has(d.name));
3190
+ if (toRemove.length === 0) return code;
3191
+
3192
+ toRemove.sort((a, b) => b.start - a.start);
3193
+ let result = code;
3194
+ for (const { start, end } of toRemove) {
3195
+ let removeEnd = end;
3196
+ while (removeEnd < result.length && (result[removeEnd] === '\n' || result[removeEnd] === '\r')) removeEnd++;
3197
+ result = result.slice(0, start) + result.slice(removeEnd);
3198
+ }
3199
+
3200
+ return result;
2898
3201
  }
2899
3202
 
2900
3203
  function _formatBytes(bytes) {
@@ -2916,7 +3219,7 @@ class BuildCache {
2916
3219
  }
2917
3220
 
2918
3221
  _hashContent(content) {
2919
- return createHash('sha256').update(content).digest('hex').slice(0, 16);
3222
+ return Bun.hash(content).toString(16);
2920
3223
  }
2921
3224
 
2922
3225
  load() {
@@ -2953,12 +3256,11 @@ class BuildCache {
2953
3256
 
2954
3257
  // Hash multiple files together for group caching
2955
3258
  _hashGroup(files) {
2956
- const hash = createHash('sha256');
3259
+ let combined = '';
2957
3260
  for (const f of files.slice().sort()) {
2958
- hash.update(f);
2959
- hash.update(readFileSync(f, 'utf-8'));
3261
+ combined += f + readFileSync(f, 'utf-8');
2960
3262
  }
2961
- return hash.digest('hex').slice(0, 16);
3263
+ return Bun.hash(combined).toString(16);
2962
3264
  }
2963
3265
 
2964
3266
  // Store compiled output for a multi-file group
@@ -3012,19 +3314,43 @@ class BuildCache {
3012
3314
 
3013
3315
  // Determine the compiled JS extension for a .tova file.
3014
3316
  // Module files (no blocks) → '.js', app files → '.shared.js'
3317
+ const moduleTypeCache = new Map(); // tovaPath -> '.js' | '.shared.js'
3318
+
3015
3319
  function getCompiledExtension(tovaPath) {
3016
3320
  // Check compilation cache first
3017
3321
  if (compilationCache.has(tovaPath)) {
3018
3322
  return compilationCache.get(tovaPath).isModule ? '.js' : '.shared.js';
3019
3323
  }
3020
- // Quick-scan the source for block keywords
3324
+ // Check module type cache (set during parsing)
3325
+ if (moduleTypeCache.has(tovaPath)) {
3326
+ return moduleTypeCache.get(tovaPath);
3327
+ }
3328
+ // Fall back: quick-lex the file to detect block keywords at top level
3021
3329
  if (existsSync(tovaPath)) {
3022
3330
  const src = readFileSync(tovaPath, 'utf-8');
3023
- // If the file contains top-level block keywords followed by '{', it's an app file
3024
- if (/^(?:shared|server|client|test|bench|data)\s*(?:\{|")/m.test(src)) {
3025
- return '.shared.js';
3331
+ try {
3332
+ const lexer = new Lexer(src, tovaPath);
3333
+ const tokens = lexer.tokenize();
3334
+ // Check if any top-level token is a block keyword (shared/server/client/test/bench/data)
3335
+ const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'test', 'bench', 'data']);
3336
+ let depth = 0;
3337
+ for (const tok of tokens) {
3338
+ if (tok.type === 'LBRACE') depth++;
3339
+ else if (tok.type === 'RBRACE') depth--;
3340
+ else if (depth === 0 && tok.type === 'IDENTIFIER' && BLOCK_KEYWORDS.has(tok.value)) {
3341
+ moduleTypeCache.set(tovaPath, '.shared.js');
3342
+ return '.shared.js';
3343
+ }
3344
+ }
3345
+ moduleTypeCache.set(tovaPath, '.js');
3346
+ return '.js';
3347
+ } catch {
3348
+ // If lexing fails, fall back to regex heuristic
3349
+ if (/^(?:shared|server|client|test|bench|data)\s*(?:\{|")/m.test(src)) {
3350
+ return '.shared.js';
3351
+ }
3352
+ return '.js';
3026
3353
  }
3027
- return '.js';
3028
3354
  }
3029
3355
  return '.shared.js'; // default fallback
3030
3356
  }
@@ -3036,6 +3362,53 @@ const compilationChain = []; // ordered import chain for circular import error m
3036
3362
  // Track module exports for cross-file import validation
3037
3363
  const moduleExports = new Map();
3038
3364
 
3365
+ // Dependency graph: file -> Set of files it imports (forward deps)
3366
+ const fileDependencies = new Map();
3367
+ // Reverse dependency graph: file -> Set of files that import it
3368
+ const fileReverseDeps = new Map();
3369
+
3370
+ function trackDependency(fromFile, toFile) {
3371
+ if (!fileDependencies.has(fromFile)) fileDependencies.set(fromFile, new Set());
3372
+ fileDependencies.get(fromFile).add(toFile);
3373
+ if (!fileReverseDeps.has(toFile)) fileReverseDeps.set(toFile, new Set());
3374
+ fileReverseDeps.get(toFile).add(fromFile);
3375
+ }
3376
+
3377
+ // Get all files that transitively depend on changedFile
3378
+ function getTransitiveDependents(changedFile) {
3379
+ const dependents = new Set();
3380
+ const queue = [changedFile];
3381
+ while (queue.length > 0) {
3382
+ const file = queue.pop();
3383
+ dependents.add(file);
3384
+ const rdeps = fileReverseDeps.get(file);
3385
+ if (rdeps) {
3386
+ for (const dep of rdeps) {
3387
+ if (!dependents.has(dep)) queue.push(dep);
3388
+ }
3389
+ }
3390
+ }
3391
+ return dependents;
3392
+ }
3393
+
3394
+ function invalidateFile(changedPath) {
3395
+ const toInvalidate = getTransitiveDependents(changedPath);
3396
+ for (const file of toInvalidate) {
3397
+ compilationCache.delete(file);
3398
+ moduleTypeCache.delete(file);
3399
+ moduleExports.delete(file);
3400
+ // Clear forward deps (will be rebuilt on recompile)
3401
+ const deps = fileDependencies.get(file);
3402
+ if (deps) {
3403
+ for (const dep of deps) {
3404
+ const rdeps = fileReverseDeps.get(dep);
3405
+ if (rdeps) rdeps.delete(file);
3406
+ }
3407
+ fileDependencies.delete(file);
3408
+ }
3409
+ }
3410
+ }
3411
+
3039
3412
  function collectExports(ast, filename) {
3040
3413
  const publicExports = new Set();
3041
3414
  const allNames = new Set();
@@ -3115,6 +3488,10 @@ function compileWithImports(source, filename, srcDir) {
3115
3488
  const parser = new Parser(tokens, filename);
3116
3489
  const ast = parser.parse();
3117
3490
 
3491
+ // Cache module type from AST (avoids regex heuristic on subsequent lookups)
3492
+ const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === 'ClientBlock' || n.type === 'TestBlock' || n.type === 'BenchBlock' || n.type === 'DataBlock');
3493
+ moduleTypeCache.set(filename, hasBlocks ? '.shared.js' : '.js');
3494
+
3118
3495
  // Collect this module's exports for validation
3119
3496
  collectExports(ast, filename);
3120
3497
 
@@ -3122,6 +3499,7 @@ function compileWithImports(source, filename, srcDir) {
3122
3499
  for (const node of ast.body) {
3123
3500
  if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
3124
3501
  const importPath = resolve(dirname(filename), node.source);
3502
+ trackDependency(filename, importPath);
3125
3503
  if (compilationInProgress.has(importPath)) {
3126
3504
  const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3127
3505
  throw new Error(`Circular import detected: ${chain}`);
@@ -3148,6 +3526,7 @@ function compileWithImports(source, filename, srcDir) {
3148
3526
  }
3149
3527
  if (node.type === 'ImportDefault' && node.source.endsWith('.tova')) {
3150
3528
  const importPath = resolve(dirname(filename), node.source);
3529
+ trackDependency(filename, importPath);
3151
3530
  if (compilationInProgress.has(importPath)) {
3152
3531
  const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3153
3532
  throw new Error(`Circular import detected: ${chain}`);
@@ -3160,6 +3539,7 @@ function compileWithImports(source, filename, srcDir) {
3160
3539
  }
3161
3540
  if (node.type === 'ImportWildcard' && node.source.endsWith('.tova')) {
3162
3541
  const importPath = resolve(dirname(filename), node.source);
3542
+ trackDependency(filename, importPath);
3163
3543
  if (compilationInProgress.has(importPath)) {
3164
3544
  const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3165
3545
  throw new Error(`Circular import detected: ${chain}`);