tova 0.2.9 → 0.3.1

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
@@ -9,11 +9,13 @@ import { Parser } from '../src/parser/parser.js';
9
9
  import { Analyzer } from '../src/analyzer/analyzer.js';
10
10
  import { Program } from '../src/parser/ast.js';
11
11
  import { CodeGenerator } from '../src/codegen/codegen.js';
12
- import { richError, formatDiagnostics, DiagnosticFormatter } from '../src/diagnostics/formatter.js';
12
+ import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from '../src/diagnostics/formatter.js';
13
+ import { getExplanation, lookupCode } from '../src/diagnostics/error-codes.js';
13
14
  import { getFullStdlib, buildSelectiveStdlib, BUILTIN_NAMES, PROPAGATE } from '../src/stdlib/inline.js';
14
15
  import { Formatter } from '../src/formatter/formatter.js';
15
16
  import { REACTIVITY_SOURCE, RPC_SOURCE, ROUTER_SOURCE } from '../src/runtime/embedded.js';
16
17
  import '../src/runtime/string-proto.js';
18
+ import '../src/runtime/array-proto.js';
17
19
  import { resolveConfig } from '../src/config/resolve.js';
18
20
  import { writePackageJson } from '../src/config/package-json.js';
19
21
  import { addToSection, removeFromSection } from '../src/config/edit-toml.js';
@@ -35,6 +37,8 @@ Usage:
35
37
  Commands:
36
38
  run <file> Compile and execute a .tova file
37
39
  build [dir] Compile .tova files to JavaScript (default: current dir)
40
+ check [dir] Type-check .tova files without generating code
41
+ clean Delete .tova-out build artifacts
38
42
  dev Start development server with live reload
39
43
  new <name> Create a new Tova project
40
44
  install Install npm dependencies from tova.toml
@@ -43,17 +47,25 @@ Commands:
43
47
  repl Start interactive Tova REPL
44
48
  lsp Start Language Server Protocol server
45
49
  fmt <file> Format a .tova file (--check to verify only)
46
- test [dir] Run test blocks in .tova files (--filter, --watch)
50
+ test [dir] Run test blocks in .tova files (--filter, --watch, --coverage, --serial)
51
+ bench [dir] Run bench blocks in .tova files
52
+ doc [dir] Generate documentation from /// docstrings
53
+ init Initialize a Tova project in the current directory
47
54
  migrate:create <name> Create a new migration file
48
55
  migrate:up [file.tova] Run pending migrations
49
56
  migrate:status [file.tova] Show migration status
57
+ upgrade Upgrade Tova to the latest version
58
+ info Show Tova version, Bun version, project config, and installed dependencies
59
+ explain <code> Show detailed explanation for an error/warning code (e.g., tova explain E202)
50
60
 
51
61
  Options:
52
62
  --help, -h Show this help message
53
63
  --version, -v Show version
54
64
  --output, -o Output directory (default: .tova-out)
55
65
  --production Production build (minify, bundle, hash)
56
- --watch Watch for file changes
66
+ --watch Watch for file changes and rebuild
67
+ --verbose Show detailed output during compilation
68
+ --quiet Suppress non-error output
57
69
  --debug Show verbose error output
58
70
  --strict Enable strict type checking
59
71
  `;
@@ -75,14 +87,26 @@ async function main() {
75
87
 
76
88
  const isStrict = args.includes('--strict');
77
89
  switch (command) {
78
- case 'run':
79
- await runFile(args.filter(a => a !== '--strict')[1], { strict: isStrict });
90
+ case 'run': {
91
+ const runArgs = args.filter(a => a !== '--strict');
92
+ const filePath = runArgs[1];
93
+ const restArgs = runArgs.slice(2);
94
+ const ddIdx = restArgs.indexOf('--');
95
+ const scriptArgs = ddIdx !== -1 ? restArgs.slice(ddIdx + 1) : restArgs;
96
+ await runFile(filePath, { strict: isStrict, scriptArgs });
80
97
  break;
98
+ }
81
99
  case 'build':
82
- buildProject(args.slice(1));
100
+ await buildProject(args.slice(1));
101
+ break;
102
+ case 'check':
103
+ await checkProject(args.slice(1));
104
+ break;
105
+ case 'clean':
106
+ cleanBuild(args.slice(1));
83
107
  break;
84
108
  case 'dev':
85
- devServer(args.slice(1));
109
+ await devServer(args.slice(1));
86
110
  break;
87
111
  case 'repl':
88
112
  await startRepl();
@@ -93,6 +117,9 @@ async function main() {
93
117
  case 'new':
94
118
  newProject(args[1]);
95
119
  break;
120
+ case 'init':
121
+ initProject();
122
+ break;
96
123
  case 'install':
97
124
  await installDeps();
98
125
  break;
@@ -108,18 +135,62 @@ async function main() {
108
135
  case 'test':
109
136
  await runTests(args.slice(1));
110
137
  break;
138
+ case 'bench':
139
+ await runBench(args.slice(1));
140
+ break;
141
+ case 'doc':
142
+ await generateDocs(args.slice(1));
143
+ break;
111
144
  case 'migrate:create':
112
145
  migrateCreate(args[1]);
113
146
  break;
114
147
  case 'migrate:up':
115
148
  await migrateUp(args.slice(1));
116
149
  break;
150
+ case 'migrate:down':
151
+ await migrateDown(args.slice(1));
152
+ break;
153
+ case 'migrate:reset':
154
+ await migrateReset(args.slice(1));
155
+ break;
156
+ case 'migrate:fresh':
157
+ await migrateFresh(args.slice(1));
158
+ break;
117
159
  case 'migrate:status':
118
160
  await migrateStatus(args.slice(1));
119
161
  break;
162
+ case 'explain': {
163
+ const code = args[1];
164
+ if (!code) {
165
+ console.error('Usage: tova explain <error-code> (e.g., tova explain E202)');
166
+ process.exit(1);
167
+ }
168
+ const info = lookupCode(code);
169
+ if (!info) {
170
+ console.error(`Unknown error code: ${code}`);
171
+ process.exit(1);
172
+ }
173
+ const explanation = getExplanation(code);
174
+ console.log(`\n ${code}: ${info.title} [${info.category}]\n`);
175
+ if (explanation) {
176
+ console.log(explanation);
177
+ } else {
178
+ console.log(` No detailed explanation available yet for ${code}.\n`);
179
+ }
180
+ break;
181
+ }
182
+ case 'upgrade':
183
+ await upgradeCommand();
184
+ break;
185
+ case 'info':
186
+ await infoCommand();
187
+ break;
120
188
  default:
121
189
  if (command.endsWith('.tova')) {
122
- await runFile(command, { strict: isStrict });
190
+ const directArgs = args.filter(a => a !== '--strict').slice(1);
191
+ const ddIdx = directArgs.indexOf('--');
192
+ const scriptArgs = ddIdx !== -1 ? directArgs.slice(ddIdx + 1) : directArgs;
193
+ await runFile(command, { strict: isStrict, scriptArgs });
123
194
  } else {
124
195
  console.error(`Unknown command: ${command}`);
125
196
  console.log(HELP);
@@ -143,7 +214,7 @@ function compileTova(source, filename, options = {}) {
143
214
  if (warnings.length > 0) {
144
215
  const formatter = new DiagnosticFormatter(source, filename);
145
216
  for (const w of warnings) {
146
- console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }));
217
+ console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
147
218
  }
148
219
  }
149
220
 
@@ -205,15 +276,23 @@ function formatFile(args) {
205
276
  async function runTests(args) {
206
277
  const filterPattern = args.find((a, i) => args[i - 1] === '--filter') || null;
207
278
  const watchMode = args.includes('--watch');
279
+ const coverageMode = args.includes('--coverage');
280
+ const serialMode = args.includes('--serial');
208
281
  const targetDir = args.find(a => !a.startsWith('--') && a !== filterPattern) || '.';
209
282
 
210
- // Find all .tova files with test blocks
283
+ // Find all .tova files with test blocks + dedicated test files (*.test.tova, *_test.tova)
211
284
  const tovaFiles = findTovaFiles(resolve(targetDir));
212
285
  const testFiles = [];
213
286
 
214
287
  for (const file of tovaFiles) {
288
+ const base = basename(file);
289
+ // Dedicated test files are always included
290
+ if (base.endsWith('.test.tova') || base.endsWith('_test.tova')) {
291
+ testFiles.push(file);
292
+ continue;
293
+ }
215
294
  const source = readFileSync(file, 'utf-8');
216
- // Quick check for test blocks
295
+ // Quick check for inline test blocks
217
296
  if (/\btest\s+["'{]/m.test(source) || /\btest\s*\{/m.test(source)) {
218
297
  testFiles.push(file);
219
298
  }
@@ -235,7 +314,17 @@ async function runTests(args) {
235
314
  for (const file of testFiles) {
236
315
  try {
237
316
  const source = readFileSync(file, 'utf-8');
238
- const lexer = new Lexer(source, file);
317
+ const base = basename(file);
318
+ const isDedicatedTestFile = base.endsWith('.test.tova') || base.endsWith('_test.tova');
319
+
320
+ // For dedicated test files without explicit test blocks, wrap entire file in one
321
+ let sourceToCompile = source;
322
+ if (isDedicatedTestFile && !/\btest\s+["'{]/m.test(source) && !/\btest\s*\{/m.test(source)) {
323
+ const testName = base.replace(/\.(test|_test)\.tova$/, '').replace(/_test\.tova$/, '');
324
+ sourceToCompile = `test "${testName}" {\n${source}\n}`;
325
+ }
326
+
327
+ const lexer = new Lexer(sourceToCompile, file);
239
328
  const tokens = lexer.tokenize();
240
329
  const parser = new Parser(tokens, file);
241
330
  const ast = parser.parse();
@@ -273,6 +362,13 @@ async function runTests(args) {
273
362
  if (filterPattern) {
274
363
  bunArgs.push('-t', filterPattern);
275
364
  }
365
+ if (coverageMode) {
366
+ bunArgs.push('--coverage');
367
+ }
368
+ if (serialMode) {
369
+ // Force sequential execution (bun runs files in parallel by default)
370
+ bunArgs.push('--concurrency', '1');
371
+ }
276
372
 
277
373
  const runBunTest = () => {
278
374
  return new Promise((res) => {
@@ -299,6 +395,113 @@ async function runTests(args) {
299
395
  }
300
396
  }
301
397
 
398
+ async function runBench(args) {
399
+ const targetDir = args.find(a => !a.startsWith('--')) || '.';
400
+ const tovaFiles = findTovaFiles(resolve(targetDir));
401
+ const benchFiles = [];
402
+
403
+ for (const file of tovaFiles) {
404
+ const source = readFileSync(file, 'utf-8');
405
+ if (/\bbench\s+["'{]/m.test(source) || /\bbench\s*\{/m.test(source)) {
406
+ benchFiles.push(file);
407
+ }
408
+ }
409
+
410
+ if (benchFiles.length === 0) {
411
+ console.log('No bench files found.');
412
+ return;
413
+ }
414
+
415
+ console.log(`Found ${benchFiles.length} bench file(s)\n`);
416
+
417
+ const tmpDir = resolve('.tova-bench-out');
418
+ if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true });
419
+
420
+ for (const file of benchFiles) {
421
+ try {
422
+ const source = readFileSync(file, 'utf-8');
423
+ const lexer = new Lexer(source, file);
424
+ const tokens = lexer.tokenize();
425
+ const parser = new Parser(tokens, file);
426
+ const ast = parser.parse();
427
+
428
+ const codegen = new CodeGenerator(ast, file);
429
+ const result = codegen.generate();
430
+
431
+ if (result.bench) {
432
+ const relPath = relative(resolve(targetDir), file).replace(/\.tova$/, '.bench.js');
433
+ const outPath = join(tmpDir, relPath);
434
+ const outDir = dirname(outPath);
435
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
436
+
437
+ const stdlib = getFullStdlib();
438
+ const fullBench = stdlib + '\n' + result.bench;
439
+ writeFileSync(outPath, fullBench);
440
+ console.log(` Compiled: ${relative('.', file)}`);
441
+
442
+ // Run the bench file
443
+ console.log('');
444
+ const proc = spawn('bun', ['run', outPath], { stdio: 'inherit' });
445
+ await new Promise(res => proc.on('close', res));
446
+ console.log('');
447
+ }
448
+ } catch (err) {
449
+ console.error(` Error compiling ${relative('.', file)}: ${err.message}`);
450
+ }
451
+ }
452
+ }
453
+
454
+ async function generateDocs(args) {
455
+ const { DocGenerator } = await import('../src/docs/generator.js');
456
+ const outputDir = args.find((a, i) => args[i - 1] === '--output' || args[i - 1] === '-o') || 'docs-out';
457
+ const format = args.find((a, i) => args[i - 1] === '--format') || 'html';
458
+ const targetDir = args.find(a => !a.startsWith('--') && a !== outputDir && a !== format) || '.';
459
+
460
+ const tovaFiles = findTovaFiles(resolve(targetDir));
461
+ if (tovaFiles.length === 0) {
462
+ console.log('No .tova files found.');
463
+ return;
464
+ }
465
+
466
+ const modules = [];
467
+ for (const file of tovaFiles) {
468
+ try {
469
+ const source = readFileSync(file, 'utf-8');
470
+ // Quick check: skip files without docstrings
471
+ if (!source.includes('///')) continue;
472
+
473
+ const lexer = new Lexer(source, file);
474
+ const tokens = lexer.tokenize();
475
+ const parser = new Parser(tokens, file);
476
+ const ast = parser.parse();
477
+ const name = relative(resolve(targetDir), file).replace(/\.tova$/, '').replace(/[/\\]/g, '.');
478
+ modules.push({ name, ast });
479
+ } catch (err) {
480
+ console.error(` Error parsing ${relative('.', file)}: ${err.message}`);
481
+ }
482
+ }
483
+
484
+ if (modules.length === 0) {
485
+ console.log('No documented .tova files found (add /// docstrings to your code).');
486
+ return;
487
+ }
488
+
489
+ const generator = new DocGenerator(modules);
490
+ const pages = generator.generate(format);
491
+
492
+ const outDir = resolve(outputDir);
493
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
494
+
495
+ let count = 0;
496
+ for (const [filename, content] of Object.entries(pages)) {
497
+ const outPath = join(outDir, filename);
498
+ writeFileSync(outPath, content);
499
+ count++;
500
+ }
501
+
502
+ console.log(`Generated ${count} documentation file(s) in ${relative('.', outDir)}/`);
503
+ }
504
+
302
505
  function findTovaFiles(dir) {
303
506
  const files = [];
304
507
  if (!existsSync(dir) || !statSync(dir).isDirectory()) return files;
@@ -347,16 +550,48 @@ async function runFile(filePath, options = {}) {
347
550
  const source = readFileSync(resolved, 'utf-8');
348
551
 
349
552
  try {
553
+ // Check if file has .tova imports — if so, compile dependencies and inline them
554
+ const hasTovaImports = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.tova['"]/m.test(source);
555
+
350
556
  const output = compileTova(source, filePath, { strict: options.strict });
351
557
 
352
558
  // Execute the generated JavaScript (with stdlib)
353
559
  const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
354
560
  const stdlib = getRunStdlib();
355
- let code = stdlib + '\n' + (output.shared || '') + '\n' + (output.server || output.client || '');
561
+
562
+ // Compile .tova dependencies and inline them
563
+ let depCode = '';
564
+ if (hasTovaImports) {
565
+ const importRegex = /import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"]([^'"]*\.tova)['"]/gm;
566
+ let match;
567
+ const compiled = new Set();
568
+ while ((match = importRegex.exec(source)) !== null) {
569
+ const depPath = resolve(dirname(resolved), match[1]);
570
+ if (compiled.has(depPath)) continue;
571
+ compiled.add(depPath);
572
+ if (existsSync(depPath)) {
573
+ const depSource = readFileSync(depPath, 'utf-8');
574
+ const dep = compileTova(depSource, depPath, { strict: options.strict });
575
+ let depShared = dep.shared || '';
576
+ // Strip export keywords for inlining
577
+ depShared = depShared.replace(/^export /gm, '');
578
+ depCode += depShared + '\n';
579
+ }
580
+ }
581
+ }
582
+
583
+ let code = stdlib + '\n' + depCode + (output.shared || '') + '\n' + (output.server || output.client || '');
356
584
  // Strip 'export ' keywords — not valid inside AsyncFunction (used in tova build only)
357
585
  code = code.replace(/^export /gm, '');
358
- const fn = new AsyncFunction(code);
359
- await fn();
586
+ // Strip import lines for .tova files (already inlined above)
587
+ code = code.replace(/^import\s+(?:\{[^}]*\}|[\w$]+|\*\s+as\s+[\w$]+)\s+from\s+['"][^'"]*\.(?:tova|(?:shared\.)?js)['"];?\s*$/gm, '');
588
+ // Auto-call main() if the compiled code defines a main function
589
+ const scriptArgs = options.scriptArgs || [];
590
+ if (/\bfunction\s+main\s*\(/.test(code)) {
591
+ code += '\nconst __tova_exit = await main(__tova_args); if (typeof __tova_exit === "number") process.exitCode = __tova_exit;\n';
592
+ }
593
+ const fn = new AsyncFunction('__tova_args', '__tova_filename', '__tova_dirname', code);
594
+ await fn(scriptArgs, resolved, dirname(resolved));
360
595
  } catch (err) {
361
596
  console.error(richError(source, err, filePath));
362
597
  if (process.argv.includes('--debug') || process.env.DEBUG) {
@@ -372,6 +607,9 @@ async function buildProject(args) {
372
607
  const config = resolveConfig(process.cwd());
373
608
  const isProduction = args.includes('--production');
374
609
  const buildStrict = args.includes('--strict');
610
+ const isVerbose = args.includes('--verbose');
611
+ const isQuiet = args.includes('--quiet');
612
+ const isWatch = args.includes('--watch');
375
613
  const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
376
614
  const srcDir = resolve(explicitSrc || config.project.entry || '.');
377
615
  const outIdx = args.indexOf('--output');
@@ -397,18 +635,56 @@ async function buildProject(args) {
397
635
  writeFileSync(join(runtimeDest, 'rpc.js'), RPC_SOURCE);
398
636
  writeFileSync(join(runtimeDest, 'router.js'), ROUTER_SOURCE);
399
637
 
400
- console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
638
+ if (!isQuiet) console.log(`\n Building ${tovaFiles.length} file(s)...\n`);
401
639
 
402
640
  let errorCount = 0;
641
+ const buildStart = Date.now();
403
642
  compilationCache.clear();
404
643
 
644
+ // Load incremental build cache
645
+ const noCache = args.includes('--no-cache');
646
+ const buildCache = new BuildCache(join(outDir, '.cache'));
647
+ if (!noCache) buildCache.load();
648
+ let skippedCount = 0;
649
+
405
650
  // Group files by directory for multi-file merging
406
651
  const dirGroups = groupFilesByDirectory(tovaFiles);
407
652
 
408
653
  for (const [dir, files] of dirGroups) {
409
654
  const dirName = basename(dir) === '.' ? 'app' : basename(dir);
410
655
  const relDir = relative(srcDir, dir) || '.';
656
+ const groupStart = Date.now();
411
657
  try {
658
+ // Check incremental cache: skip if all files in this group are unchanged
659
+ if (!noCache) {
660
+ if (files.length === 1) {
661
+ const absFile = files[0];
662
+ const sourceContent = readFileSync(absFile, 'utf-8');
663
+ if (buildCache.isUpToDate(absFile, sourceContent)) {
664
+ const cached = buildCache.getCached(absFile);
665
+ if (cached) {
666
+ skippedCount++;
667
+ if (isVerbose && !isQuiet) {
668
+ console.log(` ○ ${relative(srcDir, absFile)} (cached)`);
669
+ }
670
+ continue;
671
+ }
672
+ }
673
+ } else {
674
+ const dirKey = `dir:${dir}`;
675
+ if (buildCache.isGroupUpToDate(dirKey, files)) {
676
+ const cached = buildCache.getCached(dirKey);
677
+ if (cached) {
678
+ skippedCount++;
679
+ if (isVerbose && !isQuiet) {
680
+ console.log(` ○ ${relative(srcDir, dir)}/ (${files.length} files, cached)`);
681
+ }
682
+ continue;
683
+ }
684
+ }
685
+ }
686
+ }
687
+
412
688
  const result = mergeDirectory(dir, srcDir, { strict: buildStrict });
413
689
  if (!result) continue;
414
690
 
@@ -416,6 +692,8 @@ async function buildProject(args) {
416
692
  // Use single-file basename for single-file dirs, directory name for multi-file
417
693
  const outBaseName = single ? basename(files[0], '.tova') : dirName;
418
694
  const relLabel = single ? relative(srcDir, files[0]) : `${relDir}/ (${files.length} files merged)`;
695
+ const elapsed = Date.now() - groupStart;
696
+ const timing = isVerbose ? ` (${elapsed}ms)` : '';
419
697
 
420
698
  // Helper to generate source maps
421
699
  const generateSourceMap = (code, jsFile) => {
@@ -433,44 +711,80 @@ async function buildProject(args) {
433
711
  return code;
434
712
  };
435
713
 
436
- // Write shared
437
- if (output.shared && output.shared.trim()) {
438
- const sharedPath = join(outDir, `${outBaseName}.shared.js`);
439
- writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
440
- console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}`);
441
- }
714
+ // Module files: write single <name>.js (not .shared.js)
715
+ if (output.isModule) {
716
+ if (output.shared && output.shared.trim()) {
717
+ const modulePath = join(outDir, `${outBaseName}.js`);
718
+ writeFileSync(modulePath, generateSourceMap(output.shared, modulePath));
719
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', modulePath)}${timing}`);
720
+ }
721
+ // Update incremental build cache
722
+ if (!noCache) {
723
+ const outputPaths = {};
724
+ if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.js`);
725
+ if (single) {
726
+ const absFile = files[0];
727
+ const sourceContent = readFileSync(absFile, 'utf-8');
728
+ buildCache.set(absFile, sourceContent, outputPaths);
729
+ } else {
730
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
731
+ }
732
+ }
733
+ } else {
734
+ // Write shared
735
+ if (output.shared && output.shared.trim()) {
736
+ const sharedPath = join(outDir, `${outBaseName}.shared.js`);
737
+ writeFileSync(sharedPath, generateSourceMap(output.shared, sharedPath));
738
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', sharedPath)}${timing}`);
739
+ }
442
740
 
443
- // Write default server
444
- if (output.server) {
445
- const serverPath = join(outDir, `${outBaseName}.server.js`);
446
- writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
447
- console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}`);
448
- }
741
+ // Write default server
742
+ if (output.server) {
743
+ const serverPath = join(outDir, `${outBaseName}.server.js`);
744
+ writeFileSync(serverPath, generateSourceMap(output.server, serverPath));
745
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', serverPath)}${timing}`);
746
+ }
449
747
 
450
- // Write default client
451
- if (output.client) {
452
- const clientPath = join(outDir, `${outBaseName}.client.js`);
453
- writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
454
- console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}`);
455
- }
456
-
457
- // Write named server blocks (multi-block)
458
- if (output.multiBlock && output.servers) {
459
- for (const [name, code] of Object.entries(output.servers)) {
460
- if (name === 'default') continue;
461
- const path = join(outDir, `${outBaseName}.server.${name}.js`);
462
- writeFileSync(path, code);
463
- console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]`);
748
+ // Write default client
749
+ if (output.client) {
750
+ const clientPath = join(outDir, `${outBaseName}.client.js`);
751
+ writeFileSync(clientPath, generateSourceMap(output.client, clientPath));
752
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', clientPath)}${timing}`);
464
753
  }
465
- }
466
754
 
467
- // Write named client blocks (multi-block)
468
- if (output.multiBlock && output.clients) {
469
- for (const [name, code] of Object.entries(output.clients)) {
470
- if (name === 'default') continue;
471
- const path = join(outDir, `${outBaseName}.client.${name}.js`);
472
- writeFileSync(path, code);
473
- console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]`);
755
+ // Write named server blocks (multi-block)
756
+ if (output.multiBlock && output.servers) {
757
+ for (const [name, code] of Object.entries(output.servers)) {
758
+ if (name === 'default') continue;
759
+ const path = join(outDir, `${outBaseName}.server.${name}.js`);
760
+ writeFileSync(path, code);
761
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [server:${name}]${timing}`);
762
+ }
763
+ }
764
+
765
+ // Write named client blocks (multi-block)
766
+ if (output.multiBlock && output.clients) {
767
+ for (const [name, code] of Object.entries(output.clients)) {
768
+ if (name === 'default') continue;
769
+ const path = join(outDir, `${outBaseName}.client.${name}.js`);
770
+ writeFileSync(path, code);
771
+ if (!isQuiet) console.log(` ✓ ${relLabel} → ${relative('.', path)} [client:${name}]${timing}`);
772
+ }
773
+ }
774
+
775
+ // Update incremental build cache
776
+ if (!noCache) {
777
+ const outputPaths = {};
778
+ if (output.shared && output.shared.trim()) outputPaths.shared = join(outDir, `${outBaseName}.shared.js`);
779
+ if (output.server) outputPaths.server = join(outDir, `${outBaseName}.server.js`);
780
+ if (output.client) outputPaths.client = join(outDir, `${outBaseName}.client.js`);
781
+ if (single) {
782
+ const absFile = files[0];
783
+ const sourceContent = readFileSync(absFile, 'utf-8');
784
+ buildCache.set(absFile, sourceContent, outputPaths);
785
+ } else {
786
+ buildCache.setGroup(`dir:${dir}`, files, outputPaths);
787
+ }
474
788
  }
475
789
  }
476
790
  } catch (err) {
@@ -479,9 +793,150 @@ async function buildProject(args) {
479
793
  }
480
794
  }
481
795
 
796
+ // Save incremental build cache and prune stale entries
797
+ if (!noCache) {
798
+ const dirKeys = [...dirGroups.keys()].map(d => `dir:${d}`);
799
+ buildCache.prune(tovaFiles, dirKeys);
800
+ buildCache.save();
801
+ }
802
+
482
803
  const dirCount = dirGroups.size;
483
- console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded.\n`);
804
+ const totalElapsed = Date.now() - buildStart;
805
+ if (!isQuiet) {
806
+ const timingStr = isVerbose ? ` in ${totalElapsed}ms` : '';
807
+ const cachedStr = skippedCount > 0 ? ` (${skippedCount} cached)` : '';
808
+ console.log(`\n Build complete. ${dirCount - errorCount}/${dirCount} directory group(s) succeeded${cachedStr}${timingStr}.\n`);
809
+ }
484
810
  if (errorCount > 0) process.exit(1);
811
+
812
+ // Watch mode for build command
813
+ if (isWatch) {
814
+ console.log(' Watching for changes...\n');
815
+ let debounceTimer = null;
816
+ const watcher = fsWatch(srcDir, { recursive: true }, (event, filename) => {
817
+ if (!filename || !filename.endsWith('.tova')) return;
818
+ if (debounceTimer) clearTimeout(debounceTimer);
819
+ debounceTimer = setTimeout(async () => {
820
+ compilationCache.clear();
821
+ if (!isQuiet) console.log(` Rebuilding (${filename} changed)...`);
822
+ await buildProject(args.filter(a => a !== '--watch'));
823
+ if (!isQuiet) console.log(' Watching for changes...\n');
824
+ }, 100);
825
+ });
826
+ // Keep process alive
827
+ await new Promise(() => {});
828
+ }
829
+ }
830
+
831
+ // ─── Check (type-check only, no codegen) ────────────────────
832
+
833
+ async function checkProject(args) {
834
+ const checkStrict = args.includes('--strict');
835
+ const isVerbose = args.includes('--verbose');
836
+ const isQuiet = args.includes('--quiet');
837
+
838
+ // --explain <code>: show explanation for a specific error code inline with check output
839
+ const explainIdx = args.indexOf('--explain');
840
+ const explainCode = explainIdx >= 0 ? args[explainIdx + 1] : null;
841
+ if (explainCode) {
842
+ // If --explain is used standalone, just show the explanation
843
+ const info = lookupCode(explainCode);
844
+ if (!info) {
845
+ console.error(`Unknown error code: ${explainCode}`);
846
+ process.exit(1);
847
+ }
848
+ const explanation = getExplanation(explainCode);
849
+ console.log(`\n ${explainCode}: ${info.title} [${info.category}]\n`);
850
+ if (explanation) {
851
+ console.log(explanation);
852
+ } else {
853
+ console.log(` No detailed explanation available yet for ${explainCode}.\n`);
854
+ }
855
+ process.exit(0);
856
+ }
857
+
858
+ const explicitSrc = args.filter(a => !a.startsWith('--'))[0];
859
+ const srcDir = resolve(explicitSrc || '.');
860
+
861
+ const tovaFiles = findFiles(srcDir, '.tova');
862
+ if (tovaFiles.length === 0) {
863
+ console.error('No .tova files found');
864
+ process.exit(1);
865
+ }
866
+
867
+ let totalErrors = 0;
868
+ let totalWarnings = 0;
869
+ const seenCodes = new Set();
870
+
871
+ for (const file of tovaFiles) {
872
+ const relPath = relative(srcDir, file);
873
+ const start = Date.now();
874
+ try {
875
+ const source = readFileSync(file, 'utf-8');
876
+ const lexer = new Lexer(source, file);
877
+ const tokens = lexer.tokenize();
878
+ const parser = new Parser(tokens, file);
879
+ const ast = parser.parse();
880
+ const analyzer = new Analyzer(ast, file, { strict: checkStrict, tolerant: true });
881
+ const result = analyzer.analyze();
882
+
883
+ const errors = result.errors || [];
884
+ const warnings = result.warnings || [];
885
+ totalErrors += errors.length;
886
+ totalWarnings += warnings.length;
887
+
888
+ if (errors.length > 0 || warnings.length > 0) {
889
+ const formatter = new DiagnosticFormatter(source, file);
890
+ for (const e of errors) {
891
+ console.error(formatter.formatError(e.message, { line: e.line, column: e.column }, { hint: e.hint, code: e.code, length: e.length, fix: e.fix }));
892
+ if (e.code) seenCodes.add(e.code);
893
+ }
894
+ for (const w of warnings) {
895
+ console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
896
+ if (w.code) seenCodes.add(w.code);
897
+ }
898
+ }
899
+
900
+ if (isVerbose) {
901
+ const elapsed = Date.now() - start;
902
+ console.log(` ✓ ${relPath} (${elapsed}ms)`);
903
+ }
904
+ } catch (err) {
905
+ totalErrors++;
906
+ if (err.errors) {
907
+ const source = readFileSync(file, 'utf-8');
908
+ console.error(richError(source, err, file));
909
+ } else {
910
+ console.error(` ✗ ${relPath}: ${err.message}`);
911
+ }
912
+ }
913
+ }
914
+
915
+ if (!isQuiet) {
916
+ console.log(`\n ${tovaFiles.length} file${tovaFiles.length === 1 ? '' : 's'} checked, ${formatSummary(totalErrors, totalWarnings)}`);
917
+ // Show explain hint for encountered error codes
918
+ if (seenCodes.size > 0 && (totalErrors > 0 || totalWarnings > 0)) {
919
+ const codes = [...seenCodes].sort().slice(0, 5).join(', ');
920
+ const more = seenCodes.size > 5 ? ` and ${seenCodes.size - 5} more` : '';
921
+ console.log(`\n Run \`tova explain <code>\` for details on: ${codes}${more}`);
922
+ }
923
+ console.log('');
924
+ }
925
+ if (totalErrors > 0) process.exit(1);
926
+ }
927
+
928
+ // ─── Clean (delete build artifacts) ─────────────────────────
929
+
930
+ function cleanBuild(args) {
931
+ const config = resolveConfig(process.cwd());
932
+ const outDir = resolve(config.build?.output || '.tova-out');
933
+
934
+ if (existsSync(outDir)) {
935
+ rmSync(outDir, { recursive: true, force: true });
936
+ console.log(` Cleaned ${relative('.', outDir)}/`);
937
+ } else {
938
+ console.log(` Nothing to clean (${relative('.', outDir)}/ does not exist)`);
939
+ }
485
940
  }
486
941
 
487
942
  // ─── Dev Server ─────────────────────────────────────────────
@@ -1025,6 +1480,98 @@ tova add prettier --dev
1025
1480
  console.log(` tova dev\n`);
1026
1481
  }
1027
1482
 
1483
+ // ─── Init (in-place) ────────────────────────────────────────
1484
+
1485
+ function initProject() {
1486
+ const projectDir = process.cwd();
1487
+ const name = basename(projectDir);
1488
+
1489
+ if (existsSync(join(projectDir, 'tova.toml'))) {
1490
+ console.error('Error: tova.toml already exists in this directory');
1491
+ process.exit(1);
1492
+ }
1493
+
1494
+ console.log(`\n Initializing Tova project: ${name}\n`);
1495
+
1496
+ // tova.toml
1497
+ const tomlContent = stringifyTOML({
1498
+ project: {
1499
+ name,
1500
+ version: '0.1.0',
1501
+ description: '',
1502
+ entry: 'src',
1503
+ },
1504
+ build: {
1505
+ output: '.tova-out',
1506
+ },
1507
+ dev: {
1508
+ port: 3000,
1509
+ },
1510
+ dependencies: {},
1511
+ npm: {},
1512
+ });
1513
+ writeFileSync(join(projectDir, 'tova.toml'), tomlContent);
1514
+ console.log(' ✓ Created tova.toml');
1515
+
1516
+ // src/ directory
1517
+ mkdirSync(join(projectDir, 'src'), { recursive: true });
1518
+
1519
+ // .gitignore (only if missing)
1520
+ const gitignorePath = join(projectDir, '.gitignore');
1521
+ if (!existsSync(gitignorePath)) {
1522
+ writeFileSync(gitignorePath, `node_modules/
1523
+ .tova-out/
1524
+ package.json
1525
+ bun.lock
1526
+ *.db
1527
+ *.db-shm
1528
+ *.db-wal
1529
+ `);
1530
+ console.log(' ✓ Created .gitignore');
1531
+ }
1532
+
1533
+ // Starter app.tova (only if src/ has no .tova files)
1534
+ const srcDir = join(projectDir, 'src');
1535
+ const existingTova = existsSync(srcDir) ? readdirSync(srcDir).filter(f => f.endsWith('.tova')) : [];
1536
+ if (existingTova.length === 0) {
1537
+ writeFileSync(join(srcDir, 'app.tova'), `// ${name} — Built with Tova
1538
+
1539
+ shared {
1540
+ type Message {
1541
+ text: String
1542
+ }
1543
+ }
1544
+
1545
+ server {
1546
+ fn get_message() -> Message {
1547
+ Message("Hello from Tova!")
1548
+ }
1549
+
1550
+ route GET "/api/message" => get_message
1551
+ }
1552
+
1553
+ client {
1554
+ state message = ""
1555
+
1556
+ effect {
1557
+ result = server.get_message()
1558
+ message = result.text
1559
+ }
1560
+
1561
+ component App {
1562
+ <div class="app">
1563
+ <h1>"{message}"</h1>
1564
+ <p>"Edit src/app.tova to get started."</p>
1565
+ </div>
1566
+ }
1567
+ }
1568
+ `);
1569
+ console.log(' ✓ Created src/app.tova');
1570
+ }
1571
+
1572
+ console.log(`\n Project initialized. Run 'tova dev' to start.\n`);
1573
+ }
1574
+
1028
1575
  // ─── Package Management ─────────────────────────────────────
1029
1576
 
1030
1577
  async function installDeps() {
@@ -1059,6 +1606,8 @@ async function addDep(args) {
1059
1606
  if (!pkg) {
1060
1607
  console.error('Error: No package specified');
1061
1608
  console.error('Usage: tova add <package> [--dev]');
1609
+ console.error(' tova add npm:<package> — add an npm package');
1610
+ console.error(' tova add <tova-package> — add a Tova package (local path or git URL)');
1062
1611
  process.exit(1);
1063
1612
  }
1064
1613
 
@@ -1071,44 +1620,100 @@ async function addDep(args) {
1071
1620
  process.exit(1);
1072
1621
  }
1073
1622
 
1074
- // Parse package name and version
1075
- let name = pkg;
1076
- let version = 'latest';
1077
- if (pkg.includes('@') && !pkg.startsWith('@')) {
1078
- const atIdx = pkg.lastIndexOf('@');
1079
- name = pkg.slice(0, atIdx);
1080
- version = pkg.slice(atIdx + 1);
1081
- } else if (pkg.startsWith('@') && pkg.includes('@', 1)) {
1082
- // Scoped package with version: @scope/name@version
1083
- const atIdx = pkg.lastIndexOf('@');
1084
- name = pkg.slice(0, atIdx);
1085
- version = pkg.slice(atIdx + 1);
1623
+ // Determine if this is an npm package or a Tova native dependency
1624
+ const isNpm = pkg.startsWith('npm:');
1625
+ const actualPkg = isNpm ? pkg.slice(4) : pkg;
1626
+
1627
+ if (isNpm) {
1628
+ // npm package handling (existing behavior)
1629
+ let name = actualPkg;
1630
+ let version = 'latest';
1631
+ if (actualPkg.includes('@') && !actualPkg.startsWith('@')) {
1632
+ const atIdx = actualPkg.lastIndexOf('@');
1633
+ name = actualPkg.slice(0, atIdx);
1634
+ version = actualPkg.slice(atIdx + 1);
1635
+ } else if (actualPkg.startsWith('@') && actualPkg.includes('@', 1)) {
1636
+ const atIdx = actualPkg.lastIndexOf('@');
1637
+ name = actualPkg.slice(0, atIdx);
1638
+ version = actualPkg.slice(atIdx + 1);
1639
+ }
1640
+
1641
+ if (version === 'latest') {
1642
+ try {
1643
+ const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
1644
+ let out = '';
1645
+ proc.stdout.on('data', d => out += d);
1646
+ const code = await new Promise(res => proc.on('close', res));
1647
+ if (code === 0 && out.trim()) {
1648
+ version = `^${out.trim()}`;
1649
+ } else {
1650
+ version = '*';
1651
+ }
1652
+ } catch {
1653
+ version = '*';
1654
+ }
1655
+ }
1656
+
1657
+ const section = isDev ? 'npm.dev' : 'npm';
1658
+ addToSection(tomlPath, section, name, version);
1659
+ console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
1660
+ await installDeps();
1661
+ } else {
1662
+ // Tova native dependency
1663
+ let name = actualPkg;
1664
+ let source = actualPkg;
1665
+
1666
+ // Detect source type
1667
+ if (actualPkg.startsWith('file:') || actualPkg.startsWith('./') || actualPkg.startsWith('../') || actualPkg.startsWith('/')) {
1668
+ // Local path dependency
1669
+ source = actualPkg.startsWith('file:') ? actualPkg : `file:${actualPkg}`;
1670
+ name = basename(actualPkg.replace(/^file:/, ''));
1671
+ } else if (actualPkg.startsWith('git:') || actualPkg.includes('github.com/') || actualPkg.includes('.git')) {
1672
+ // Git dependency
1673
+ source = actualPkg.startsWith('git:') ? actualPkg : `git:${actualPkg}`;
1674
+ name = basename(actualPkg.replace(/\.git$/, '').replace(/^git:/, ''));
1675
+ } else {
1676
+ // Tova registry package (future: for now, just store the name)
1677
+ source = `*`;
1678
+ }
1679
+
1680
+ addToSection(tomlPath, 'dependencies', name, source);
1681
+ console.log(` Added ${name} = "${source}" to [dependencies] in tova.toml`);
1682
+
1683
+ // Generate lock file
1684
+ generateLockFile(cwd);
1685
+ }
1686
+ }
1687
+
1688
+ function generateLockFile(cwd) {
1689
+ const config = resolveConfig(cwd);
1690
+ const deps = config.dependencies || {};
1691
+ const npmProd = config.npm?.prod || {};
1692
+ const npmDev = config.npm?.dev || {};
1693
+
1694
+ const lock = {
1695
+ version: 1,
1696
+ generated: new Date().toISOString(),
1697
+ dependencies: {},
1698
+ npm: {},
1699
+ };
1700
+
1701
+ for (const [name, source] of Object.entries(deps)) {
1702
+ lock.dependencies[name] = {
1703
+ source,
1704
+ resolved: source, // For now, resolved = source
1705
+ };
1086
1706
  }
1087
1707
 
1088
- // If version is 'latest', resolve it via npm registry
1089
- if (version === 'latest') {
1090
- try {
1091
- const proc = spawn('npm', ['view', name, 'version'], { stdio: ['pipe', 'pipe', 'pipe'] });
1092
- let out = '';
1093
- proc.stdout.on('data', d => out += d);
1094
- const code = await new Promise(res => proc.on('close', res));
1095
- if (code === 0 && out.trim()) {
1096
- version = `^${out.trim()}`;
1097
- } else {
1098
- version = '*';
1099
- }
1100
- } catch {
1101
- version = '*';
1102
- }
1708
+ for (const [name, version] of Object.entries(npmProd)) {
1709
+ lock.npm[name] = { version, dev: false };
1710
+ }
1711
+ for (const [name, version] of Object.entries(npmDev)) {
1712
+ lock.npm[name] = { version, dev: true };
1103
1713
  }
1104
1714
 
1105
- const section = isDev ? 'npm.dev' : 'npm';
1106
- addToSection(tomlPath, section, name, version);
1107
-
1108
- console.log(` Added ${name}@${version} to [${section}] in tova.toml`);
1109
-
1110
- // Run install
1111
- await installDeps();
1715
+ const lockPath = join(cwd, 'tova.lock');
1716
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
1112
1717
  }
1113
1718
 
1114
1719
  async function removeDep(pkg) {
@@ -1126,8 +1731,9 @@ async function removeDep(pkg) {
1126
1731
  process.exit(1);
1127
1732
  }
1128
1733
 
1129
- // Try removing from [npm] first, then [npm.dev]
1130
- const removed = removeFromSection(tomlPath, 'npm', pkg) ||
1734
+ // Try removing from [dependencies], [npm], or [npm.dev]
1735
+ const removed = removeFromSection(tomlPath, 'dependencies', pkg) ||
1736
+ removeFromSection(tomlPath, 'npm', pkg) ||
1131
1737
  removeFromSection(tomlPath, 'npm.dev', pkg);
1132
1738
 
1133
1739
  if (removed) {
@@ -1302,7 +1908,7 @@ async function migrateUp(args) {
1302
1908
  await db.exec(sql);
1303
1909
  }
1304
1910
  const ph = db.driver === 'postgres' ? '$1' : '?';
1305
- await db.exec(`INSERT INTO __migrations (name) VALUES ('${file.replace(/'/g, "''")}')`);
1911
+ await db.query(`INSERT INTO __migrations (name) VALUES (${ph})`, file);
1306
1912
  console.log(` ✓ ${file}`);
1307
1913
  }
1308
1914
 
@@ -1312,6 +1918,170 @@ async function migrateUp(args) {
1312
1918
  }
1313
1919
  }
1314
1920
 
1921
+ async function migrateDown(args) {
1922
+ const tovaFile = findTovaFile(args[0]);
1923
+ const cfg = discoverDbConfig(tovaFile);
1924
+ const db = await connectDb(cfg);
1925
+
1926
+ try {
1927
+ await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
1928
+ id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
1929
+ name TEXT NOT NULL UNIQUE,
1930
+ applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
1931
+ )`);
1932
+
1933
+ const applied = await db.query('SELECT name FROM __migrations ORDER BY name DESC');
1934
+ if (applied.length === 0) {
1935
+ console.log('\n No migrations to roll back.\n');
1936
+ return;
1937
+ }
1938
+
1939
+ const migrDir = resolve('migrations');
1940
+ const lastMigration = applied[0].name;
1941
+
1942
+ console.log(`\n Rolling back: ${lastMigration}...\n`);
1943
+
1944
+ const mod = await import(join(migrDir, lastMigration));
1945
+ if (!mod.down) {
1946
+ console.error(` Error: ${lastMigration} has no 'down' export — cannot roll back`);
1947
+ process.exit(1);
1948
+ }
1949
+
1950
+ const sql = mod.down.trim();
1951
+ if (sql) {
1952
+ await db.exec(sql);
1953
+ }
1954
+
1955
+ await db.exec(`DELETE FROM __migrations WHERE name = '${lastMigration}'`);
1956
+ console.log(` ✓ Rolled back: ${lastMigration}`);
1957
+ console.log(`\n Done.\n`);
1958
+ } finally {
1959
+ await db.close();
1960
+ }
1961
+ }
1962
+
1963
+ async function migrateReset(args) {
1964
+ const tovaFile = findTovaFile(args[0]);
1965
+ const cfg = discoverDbConfig(tovaFile);
1966
+ const db = await connectDb(cfg);
1967
+
1968
+ try {
1969
+ await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
1970
+ id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
1971
+ name TEXT NOT NULL UNIQUE,
1972
+ applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
1973
+ )`);
1974
+
1975
+ const applied = await db.query('SELECT name FROM __migrations ORDER BY name DESC');
1976
+ if (applied.length === 0) {
1977
+ console.log('\n No migrations to roll back.\n');
1978
+ return;
1979
+ }
1980
+
1981
+ const migrDir = resolve('migrations');
1982
+ console.log(`\n Rolling back ${applied.length} migration(s)...\n`);
1983
+
1984
+ for (const row of applied) {
1985
+ const file = row.name;
1986
+ try {
1987
+ const mod = await import(join(migrDir, file));
1988
+ if (mod.down) {
1989
+ const sql = mod.down.trim();
1990
+ if (sql) {
1991
+ await db.exec(sql);
1992
+ }
1993
+ } else {
1994
+ console.error(` ⚠ ${file} has no 'down' export — skipping rollback`);
1995
+ continue;
1996
+ }
1997
+ } catch (e) {
1998
+ console.error(` ⚠ Error rolling back ${file}: ${e.message}`);
1999
+ continue;
2000
+ }
2001
+ await db.exec(`DELETE FROM __migrations WHERE name = '${file}'`);
2002
+ console.log(` ✓ Rolled back: ${file}`);
2003
+ }
2004
+
2005
+ console.log(`\n Done. All migrations rolled back.\n`);
2006
+ } finally {
2007
+ await db.close();
2008
+ }
2009
+ }
2010
+
2011
+ async function migrateFresh(args) {
2012
+ const tovaFile = findTovaFile(args[0]);
2013
+ const cfg = discoverDbConfig(tovaFile);
2014
+ const db = await connectDb(cfg);
2015
+
2016
+ try {
2017
+ // Drop all tables
2018
+ console.log('\n Dropping all tables...\n');
2019
+ if (db.driver === 'sqlite') {
2020
+ const tables = await db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'");
2021
+ for (const t of tables) {
2022
+ await db.exec(`DROP TABLE IF EXISTS "${t.name}"`);
2023
+ console.log(` ✓ Dropped: ${t.name}`);
2024
+ }
2025
+ } else if (db.driver === 'postgres') {
2026
+ const tables = await db.query("SELECT tablename FROM pg_tables WHERE schemaname = 'public'");
2027
+ for (const t of tables) {
2028
+ await db.exec(`DROP TABLE IF EXISTS "${t.tablename}" CASCADE`);
2029
+ console.log(` ✓ Dropped: ${t.tablename}`);
2030
+ }
2031
+ } else if (db.driver === 'mysql') {
2032
+ const tables = await db.query("SHOW TABLES");
2033
+ for (const t of tables) {
2034
+ const tableName = Object.values(t)[0];
2035
+ await db.exec(`DROP TABLE IF EXISTS \`${tableName}\``);
2036
+ console.log(` ✓ Dropped: ${tableName}`);
2037
+ }
2038
+ }
2039
+
2040
+ // Re-create migrations table and run all migrations
2041
+ await db.exec(`CREATE TABLE IF NOT EXISTS __migrations (
2042
+ id INTEGER PRIMARY KEY ${db.driver === 'postgres' ? 'GENERATED ALWAYS AS IDENTITY' : 'AUTOINCREMENT'},
2043
+ name TEXT NOT NULL UNIQUE,
2044
+ applied_at TEXT DEFAULT (${db.driver === 'postgres' ? "NOW()::TEXT" : "datetime('now')"})
2045
+ )`);
2046
+
2047
+ const migrDir = resolve('migrations');
2048
+ if (!existsSync(migrDir)) {
2049
+ console.log(' No migrations directory found.\n');
2050
+ return;
2051
+ }
2052
+
2053
+ const files = readdirSync(migrDir)
2054
+ .filter(f => f.endsWith('.js'))
2055
+ .sort();
2056
+
2057
+ if (files.length === 0) {
2058
+ console.log(' No migration files found.\n');
2059
+ return;
2060
+ }
2061
+
2062
+ console.log(`\n Running ${files.length} migration(s)...\n`);
2063
+
2064
+ for (const file of files) {
2065
+ const mod = await import(join(migrDir, file));
2066
+ if (!mod.up) {
2067
+ console.error(` Skipping ${file}: no 'up' export`);
2068
+ continue;
2069
+ }
2070
+ const sql = mod.up.trim();
2071
+ if (sql) {
2072
+ await db.exec(sql);
2073
+ }
2074
+ const ph = db.driver === 'postgres' ? '$1' : '?';
2075
+ await db.query(`INSERT INTO __migrations (name) VALUES (${ph})`, file);
2076
+ console.log(` ✓ ${file}`);
2077
+ }
2078
+
2079
+ console.log(`\n Done. Fresh database with ${files.length} migration(s) applied.\n`);
2080
+ } finally {
2081
+ await db.close();
2082
+ }
2083
+ }
2084
+
1315
2085
  async function migrateStatus(args) {
1316
2086
  const tovaFile = findTovaFile(args[0]);
1317
2087
  const cfg = discoverDbConfig(tovaFile);
@@ -1440,14 +2210,147 @@ async function startLsp() {
1440
2210
 
1441
2211
  async function startRepl() {
1442
2212
  const readline = await import('readline');
2213
+
2214
+ // ─── ANSI color helpers ──────────────────────────
2215
+ const c = {
2216
+ reset: '\x1b[0m',
2217
+ keyword: '\x1b[35m', // magenta
2218
+ string: '\x1b[32m', // green
2219
+ number: '\x1b[33m', // yellow
2220
+ boolean: '\x1b[33m', // yellow
2221
+ comment: '\x1b[90m', // gray
2222
+ builtin: '\x1b[36m', // cyan
2223
+ type: '\x1b[34m', // blue
2224
+ nil: '\x1b[90m', // gray
2225
+ prompt: '\x1b[1;36m', // bold cyan
2226
+ result: '\x1b[90m', // gray
2227
+ typeHint: '\x1b[2;36m', // dim cyan
2228
+ };
2229
+
2230
+ const KEYWORDS = new Set([
2231
+ 'fn', 'let', 'var', 'if', 'elif', 'else', 'for', 'while', 'loop', 'when',
2232
+ 'in', 'return', 'match', 'type', 'import', 'from', 'and', 'or', 'not',
2233
+ 'try', 'catch', 'finally', 'break', 'continue', 'async', 'await',
2234
+ 'guard', 'interface', 'derive', 'pub', 'impl', 'trait', 'defer',
2235
+ 'yield', 'extern', 'is', 'with', 'as', 'export', 'server', 'client', 'shared',
2236
+ ]);
2237
+
2238
+ const TYPE_NAMES = new Set([
2239
+ 'Int', 'Float', 'String', 'Bool', 'Nil', 'Any', 'Result', 'Option',
2240
+ 'Function', 'List', 'Object', 'Promise',
2241
+ ]);
2242
+
2243
+ const RUNTIME_NAMES = new Set(['Ok', 'Err', 'Some', 'None', 'true', 'false', 'nil']);
2244
+
2245
+ // ─── Syntax highlighter ──────────────────────────
2246
+ function highlight(line) {
2247
+ let out = '';
2248
+ let i = 0;
2249
+ while (i < line.length) {
2250
+ // Comments
2251
+ if (line[i] === '/' && line[i + 1] === '/') {
2252
+ out += c.comment + line.slice(i) + c.reset;
2253
+ break;
2254
+ }
2255
+ // Strings
2256
+ if (line[i] === '"' || line[i] === "'" || line[i] === '`') {
2257
+ const quote = line[i];
2258
+ let j = i + 1;
2259
+ // Handle triple-quoted strings
2260
+ if (quote === '"' && line[j] === '"' && line[j + 1] === '"') {
2261
+ j += 2;
2262
+ while (j < line.length && !(line[j] === '"' && line[j + 1] === '"' && line[j + 2] === '"')) j++;
2263
+ if (j < line.length) j += 3;
2264
+ out += c.string + line.slice(i, j) + c.reset;
2265
+ i = j;
2266
+ continue;
2267
+ }
2268
+ while (j < line.length && line[j] !== quote) {
2269
+ if (line[j] === '\\') j++;
2270
+ j++;
2271
+ }
2272
+ if (j < line.length) j++;
2273
+ out += c.string + line.slice(i, j) + c.reset;
2274
+ i = j;
2275
+ continue;
2276
+ }
2277
+ // Numbers
2278
+ if (/[0-9]/.test(line[i]) && (i === 0 || !/[a-zA-Z_]/.test(line[i - 1]))) {
2279
+ let j = i;
2280
+ while (j < line.length && /[0-9._eExXbBoO]/.test(line[j])) j++;
2281
+ out += c.number + line.slice(i, j) + c.reset;
2282
+ i = j;
2283
+ continue;
2284
+ }
2285
+ // Identifiers and keywords
2286
+ if (/[a-zA-Z_]/.test(line[i])) {
2287
+ let j = i;
2288
+ while (j < line.length && /[a-zA-Z0-9_]/.test(line[j])) j++;
2289
+ const word = line.slice(i, j);
2290
+ if (KEYWORDS.has(word)) {
2291
+ out += c.keyword + word + c.reset;
2292
+ } else if (TYPE_NAMES.has(word)) {
2293
+ out += c.type + word + c.reset;
2294
+ } else if (RUNTIME_NAMES.has(word)) {
2295
+ out += c.boolean + word + c.reset;
2296
+ } else if (BUILTIN_NAMES.has(word)) {
2297
+ out += c.builtin + word + c.reset;
2298
+ } else {
2299
+ out += word;
2300
+ }
2301
+ i = j;
2302
+ continue;
2303
+ }
2304
+ out += line[i];
2305
+ i++;
2306
+ }
2307
+ return out;
2308
+ }
2309
+
2310
+ // ─── Tab completions ─────────────────────────────
2311
+ const completionWords = [
2312
+ ...KEYWORDS, ...TYPE_NAMES, ...RUNTIME_NAMES, ...BUILTIN_NAMES,
2313
+ ':quit', ':exit', ':help', ':clear', ':type',
2314
+ ];
2315
+ const userDefinedNames = new Set();
2316
+
2317
+ function completer(line) {
2318
+ const allWords = [...new Set([...completionWords, ...userDefinedNames])];
2319
+ // Find the last word being typed
2320
+ const match = line.match(/([a-zA-Z_:][a-zA-Z0-9_]*)$/);
2321
+ if (!match) return [[], line];
2322
+ const prefix = match[1];
2323
+ const hits = allWords.filter(w => w.startsWith(prefix));
2324
+ return [hits, prefix];
2325
+ }
2326
+
2327
+ // ─── Type display helper ─────────────────────────
2328
+ function inferType(val) {
2329
+ if (val === null || val === undefined) return 'Nil';
2330
+ if (Array.isArray(val)) {
2331
+ if (val.length === 0) return '[_]';
2332
+ const elemType = inferType(val[0]);
2333
+ return `[${elemType}]`;
2334
+ }
2335
+ if (val?.__tag) return val.__tag;
2336
+ if (typeof val === 'number') return Number.isInteger(val) ? 'Int' : 'Float';
2337
+ if (typeof val === 'string') return 'String';
2338
+ if (typeof val === 'boolean') return 'Bool';
2339
+ if (typeof val === 'function') return 'Function';
2340
+ if (typeof val === 'object') return 'Object';
2341
+ return 'Unknown';
2342
+ }
2343
+
1443
2344
  const rl = readline.createInterface({
1444
2345
  input: process.stdin,
1445
2346
  output: process.stdout,
1446
- prompt: 'tova> ',
2347
+ prompt: `${c.prompt}tova>${c.reset} `,
2348
+ completer,
1447
2349
  });
1448
2350
 
1449
2351
  console.log(`\n Tova REPL v${VERSION}`);
1450
- console.log(' Type expressions to evaluate. Use :quit to exit.\n');
2352
+ console.log(' Type expressions to evaluate. Use :quit to exit.');
2353
+ console.log(' Use _ to reference the last result. Tab for completions.\n');
1451
2354
 
1452
2355
  const context = {};
1453
2356
  const stdlib = getStdlibForRuntime();
@@ -1461,7 +2364,7 @@ async function startRepl() {
1461
2364
 
1462
2365
  rl.prompt();
1463
2366
 
1464
- rl.on('line', (line) => {
2367
+ rl.on('line', async (line) => {
1465
2368
  const trimmed = line.trim();
1466
2369
 
1467
2370
  if (trimmed === ':quit' || trimmed === ':exit' || trimmed === ':q') {
@@ -1473,7 +2376,29 @@ async function startRepl() {
1473
2376
  if (trimmed === ':help') {
1474
2377
  console.log(' :quit Exit the REPL');
1475
2378
  console.log(' :help Show this help');
1476
- console.log(' :clear Clear context\n');
2379
+ console.log(' :clear Clear context');
2380
+ console.log(' :type Show inferred type of expression');
2381
+ console.log(' _ Reference the last result');
2382
+ console.log(' Tab Autocomplete keywords, builtins, and variables\n');
2383
+ rl.prompt();
2384
+ return;
2385
+ }
2386
+
2387
+ if (trimmed.startsWith(':type ')) {
2388
+ const expr = trimmed.slice(6).trim();
2389
+ try {
2390
+ const output = compileTova(expr, '<repl>');
2391
+ const code = output.shared || '';
2392
+ const ctxKeys = Object.keys(context).filter(k => k !== '__mutable');
2393
+ const destructure = ctxKeys.length > 0 ? `const {${ctxKeys.join(',')}} = __ctx;\n` : '';
2394
+ // REPL context: evaluating user-provided Tova expressions (intentional dynamic eval)
2395
+ const fn = new Function('__ctx', `${destructure}return (${code.replace(/;$/, '')});`);
2396
+ const val = fn(context);
2397
+ const typeStr = val === null ? 'Nil' : Array.isArray(val) ? 'List' : val?.__tag ? val.__tag : typeof val === 'number' ? (Number.isInteger(val) ? 'Int' : 'Float') : typeof val === 'string' ? 'String' : typeof val === 'boolean' ? 'Bool' : typeof val === 'function' ? 'Function' : typeof val === 'object' ? 'Object' : 'Unknown';
2398
+ console.log(` ${expr} : ${typeStr}`);
2399
+ } catch (err) {
2400
+ console.error(` Error: ${err.message}`);
2401
+ }
1477
2402
  rl.prompt();
1478
2403
  return;
1479
2404
  }
@@ -1482,11 +2407,68 @@ async function startRepl() {
1482
2407
  for (const key of Object.keys(context)) delete context[key];
1483
2408
  delete context.__mutable;
1484
2409
  initFn.call(context);
2410
+ userDefinedNames.clear();
1485
2411
  console.log(' Context cleared.\n');
1486
2412
  rl.prompt();
1487
2413
  return;
1488
2414
  }
1489
2415
 
2416
+ // Handle import statements
2417
+ const tovaImportMatch = trimmed.match(/^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]*\.tova)['"]\s*$/);
2418
+ const npmImportNamedMatch = !tovaImportMatch && trimmed.match(/^import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]\s*$/);
2419
+ const npmImportDefaultMatch = !tovaImportMatch && !npmImportNamedMatch && trimmed.match(/^import\s+([\w$]+)\s+from\s+['"]([^'"]+)['"]\s*$/);
2420
+
2421
+ if (tovaImportMatch) {
2422
+ // .tova file import: compile and inject into context
2423
+ const names = tovaImportMatch[1].split(',').map(n => n.trim()).filter(Boolean);
2424
+ const modulePath = resolve(process.cwd(), tovaImportMatch[2]);
2425
+ try {
2426
+ if (!existsSync(modulePath)) {
2427
+ throw new Error(`Module not found: ${tovaImportMatch[2]}`);
2428
+ }
2429
+ const modSource = readFileSync(modulePath, 'utf-8');
2430
+ const modOutput = compileTova(modSource, modulePath);
2431
+ let modCode = (modOutput.shared || '');
2432
+ // Strip export keywords
2433
+ modCode = modCode.replace(/^export /gm, '');
2434
+ // REPL context: executing compiled Tova module code (intentional dynamic eval)
2435
+ const modFn = new Function('__ctx', modCode + '\n' + names.map(n => `__ctx.${n} = ${n};`).join('\n'));
2436
+ modFn(context);
2437
+ console.log(` Imported { ${names.join(', ')} } from ${tovaImportMatch[2]}`);
2438
+ } catch (err) {
2439
+ console.error(` Error: ${err.message}`);
2440
+ }
2441
+ rl.prompt();
2442
+ return;
2443
+ }
2444
+
2445
+ if (npmImportNamedMatch || npmImportDefaultMatch) {
2446
+ // npm/JS module import via dynamic import()
2447
+ const moduleName = (npmImportNamedMatch || npmImportDefaultMatch)[2];
2448
+ try {
2449
+ const mod = await import(moduleName);
2450
+ if (npmImportNamedMatch) {
2451
+ const names = npmImportNamedMatch[1].split(',').map(n => n.trim()).filter(Boolean);
2452
+ for (const name of names) {
2453
+ if (name in mod) {
2454
+ context[name] = mod[name];
2455
+ } else {
2456
+ console.error(` Warning: '${name}' not found in '${moduleName}'`);
2457
+ }
2458
+ }
2459
+ console.log(` Imported { ${names.join(', ')} } from ${moduleName}`);
2460
+ } else {
2461
+ const name = npmImportDefaultMatch[1];
2462
+ context[name] = mod.default || mod;
2463
+ console.log(` Imported ${name} from ${moduleName}`);
2464
+ }
2465
+ } catch (err) {
2466
+ console.error(` Error: ${err.message}`);
2467
+ }
2468
+ rl.prompt();
2469
+ return;
2470
+ }
2471
+
1490
2472
  buffer += (buffer ? '\n' : '') + line;
1491
2473
 
1492
2474
  // Track open braces for multi-line input (skip braces inside strings)
@@ -1504,7 +2486,7 @@ async function startRepl() {
1504
2486
  }
1505
2487
 
1506
2488
  if (braceDepth > 0) {
1507
- process.stdout.write('... ');
2489
+ process.stdout.write(`${c.prompt}...${c.reset} `);
1508
2490
  return;
1509
2491
  }
1510
2492
 
@@ -1518,10 +2500,11 @@ async function startRepl() {
1518
2500
  if (code.trim()) {
1519
2501
  // Extract function/const/let names from compiled code
1520
2502
  const declaredInCode = new Set();
1521
- for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
1522
- for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) declaredInCode.add(m[1]);
2503
+ for (const m of code.matchAll(/\bfunction\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
2504
+ for (const m of code.matchAll(/\bconst\s+([a-zA-Z_]\w*)/g)) { declaredInCode.add(m[1]); userDefinedNames.add(m[1]); }
1523
2505
  for (const m of code.matchAll(/\blet\s+([a-zA-Z_]\w*)/g)) {
1524
2506
  declaredInCode.add(m[1]);
2507
+ userDefinedNames.add(m[1]);
1525
2508
  // Track mutable variables for proper let destructuring
1526
2509
  if (!context.__mutable) context.__mutable = new Set();
1527
2510
  context.__mutable.add(m[1]);
@@ -1569,7 +2552,9 @@ async function startRepl() {
1569
2552
  const fn = new Function('__ctx', `${destructure}${evalCode}`);
1570
2553
  const result = fn(context);
1571
2554
  if (result !== undefined) {
1572
- console.log(' ', result);
2555
+ context._ = result; // Save as last result
2556
+ const typeStr = inferType(result);
2557
+ console.log(` ${result} ${c.typeHint}: ${typeStr}${c.reset}`);
1573
2558
  }
1574
2559
  } catch (e) {
1575
2560
  // If return-wrapping fails, fall back to plain execution
@@ -1804,11 +2789,14 @@ async function productionBuild(srcDir, outDir) {
1804
2789
  console.log(` index.html`);
1805
2790
  }
1806
2791
 
1807
- // Try to use Bun.build for minification if available
1808
- try {
1809
- const clientFiles = readdirSync(outDir).filter(f => f.startsWith('client.') && f.endsWith('.js'));
1810
- for (const f of clientFiles) {
1811
- const filePath = join(outDir, f);
2792
+ // Minify all JS bundles using Bun's built-in transpiler
2793
+ const jsFiles = readdirSync(outDir).filter(f => f.endsWith('.js') && !f.endsWith('.min.js'));
2794
+ let minified = 0;
2795
+ for (const f of jsFiles) {
2796
+ const filePath = join(outDir, f);
2797
+ const minPath = join(outDir, f.replace('.js', '.min.js'));
2798
+ try {
2799
+ // Use Bun.build for proper minification with tree-shaking
1812
2800
  const result = await Bun.build({
1813
2801
  entrypoints: [filePath],
1814
2802
  outdir: outDir,
@@ -1816,20 +2804,184 @@ async function productionBuild(srcDir, outDir) {
1816
2804
  naming: f.replace('.js', '.min.js'),
1817
2805
  });
1818
2806
  if (result.success) {
1819
- console.log(` ${f.replace('.js', '.min.js')} (minified)`);
2807
+ const originalSize = statSync(filePath).size;
2808
+ const minSize = statSync(minPath).size;
2809
+ const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
2810
+ console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
2811
+ minified++;
2812
+ }
2813
+ } catch {
2814
+ // Bun.build not available — fall back to simple whitespace stripping
2815
+ try {
2816
+ const source = readFileSync(filePath, 'utf-8');
2817
+ const stripped = _simpleMinify(source);
2818
+ writeFileSync(minPath, stripped);
2819
+ const originalSize = Buffer.byteLength(source);
2820
+ const minSize = Buffer.byteLength(stripped);
2821
+ const ratio = ((1 - minSize / originalSize) * 100).toFixed(0);
2822
+ console.log(` ${f.replace('.js', '.min.js')} (${_formatBytes(minSize)}, ${ratio}% smaller)`);
2823
+ minified++;
2824
+ } catch {
2825
+ // Skip files that can't be minified
1820
2826
  }
1821
2827
  }
1822
- } catch (e) {
1823
- // Bun.build not available, skip minification
2828
+ }
2829
+
2830
+ if (minified === 0 && jsFiles.length > 0) {
2831
+ console.log(' (minification skipped — Bun.build unavailable)');
1824
2832
  }
1825
2833
 
1826
2834
  console.log(`\n Production build complete.\n`);
1827
2835
  }
1828
2836
 
2837
+ // Simple minification fallback: strip comments and collapse whitespace
2838
+ function _simpleMinify(code) {
2839
+ // Strip single-line comments (but not URLs with //)
2840
+ let result = code.replace(/(?<![:"'])\/\/[^\n]*/g, '');
2841
+ // Strip multi-line comments
2842
+ result = result.replace(/\/\*[\s\S]*?\*\//g, '');
2843
+ // Collapse multiple blank lines
2844
+ result = result.replace(/\n{3,}/g, '\n\n');
2845
+ // Trim trailing whitespace from each line
2846
+ result = result.replace(/[ \t]+$/gm, '');
2847
+ return result.trim();
2848
+ }
2849
+
2850
+ function _formatBytes(bytes) {
2851
+ if (bytes < 1024) return `${bytes} B`;
2852
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2853
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2854
+ }
2855
+
2856
+ // ─── Incremental Build Cache ─────────────────────────────────
2857
+
2858
+ class BuildCache {
2859
+ constructor(cacheDir) {
2860
+ this._cacheDir = cacheDir;
2861
+ this._manifest = null; // { files: { [absPath]: { hash, outputs: {...} } } }
2862
+ }
2863
+
2864
+ _manifestPath() {
2865
+ return join(this._cacheDir, 'manifest.json');
2866
+ }
2867
+
2868
+ _hashContent(content) {
2869
+ return createHash('sha256').update(content).digest('hex').slice(0, 16);
2870
+ }
2871
+
2872
+ load() {
2873
+ try {
2874
+ if (existsSync(this._manifestPath())) {
2875
+ this._manifest = JSON.parse(readFileSync(this._manifestPath(), 'utf-8'));
2876
+ }
2877
+ } catch {
2878
+ this._manifest = null;
2879
+ }
2880
+ if (!this._manifest) this._manifest = { files: {} };
2881
+ }
2882
+
2883
+ save() {
2884
+ mkdirSync(this._cacheDir, { recursive: true });
2885
+ writeFileSync(this._manifestPath(), JSON.stringify(this._manifest, null, 2));
2886
+ }
2887
+
2888
+ // Check if a source file is unchanged since last build
2889
+ isUpToDate(absPath, sourceContent) {
2890
+ if (!this._manifest) return false;
2891
+ const entry = this._manifest.files[absPath];
2892
+ if (!entry) return false;
2893
+ return entry.hash === this._hashContent(sourceContent);
2894
+ }
2895
+
2896
+ // Check if a multi-file group (directory) is unchanged since last build
2897
+ isGroupUpToDate(dirKey, files) {
2898
+ if (!this._manifest) return false;
2899
+ const entry = this._manifest.files[dirKey];
2900
+ if (!entry) return false;
2901
+ return entry.hash === this._hashGroup(files);
2902
+ }
2903
+
2904
+ // Hash multiple files together for group caching
2905
+ _hashGroup(files) {
2906
+ const hash = createHash('sha256');
2907
+ for (const f of files.slice().sort()) {
2908
+ hash.update(f);
2909
+ hash.update(readFileSync(f, 'utf-8'));
2910
+ }
2911
+ return hash.digest('hex').slice(0, 16);
2912
+ }
2913
+
2914
+ // Store compiled output for a multi-file group
2915
+ setGroup(dirKey, files, outputs) {
2916
+ if (!this._manifest) this._manifest = { files: {} };
2917
+ this._manifest.files[dirKey] = {
2918
+ hash: this._hashGroup(files),
2919
+ outputs,
2920
+ timestamp: Date.now(),
2921
+ };
2922
+ }
2923
+
2924
+ // Get cached compiled output for a source file
2925
+ getCached(absPath) {
2926
+ if (!this._manifest) return null;
2927
+ const entry = this._manifest.files[absPath];
2928
+ if (!entry || !entry.outputs) return null;
2929
+ // Verify cached output files still exist on disk
2930
+ for (const outFile of Object.values(entry.outputs)) {
2931
+ if (outFile && !existsSync(outFile)) return null;
2932
+ }
2933
+ return entry.outputs;
2934
+ }
2935
+
2936
+ // Store compiled output for a source file
2937
+ set(absPath, sourceContent, outputs) {
2938
+ if (!this._manifest) this._manifest = { files: {} };
2939
+ this._manifest.files[absPath] = {
2940
+ hash: this._hashContent(sourceContent),
2941
+ outputs,
2942
+ timestamp: Date.now(),
2943
+ };
2944
+ }
2945
+
2946
+ // Remove stale entries for files/dirs that no longer exist
2947
+ prune(existingFiles, existingDirs) {
2948
+ if (!this._manifest) return;
2949
+ const existingSet = new Set(existingFiles);
2950
+ const dirSet = existingDirs ? new Set(existingDirs) : null;
2951
+ for (const key of Object.keys(this._manifest.files)) {
2952
+ if (key.startsWith('dir:')) {
2953
+ if (dirSet && !dirSet.has(key)) delete this._manifest.files[key];
2954
+ } else if (!existingSet.has(key)) {
2955
+ delete this._manifest.files[key];
2956
+ }
2957
+ }
2958
+ }
2959
+ }
2960
+
1829
2961
  // ─── Multi-file Import Support ───────────────────────────────
1830
2962
 
2963
+ // Determine the compiled JS extension for a .tova file.
2964
+ // Module files (no blocks) → '.js', app files → '.shared.js'
2965
+ function getCompiledExtension(tovaPath) {
2966
+ // Check compilation cache first
2967
+ if (compilationCache.has(tovaPath)) {
2968
+ return compilationCache.get(tovaPath).isModule ? '.js' : '.shared.js';
2969
+ }
2970
+ // Quick-scan the source for block keywords
2971
+ if (existsSync(tovaPath)) {
2972
+ const src = readFileSync(tovaPath, 'utf-8');
2973
+ // If the file contains top-level block keywords followed by '{', it's an app file
2974
+ if (/^(?:shared|server|client|test|bench|data)\s*(?:\{|")/m.test(src)) {
2975
+ return '.shared.js';
2976
+ }
2977
+ return '.js';
2978
+ }
2979
+ return '.shared.js'; // default fallback
2980
+ }
2981
+
1831
2982
  const compilationCache = new Map();
1832
2983
  const compilationInProgress = new Set();
2984
+ const compilationChain = []; // ordered import chain for circular import error messages
1833
2985
 
1834
2986
  // Track module exports for cross-file import validation
1835
2987
  const moduleExports = new Map();
@@ -1904,6 +3056,7 @@ function compileWithImports(source, filename, srcDir) {
1904
3056
  }
1905
3057
 
1906
3058
  compilationInProgress.add(filename);
3059
+ compilationChain.push(filename);
1907
3060
 
1908
3061
  try {
1909
3062
  // Parse and find .tova imports
@@ -1920,7 +3073,8 @@ function compileWithImports(source, filename, srcDir) {
1920
3073
  if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
1921
3074
  const importPath = resolve(dirname(filename), node.source);
1922
3075
  if (compilationInProgress.has(importPath)) {
1923
- throw new Error(`Circular import detected: ${filename} → ${importPath}`);
3076
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3077
+ throw new Error(`Circular import detected: ${chain}`);
1924
3078
  } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
1925
3079
  const importSource = readFileSync(importPath, 'utf-8');
1926
3080
  compileWithImports(importSource, importPath, srcDir);
@@ -1938,28 +3092,33 @@ function compileWithImports(source, filename, srcDir) {
1938
3092
  }
1939
3093
  }
1940
3094
  }
1941
- // Rewrite the import path to .js
1942
- node.source = node.source.replace('.tova', '.shared.js');
3095
+ // Rewrite the import path to .js (module) or .shared.js (app)
3096
+ const ext = getCompiledExtension(importPath);
3097
+ node.source = node.source.replace('.tova', ext);
1943
3098
  }
1944
3099
  if (node.type === 'ImportDefault' && node.source.endsWith('.tova')) {
1945
3100
  const importPath = resolve(dirname(filename), node.source);
1946
3101
  if (compilationInProgress.has(importPath)) {
1947
- throw new Error(`Circular import detected: ${filename} → ${importPath}`);
3102
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3103
+ throw new Error(`Circular import detected: ${chain}`);
1948
3104
  } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
1949
3105
  const importSource = readFileSync(importPath, 'utf-8');
1950
3106
  compileWithImports(importSource, importPath, srcDir);
1951
3107
  }
1952
- node.source = node.source.replace('.tova', '.shared.js');
3108
+ const ext2 = getCompiledExtension(importPath);
3109
+ node.source = node.source.replace('.tova', ext2);
1953
3110
  }
1954
3111
  if (node.type === 'ImportWildcard' && node.source.endsWith('.tova')) {
1955
3112
  const importPath = resolve(dirname(filename), node.source);
1956
3113
  if (compilationInProgress.has(importPath)) {
1957
- throw new Error(`Circular import detected: ${filename} → ${importPath}`);
3114
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3115
+ throw new Error(`Circular import detected: ${chain}`);
1958
3116
  } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
1959
3117
  const importSource = readFileSync(importPath, 'utf-8');
1960
3118
  compileWithImports(importSource, importPath, srcDir);
1961
3119
  }
1962
- node.source = node.source.replace('.tova', '.shared.js');
3120
+ const ext3 = getCompiledExtension(importPath);
3121
+ node.source = node.source.replace('.tova', ext3);
1963
3122
  }
1964
3123
  }
1965
3124
 
@@ -1979,6 +3138,7 @@ function compileWithImports(source, filename, srcDir) {
1979
3138
  return output;
1980
3139
  } finally {
1981
3140
  compilationInProgress.delete(filename);
3141
+ compilationChain.pop();
1982
3142
  }
1983
3143
  }
1984
3144
 
@@ -2130,7 +3290,8 @@ function mergeDirectory(dir, srcDir, options = {}) {
2130
3290
  // Only process imports from OTHER directories (same-dir files are merged)
2131
3291
  if (dirname(importPath) !== dir) {
2132
3292
  if (compilationInProgress.has(importPath)) {
2133
- throw new Error(`Circular import detected: ${file} → ${importPath}`);
3293
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
3294
+ throw new Error(`Circular import detected: ${chain}`);
2134
3295
  } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
2135
3296
  const importSource = readFileSync(importPath, 'utf-8');
2136
3297
  compileWithImports(importSource, importPath, srcDir);
@@ -2148,8 +3309,9 @@ function mergeDirectory(dir, srcDir, options = {}) {
2148
3309
  }
2149
3310
  }
2150
3311
  }
2151
- // Rewrite to .js
2152
- node.source = node.source.replace('.tova', '.shared.js');
3312
+ // Rewrite to .js (module) or .shared.js (app)
3313
+ const ext = getCompiledExtension(importPath);
3314
+ node.source = node.source.replace('.tova', ext);
2153
3315
  } else {
2154
3316
  // Same-directory import — remove it since files are merged
2155
3317
  node._removed = true;
@@ -2251,4 +3413,132 @@ function findFiles(dir, ext) {
2251
3413
  return results;
2252
3414
  }
2253
3415
 
3416
+ // ─── Upgrade Command ─────────────────────────────────────────
3417
+
3418
+ async function upgradeCommand() {
3419
+ console.log(`\n Current version: Tova v${VERSION}\n`);
3420
+ console.log(' Checking for updates...');
3421
+
3422
+ try {
3423
+ // Check the npm registry for the latest version
3424
+ const res = await fetch('https://registry.npmjs.org/tova/latest');
3425
+ if (!res.ok) {
3426
+ console.error(' Could not reach the npm registry. Check your network connection.');
3427
+ process.exit(1);
3428
+ }
3429
+ const data = await res.json();
3430
+ const latestVersion = data.version;
3431
+
3432
+ if (latestVersion === VERSION) {
3433
+ console.log(` Already on the latest version (v${VERSION}).\n`);
3434
+ return;
3435
+ }
3436
+
3437
+ console.log(` New version available: v${latestVersion}\n`);
3438
+ console.log(' Upgrading...');
3439
+
3440
+ // Detect the package manager used to install tova
3441
+ const pm = detectPackageManager();
3442
+ const installCmd = pm === 'bun' ? ['bun', ['add', '-g', 'tova@latest']]
3443
+ : pm === 'pnpm' ? ['pnpm', ['add', '-g', 'tova@latest']]
3444
+ : pm === 'yarn' ? ['yarn', ['global', 'add', 'tova@latest']]
3445
+ : ['npm', ['install', '-g', 'tova@latest']];
3446
+
3447
+ const proc = spawn(installCmd[0], installCmd[1], { stdio: 'inherit' });
3448
+ const exitCode = await new Promise(res => proc.on('close', res));
3449
+
3450
+ if (exitCode === 0) {
3451
+ console.log(`\n Upgraded to Tova v${latestVersion}.\n`);
3452
+ } else {
3453
+ console.error(`\n Upgrade failed (exit code ${exitCode}).`);
3454
+ console.error(` Try manually: ${installCmd[0]} ${installCmd[1].join(' ')}\n`);
3455
+ process.exit(1);
3456
+ }
3457
+ } catch (err) {
3458
+ console.error(` Upgrade failed: ${err.message}`);
3459
+ console.error(' Try manually: bun add -g tova@latest\n');
3460
+ process.exit(1);
3461
+ }
3462
+ }
3463
+
3464
+ function detectPackageManager() {
3465
+ // Check if we're running under bun (most likely for Tova)
3466
+ if (typeof Bun !== 'undefined') return 'bun';
3467
+ // Check npm_config_user_agent for the package manager
3468
+ const ua = process.env.npm_config_user_agent || '';
3469
+ if (ua.includes('pnpm')) return 'pnpm';
3470
+ if (ua.includes('yarn')) return 'yarn';
3471
+ if (ua.includes('bun')) return 'bun';
3472
+ return 'npm';
3473
+ }
3474
+
3475
+ // ─── Info Command ────────────────────────────────────────────
3476
+
3477
+ async function infoCommand() {
3478
+ const config = resolveConfig(process.cwd());
3479
+ const hasTOML = config._source === 'tova.toml';
3480
+
3481
+ console.log(`\n ╦ ╦ ╦═╗ ╦`);
3482
+ console.log(` ║ ║ ║ ║ ╠╣`);
3483
+ console.log(` ╩═╝╚═╝╩═╝╩ v${VERSION}\n`);
3484
+
3485
+ // Bun version
3486
+ let bunVersion = 'not found';
3487
+ try {
3488
+ const proc = Bun.spawnSync(['bun', '--version']);
3489
+ bunVersion = proc.stdout.toString().trim();
3490
+ } catch {}
3491
+ console.log(` Runtime: Bun v${bunVersion}`);
3492
+ console.log(` Platform: ${process.platform} ${process.arch}`);
3493
+ console.log(` Node compat: ${process.version}`);
3494
+
3495
+ // Project config
3496
+ if (hasTOML) {
3497
+ console.log(`\n Project Config (tova.toml):`);
3498
+ if (config.project?.name) console.log(` Name: ${config.project.name}`);
3499
+ if (config.project?.version) console.log(` Version: ${config.project.version}`);
3500
+ if (config.project?.entry) console.log(` Entry: ${config.project.entry}`);
3501
+ if (config.build?.output) console.log(` Output: ${config.build.output}`);
3502
+ if (config.build?.target) console.log(` Target: ${config.build.target}`);
3503
+ } else {
3504
+ console.log(`\n No tova.toml found (using defaults).`);
3505
+ }
3506
+
3507
+ // Installed dependencies
3508
+ const pkgJsonPath = join(process.cwd(), 'package.json');
3509
+ if (existsSync(pkgJsonPath)) {
3510
+ try {
3511
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
3512
+ const deps = Object.keys(pkg.dependencies || {});
3513
+ const devDeps = Object.keys(pkg.devDependencies || {});
3514
+
3515
+ if (deps.length > 0 || devDeps.length > 0) {
3516
+ console.log(`\n Dependencies:`);
3517
+ for (const dep of deps) {
3518
+ console.log(` ${dep}: ${pkg.dependencies[dep]}`);
3519
+ }
3520
+ if (devDeps.length > 0) {
3521
+ console.log(` Dev Dependencies:`);
3522
+ for (const dep of devDeps) {
3523
+ console.log(` ${dep}: ${pkg.devDependencies[dep]}`);
3524
+ }
3525
+ }
3526
+ } else {
3527
+ console.log(`\n No dependencies installed.`);
3528
+ }
3529
+ } catch {}
3530
+ }
3531
+
3532
+ // Build output status
3533
+ const outDir = resolve(config.build?.output || '.tova-out');
3534
+ if (existsSync(outDir)) {
3535
+ const files = findFiles(outDir, '.js');
3536
+ console.log(`\n Build output: ${relative('.', outDir)}/ (${files.length} file${files.length === 1 ? '' : 's'})`);
3537
+ } else {
3538
+ console.log(`\n Build output: not built yet`);
3539
+ }
3540
+
3541
+ console.log('');
3542
+ }
3543
+
2254
3544
  main();