tova 0.10.3 → 0.11.12

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.
@@ -0,0 +1,815 @@
1
+ // src/cli/compile.js — Shared compilation pipeline
2
+ import { resolve, basename, dirname, join, relative, sep, extname } from 'path';
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
4
+ import { createRequire as _createRequire } from 'module';
5
+ import { Lexer } from '../lexer/lexer.js';
6
+ import { Parser } from '../parser/parser.js';
7
+ import { Analyzer } from '../analyzer/analyzer.js';
8
+ import { Symbol } from '../analyzer/scope.js';
9
+ import { Program } from '../parser/ast.js';
10
+ import { CodeGenerator } from '../codegen/codegen.js';
11
+ import { richError, formatDiagnostics, DiagnosticFormatter, formatSummary } from '../diagnostics/formatter.js';
12
+ import { buildSelectiveStdlib, BUILTIN_NAMES } from '../stdlib/inline.js';
13
+ import { findFiles } from './utils.js';
14
+
15
+ export function compileTova(source, filename, options = {}) {
16
+ const lexer = new Lexer(source, filename);
17
+ const tokens = lexer.tokenize();
18
+
19
+ const parser = new Parser(tokens, filename);
20
+ const ast = parser.parse();
21
+
22
+ const analyzer = new Analyzer(ast, filename, { strict: options.strict || false, strictSecurity: options.strictSecurity || false });
23
+ // Pre-define extra names in the analyzer scope (used by REPL for cross-line persistence)
24
+ if (options.knownNames) {
25
+ for (const name of options.knownNames) {
26
+ analyzer.globalScope.define(name, new Symbol(name, 'variable', null, true, { line: 0, column: 0, file: '<repl>' }));
27
+ }
28
+ }
29
+ const { warnings } = analyzer.analyze();
30
+
31
+ if (warnings.length > 0 && !options.suppressWarnings) {
32
+ const formatter = new DiagnosticFormatter(source, filename);
33
+ for (const w of warnings) {
34
+ console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }, { hint: w.hint, code: w.code, length: w.length, fix: w.fix }));
35
+ }
36
+ }
37
+
38
+ const codegen = new CodeGenerator(ast, filename, { sourceMaps: options.sourceMaps !== false });
39
+ return codegen.generate();
40
+ }
41
+
42
+ export function fixImportPaths(code, outputFilePath, outDir, srcDir) {
43
+ const relPath = relative(outDir, outputFilePath);
44
+ const depth = dirname(relPath).split(sep).filter(p => p && p !== '.').length;
45
+
46
+ // Fix runtime imports: './runtime/X.js' → correct relative path based on depth
47
+ if (depth > 0) {
48
+ const prefix = '../'.repeat(depth);
49
+ for (const runtimeFile of ['reactivity.js', 'rpc.js', 'router.js', 'devtools.js', 'ssr.js', 'testing.js']) {
50
+ code = code.split("'./runtime/" + runtimeFile + "'").join("'" + prefix + "runtime/" + runtimeFile + "'");
51
+ code = code.split('"./runtime/' + runtimeFile + '"').join('"' + prefix + 'runtime/' + runtimeFile + '"');
52
+ }
53
+ }
54
+
55
+ // Add .js extension to relative imports that don't have one
56
+ code = code.replace(
57
+ /from\s+(['"])(\.[^'"]+)\1/g,
58
+ (match, quote, path) => {
59
+ if (path.endsWith('.js')) return match;
60
+ return 'from ' + quote + path + '.js' + quote;
61
+ }
62
+ );
63
+
64
+ // Inject missing router imports
65
+ code = injectRouterImport(code, depth);
66
+
67
+ // Fix duplicate identifiers between reactivity and router imports (e.g. 'lazy')
68
+ const reactivityMatch = code.match(/^import\s+\{([^}]+)\}\s+from\s+['"][^'"]*runtime\/reactivity[^'"]*['"]/m);
69
+ const routerMatch = code.match(/^(import\s+\{)([^}]+)(\}\s+from\s+['"][^'"]*runtime\/router[^'"]*['"])/m);
70
+ if (reactivityMatch && routerMatch) {
71
+ const reactivityNames = new Set(reactivityMatch[1].split(',').map(s => s.trim()));
72
+ const routerNames = routerMatch[2].split(',').map(s => s.trim());
73
+ const deduped = routerNames.filter(n => !reactivityNames.has(n));
74
+ if (deduped.length < routerNames.length) {
75
+ if (deduped.length === 0) {
76
+ // Remove the entire router import line if nothing left
77
+ code = code.replace(/^import\s+\{[^}]*\}\s+from\s+['"][^'"]*runtime\/router[^'"]*['"];?\s*\n?/m, '');
78
+ } else {
79
+ code = code.replace(routerMatch[0], routerMatch[1] + ' ' + deduped.join(', ') + ' ' + routerMatch[3]);
80
+ }
81
+ }
82
+ }
83
+
84
+ // Fix CodeBlock/code prop template literal interpolation: the compiler treats {identifier}
85
+ // inside string attributes as interpolation, generating `${identifier}` in template literals.
86
+ // For code example strings, these should be literal braces. Revert them.
87
+ code = code.replace(/code:\s*`([\s\S]*?)`/g, (match, content) => {
88
+ if (!content.includes('${')) return match;
89
+ const fixed = content.replace(/\$\{(\w+)\}/g, '{ $1 }');
90
+ // Convert template literal to regular string with escaped quotes and newlines
91
+ return 'code: "' + fixed.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
92
+ });
93
+
94
+ // File-based routing: inject routes if src/pages/ exists and no manual routes defined
95
+ if (srcDir && !(/\b(defineRoutes|createRouter)\s*\(/.test(code))) {
96
+ const fileRoutes = generateFileBasedRoutes(srcDir);
97
+ if (fileRoutes) {
98
+ // Convert Tova-style imports to JS imports with .js extensions
99
+ const jsRoutes = fileRoutes
100
+ .replace(/from\s+"([^"]+)"/g, (m, p) => 'from "' + p + '.js"');
101
+ // Inject before the last closing function or at the end
102
+ code = code + '\n// ── File-Based Routes (auto-generated from src/pages/) ──\n' + jsRoutes + '\n';
103
+ }
104
+ }
105
+
106
+ return code;
107
+ }
108
+
109
+ export function injectRouterImport(code, depth) {
110
+ const routerFuncs = ['createRouter', 'lazy', 'resetRouter', 'getPath', 'navigate',
111
+ 'getCurrentRoute', 'getParams', 'getQuery', 'getMeta', 'getRouter',
112
+ 'defineRoutes', 'onRouteChange', 'Router', 'Link', 'Outlet', 'Redirect',
113
+ 'beforeNavigate', 'afterNavigate'];
114
+ const hasRouterImport = /runtime\/router/.test(code);
115
+ if (hasRouterImport) return code;
116
+
117
+ // Strip import lines before checking for router function usage to avoid false positives
118
+ const codeWithoutImports = code.replace(/^import\s+\{[^}]*\}\s+from\s+['"][^'"]*['"];?\s*$/gm, '');
119
+ const usedFuncs = routerFuncs.filter(fn => new RegExp('\\b' + fn + '\\b').test(codeWithoutImports));
120
+ if (usedFuncs.length === 0) return code;
121
+
122
+ const routerPath = depth === 0
123
+ ? './runtime/router.js'
124
+ : '../'.repeat(depth) + 'runtime/router.js';
125
+
126
+ const importLine = "import { " + usedFuncs.join(', ') + " } from '" + routerPath + "';\n";
127
+
128
+ // Insert after first import line, or at the start
129
+ const firstImportEnd = code.indexOf(';\n');
130
+ if (firstImportEnd !== -1 && code.trimStart().startsWith('import ')) {
131
+ return code.slice(0, firstImportEnd + 2) + importLine + code.slice(firstImportEnd + 2);
132
+ }
133
+ return importLine + code;
134
+ }
135
+
136
+ // ─── File-Based Routing ──────────────────────────────────────
137
+
138
+ export function generateFileBasedRoutes(srcDir) {
139
+ const pagesDir = join(srcDir, 'pages');
140
+ if (!existsSync(pagesDir) || !statSync(pagesDir).isDirectory()) return null;
141
+
142
+ // Scan pages directory recursively
143
+ const routes = [];
144
+ let hasLayout = false;
145
+ let has404 = false;
146
+
147
+ function scanDir(dir, prefix) {
148
+ const entries = readdirSync(dir).sort();
149
+ for (const entry of entries) {
150
+ const fullPath = join(dir, entry);
151
+ const stat = statSync(fullPath);
152
+
153
+ if (stat.isDirectory()) {
154
+ // Check for layout in subdirectory
155
+ const subLayout = join(fullPath, '_layout.tova');
156
+ if (existsSync(subLayout)) {
157
+ const childRoutes = [];
158
+ scanDir(fullPath, prefix + '/' + entry);
159
+ // Layout routes handled via nested children
160
+ continue;
161
+ }
162
+ scanDir(fullPath, prefix + '/' + entry);
163
+ continue;
164
+ }
165
+
166
+ if (!entry.endsWith('.tova')) continue;
167
+ const name = entry.replace('.tova', '');
168
+
169
+ // Skip layout files (handled separately)
170
+ if (name === '_layout') {
171
+ if (prefix === '') hasLayout = true;
172
+ continue;
173
+ }
174
+
175
+ // 404 page
176
+ if (name === '404') {
177
+ has404 = true;
178
+ const relImport = './pages' + (prefix ? prefix + '/' : '/') + name;
179
+ routes.push({ path: '404', importPath: relImport, componentName: 'NotFoundPage__auto' });
180
+ continue;
181
+ }
182
+
183
+ // Determine route path
184
+ let routePath;
185
+ if (name === 'index') {
186
+ routePath = prefix || '/';
187
+ } else if (name.startsWith('[...') && name.endsWith(']')) {
188
+ // Catch-all: [...slug] → *
189
+ routePath = prefix + '/*';
190
+ } else if (name.startsWith('[[') && name.endsWith(']]')) {
191
+ // Optional param: [[id]] → /:id?
192
+ const paramName = name.slice(2, -2);
193
+ routePath = prefix + '/:' + paramName + '?';
194
+ } else if (name.startsWith('[') && name.endsWith(']')) {
195
+ // Dynamic param: [id] → /:id
196
+ const paramName = name.slice(1, -1);
197
+ routePath = prefix + '/:' + paramName;
198
+ } else {
199
+ routePath = prefix + '/' + name;
200
+ }
201
+
202
+ const relImport = './pages' + (prefix ? prefix + '/' : '/') + name;
203
+ // Generate safe component name from path
204
+ const safeName = name
205
+ .replace(/\[\.\.\.(\w+)\]/, 'CatchAll_$1')
206
+ .replace(/\[\[(\w+)\]\]/, 'Optional_$1')
207
+ .replace(/\[(\w+)\]/, 'Param_$1')
208
+ .replace(/[^a-zA-Z0-9_]/g, '_');
209
+ const componentName = '__Page_' + (prefix ? prefix.slice(1).replace(/\//g, '_') + '_' : '') + safeName;
210
+
211
+ routes.push({ path: routePath, importPath: relImport, componentName });
212
+ }
213
+ }
214
+
215
+ scanDir(pagesDir, '');
216
+
217
+ if (routes.length === 0) return null;
218
+
219
+ // Generate import statements and route map
220
+ const imports = routes.map(r =>
221
+ 'import { Page as ' + r.componentName + ' } from "' + r.importPath + '"'
222
+ ).join('\n');
223
+
224
+ const routeEntries = routes.map(r =>
225
+ ' "' + r.path + '": ' + r.componentName + ','
226
+ ).join('\n');
227
+
228
+ // Check for root layout
229
+ let layoutImport = '';
230
+ let layoutWrap = '';
231
+ if (hasLayout) {
232
+ layoutImport = '\nimport { Layout as __RootLayout } from "./pages/_layout"';
233
+ // With layout, wrap routes as children
234
+ // For now, just generate flat routes — layout support can be added later
235
+ }
236
+
237
+ const generated = imports + layoutImport + '\n\n' +
238
+ 'defineRoutes({\n' + routeEntries + '\n})';
239
+
240
+ return generated;
241
+ }
242
+
243
+ export const moduleTypeCache = new Map(); // tovaPath -> '.js' | '.shared.js'
244
+
245
+ export function getCompiledExtension(tovaPath) {
246
+ // Check compilation cache first
247
+ if (compilationCache.has(tovaPath)) {
248
+ return compilationCache.get(tovaPath).isModule ? '.js' : '.shared.js';
249
+ }
250
+ // Check module type cache (set during parsing)
251
+ if (moduleTypeCache.has(tovaPath)) {
252
+ return moduleTypeCache.get(tovaPath);
253
+ }
254
+ // Fall back: quick-lex the file to detect block keywords at top level
255
+ if (existsSync(tovaPath)) {
256
+ const src = readFileSync(tovaPath, 'utf-8');
257
+ try {
258
+ const lexer = new Lexer(src, tovaPath);
259
+ const tokens = lexer.tokenize();
260
+ // Check if any top-level token is a block keyword (shared/server/client/test/bench/data)
261
+ const BLOCK_KEYWORDS = new Set(['shared', 'server', 'client', 'browser', 'test', 'bench', 'data']);
262
+ let depth = 0;
263
+ for (const tok of tokens) {
264
+ if (tok.type === 'LBRACE') depth++;
265
+ else if (tok.type === 'RBRACE') depth--;
266
+ else if (depth === 0 && tok.type === 'IDENTIFIER' && BLOCK_KEYWORDS.has(tok.value)) {
267
+ moduleTypeCache.set(tovaPath, '.shared.js');
268
+ return '.shared.js';
269
+ }
270
+ }
271
+ moduleTypeCache.set(tovaPath, '.js');
272
+ return '.js';
273
+ } catch {
274
+ // If lexing fails, fall back to regex heuristic
275
+ if (/^(?:shared|server|client|test|bench|data)\s*(?:\{|")/m.test(src)) {
276
+ return '.shared.js';
277
+ }
278
+ return '.js';
279
+ }
280
+ }
281
+ return '.shared.js'; // default fallback
282
+ }
283
+
284
+ export const compilationCache = new Map();
285
+ export const compilationInProgress = new Set();
286
+ export const compilationChain = []; // ordered import chain for circular import error messages
287
+
288
+ // Track module exports for cross-file import validation
289
+ export const moduleExports = new Map();
290
+
291
+ // Dependency graph: file -> Set of files it imports (forward deps)
292
+ export const fileDependencies = new Map();
293
+ // Reverse dependency graph: file -> Set of files that import it
294
+ export const fileReverseDeps = new Map();
295
+
296
+ export function trackDependency(fromFile, toFile) {
297
+ if (!fileDependencies.has(fromFile)) fileDependencies.set(fromFile, new Set());
298
+ fileDependencies.get(fromFile).add(toFile);
299
+ if (!fileReverseDeps.has(toFile)) fileReverseDeps.set(toFile, new Set());
300
+ fileReverseDeps.get(toFile).add(fromFile);
301
+ }
302
+
303
+ // Get all files that transitively depend on changedFile
304
+ export function getTransitiveDependents(changedFile) {
305
+ const dependents = new Set();
306
+ const queue = [changedFile];
307
+ while (queue.length > 0) {
308
+ const file = queue.pop();
309
+ dependents.add(file);
310
+ const rdeps = fileReverseDeps.get(file);
311
+ if (rdeps) {
312
+ for (const dep of rdeps) {
313
+ if (!dependents.has(dep)) queue.push(dep);
314
+ }
315
+ }
316
+ }
317
+ return dependents;
318
+ }
319
+
320
+ export function invalidateFile(changedPath) {
321
+ const toInvalidate = getTransitiveDependents(changedPath);
322
+ for (const file of toInvalidate) {
323
+ compilationCache.delete(file);
324
+ moduleTypeCache.delete(file);
325
+ moduleExports.delete(file);
326
+ // Clear forward deps (will be rebuilt on recompile)
327
+ const deps = fileDependencies.get(file);
328
+ if (deps) {
329
+ for (const dep of deps) {
330
+ const rdeps = fileReverseDeps.get(dep);
331
+ if (rdeps) rdeps.delete(file);
332
+ }
333
+ fileDependencies.delete(file);
334
+ }
335
+ }
336
+ }
337
+
338
+ export function collectExports(ast, filename) {
339
+ const publicExports = new Set();
340
+ const allNames = new Set();
341
+
342
+ function collectFromNode(node) {
343
+ if (node.type === 'FunctionDeclaration') {
344
+ allNames.add(node.name);
345
+ if (node.isPublic) publicExports.add(node.name);
346
+ }
347
+ if (node.type === 'Assignment' && node.targets) {
348
+ for (const t of node.targets) {
349
+ allNames.add(t);
350
+ if (node.isPublic) publicExports.add(t);
351
+ }
352
+ }
353
+ if (node.type === 'TypeDeclaration') {
354
+ allNames.add(node.name);
355
+ if (node.isPublic) publicExports.add(node.name);
356
+ if (node.variants) {
357
+ for (const v of node.variants) {
358
+ if (v.type === 'TypeVariant') {
359
+ allNames.add(v.name);
360
+ if (node.isPublic) publicExports.add(v.name);
361
+ }
362
+ }
363
+ }
364
+ }
365
+ if (node.type === 'VarDeclaration' && node.targets) {
366
+ for (const t of node.targets) {
367
+ allNames.add(t);
368
+ if (node.isPublic) publicExports.add(t);
369
+ }
370
+ }
371
+ if (node.type === 'InterfaceDeclaration') {
372
+ allNames.add(node.name);
373
+ if (node.isPublic) publicExports.add(node.name);
374
+ }
375
+ if (node.type === 'TraitDeclaration') {
376
+ allNames.add(node.name);
377
+ if (node.isPublic) publicExports.add(node.name);
378
+ }
379
+ if (node.type === 'TypeAlias') {
380
+ allNames.add(node.name);
381
+ if (node.isPublic) publicExports.add(node.name);
382
+ }
383
+ if (node.type === 'ComponentDeclaration') {
384
+ allNames.add(node.name);
385
+ if (node.isPublic) publicExports.add(node.name);
386
+ }
387
+ if (node.type === 'ImplDeclaration') { /* impl doesn't export a name */ }
388
+ if (node.type === 'ReExportDeclaration') {
389
+ if (node.specifiers) {
390
+ // Named re-exports: pub { a, b as c } from "module"
391
+ for (const spec of node.specifiers) {
392
+ publicExports.add(spec.exported);
393
+ allNames.add(spec.exported);
394
+ }
395
+ }
396
+ // Wildcard re-exports: pub * from "module" — can't enumerate statically,
397
+ // but mark as having re-exports so import validation can allow through
398
+ }
399
+ }
400
+
401
+ for (const node of ast.body) {
402
+ // Also collect exports from inside shared/server/client blocks
403
+ if (node.type === 'SharedBlock' || node.type === 'ServerBlock' || node.type === 'BrowserBlock') {
404
+ if (node.body) {
405
+ for (const inner of node.body) {
406
+ collectFromNode(inner);
407
+ }
408
+ }
409
+ continue;
410
+ }
411
+ collectFromNode(node);
412
+ }
413
+ moduleExports.set(filename, { publicExports, allNames });
414
+ return { publicExports, allNames };
415
+ }
416
+
417
+ export function compileWithImports(source, filename, srcDir, options = {}) {
418
+ if (compilationCache.has(filename)) {
419
+ return compilationCache.get(filename);
420
+ }
421
+
422
+ compilationInProgress.add(filename);
423
+ compilationChain.push(filename);
424
+
425
+ try {
426
+ // Parse and find .tova imports
427
+ const lexer = new Lexer(source, filename);
428
+ const tokens = lexer.tokenize();
429
+ const parser = new Parser(tokens, filename);
430
+ const ast = parser.parse();
431
+
432
+ // Cache module type from AST (avoids regex heuristic on subsequent lookups)
433
+ const hasBlocks = ast.body.some(n => n.type === 'SharedBlock' || n.type === 'ServerBlock' || n.type === 'BrowserBlock' || n.type === 'TestBlock' || n.type === 'BenchBlock' || n.type === 'DataBlock');
434
+ moduleTypeCache.set(filename, hasBlocks ? '.shared.js' : '.js');
435
+
436
+ // Collect this module's exports for validation
437
+ collectExports(ast, filename);
438
+
439
+ // Resolve imports: tova: prefix, @/ prefix, then .tova files
440
+ for (const node of ast.body) {
441
+ // Resolve tova: prefix imports to runtime modules
442
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
443
+ node.source = './runtime/' + node.source.slice(5) + '.js';
444
+ continue;
445
+ }
446
+ // Resolve @/ prefix imports to project root
447
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
448
+ const relPath = node.source.slice(2);
449
+ let resolved = resolve(srcDir, relPath);
450
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
451
+ const fromDir = dirname(filename);
452
+ let rel = relative(fromDir, resolved);
453
+ if (!rel.startsWith('.')) rel = './' + rel;
454
+ node.source = rel;
455
+ // Fall through to .tova import handling below
456
+ }
457
+ if (node.type === 'ImportDeclaration' && node.source.endsWith('.tova')) {
458
+ const importPath = resolve(dirname(filename), node.source);
459
+ trackDependency(filename, importPath);
460
+ if (compilationInProgress.has(importPath)) {
461
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
462
+ throw new Error(`Circular import detected: ${chain}`);
463
+ } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
464
+ const importSource = readFileSync(importPath, 'utf-8');
465
+ compileWithImports(importSource, importPath, srcDir);
466
+ }
467
+ // Validate imported names exist in target module's public exports
468
+ if (moduleExports.has(importPath)) {
469
+ const { publicExports, allNames } = moduleExports.get(importPath);
470
+ for (const spec of node.specifiers) {
471
+ if (!publicExports.has(spec.imported)) {
472
+ if (allNames.has(spec.imported)) {
473
+ throw new Error(`'${spec.imported}' is private in module '${node.source}'. Add 'pub' to export it.`);
474
+ } else {
475
+ throw new Error(`Module '${node.source}' does not export '${spec.imported}'`);
476
+ }
477
+ }
478
+ }
479
+ }
480
+ // Rewrite the import path to .js (module) or .shared.js (app)
481
+ const ext = getCompiledExtension(importPath);
482
+ node.source = node.source.replace('.tova', ext);
483
+ }
484
+ if (node.type === 'ImportDefault' && node.source.endsWith('.tova')) {
485
+ const importPath = resolve(dirname(filename), node.source);
486
+ trackDependency(filename, importPath);
487
+ if (compilationInProgress.has(importPath)) {
488
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
489
+ throw new Error(`Circular import detected: ${chain}`);
490
+ } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
491
+ const importSource = readFileSync(importPath, 'utf-8');
492
+ compileWithImports(importSource, importPath, srcDir);
493
+ }
494
+ const ext2 = getCompiledExtension(importPath);
495
+ node.source = node.source.replace('.tova', ext2);
496
+ }
497
+ if (node.type === 'ImportWildcard' && node.source.endsWith('.tova')) {
498
+ const importPath = resolve(dirname(filename), node.source);
499
+ trackDependency(filename, importPath);
500
+ if (compilationInProgress.has(importPath)) {
501
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
502
+ throw new Error(`Circular import detected: ${chain}`);
503
+ } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
504
+ const importSource = readFileSync(importPath, 'utf-8');
505
+ compileWithImports(importSource, importPath, srcDir);
506
+ }
507
+ const ext3 = getCompiledExtension(importPath);
508
+ node.source = node.source.replace('.tova', ext3);
509
+ }
510
+ }
511
+
512
+ const analyzer = new Analyzer(ast, filename);
513
+ const { warnings } = analyzer.analyze();
514
+
515
+ if (warnings.length > 0) {
516
+ const formatter = new DiagnosticFormatter(source, filename);
517
+ for (const w of warnings) {
518
+ console.warn(formatter.formatWarning(w.message, { line: w.line, column: w.column }));
519
+ }
520
+ }
521
+
522
+ const codegen = new CodeGenerator(ast, filename, { isDev: options.isDev });
523
+ const output = codegen.generate();
524
+ compilationCache.set(filename, output);
525
+ return output;
526
+ } finally {
527
+ compilationInProgress.delete(filename);
528
+ compilationChain.pop();
529
+ }
530
+ }
531
+
532
+ export function validateMergedAST(mergedBlocks, sourceFiles) {
533
+ const errors = [];
534
+
535
+ function addDup(kind, name, loc1, loc2) {
536
+ const f1 = loc1.source || 'unknown';
537
+ const f2 = loc2.source || 'unknown';
538
+ errors.push(
539
+ `Duplicate ${kind} '${name}'\n` +
540
+ ` → first defined in ${basename(f1)}:${loc1.line}\n` +
541
+ ` → also defined in ${basename(f2)}:${loc2.line}`
542
+ );
543
+ }
544
+
545
+ // Check browser blocks — top-level declarations only
546
+ const browserDecls = { component: new Map(), state: new Map(), computed: new Map(), store: new Map(), fn: new Map() };
547
+ for (const block of mergedBlocks.browserBlocks) {
548
+ for (const stmt of block.body) {
549
+ const loc = stmt.loc || block.loc;
550
+ if (stmt.type === 'ComponentDeclaration') {
551
+ if (browserDecls.component.has(stmt.name)) addDup('component', stmt.name, browserDecls.component.get(stmt.name), loc);
552
+ else browserDecls.component.set(stmt.name, loc);
553
+ } else if (stmt.type === 'StateDeclaration') {
554
+ const name = stmt.name || (stmt.targets && stmt.targets[0]);
555
+ if (name) {
556
+ if (browserDecls.state.has(name)) addDup('state', name, browserDecls.state.get(name), loc);
557
+ else browserDecls.state.set(name, loc);
558
+ }
559
+ } else if (stmt.type === 'ComputedDeclaration') {
560
+ const name = stmt.name;
561
+ if (name) {
562
+ if (browserDecls.computed.has(name)) addDup('computed', name, browserDecls.computed.get(name), loc);
563
+ else browserDecls.computed.set(name, loc);
564
+ }
565
+ } else if (stmt.type === 'StoreDeclaration') {
566
+ if (browserDecls.store.has(stmt.name)) addDup('store', stmt.name, browserDecls.store.get(stmt.name), loc);
567
+ else browserDecls.store.set(stmt.name, loc);
568
+ } else if (stmt.type === 'FunctionDeclaration') {
569
+ if (browserDecls.fn.has(stmt.name)) addDup('function', stmt.name, browserDecls.fn.get(stmt.name), loc);
570
+ else browserDecls.fn.set(stmt.name, loc);
571
+ }
572
+ }
573
+ }
574
+
575
+ // Check server blocks — group by block name, check within same-name groups
576
+ const serverGrouped = new Map();
577
+ for (const block of mergedBlocks.serverBlocks) {
578
+ const key = block.name || null;
579
+ if (!serverGrouped.has(key)) serverGrouped.set(key, []);
580
+ serverGrouped.get(key).push(block);
581
+ }
582
+
583
+ for (const [, blocks] of serverGrouped) {
584
+ const serverDecls = { fn: new Map(), model: new Map(), route: new Map() };
585
+ const singletons = new Map(); // db, cors, auth, session, etc.
586
+ const SINGLETON_TYPES = {
587
+ 'DbDeclaration': 'db', 'CorsDeclaration': 'cors', 'AuthDeclaration': 'auth',
588
+ 'SessionDeclaration': 'session', 'CompressionDeclaration': 'compression',
589
+ 'TlsDeclaration': 'tls', 'UploadDeclaration': 'upload', 'RateLimitDeclaration': 'rate_limit',
590
+ };
591
+
592
+ for (const block of blocks) {
593
+ const walkBody = (stmts) => {
594
+ for (const stmt of stmts) {
595
+ const loc = stmt.loc || block.loc;
596
+ if (stmt.type === 'FunctionDeclaration') {
597
+ if (serverDecls.fn.has(stmt.name)) addDup('server function', stmt.name, serverDecls.fn.get(stmt.name), loc);
598
+ else serverDecls.fn.set(stmt.name, loc);
599
+ } else if (stmt.type === 'ModelDeclaration') {
600
+ if (serverDecls.model.has(stmt.name)) addDup('model', stmt.name, serverDecls.model.get(stmt.name), loc);
601
+ else serverDecls.model.set(stmt.name, loc);
602
+ } else if (stmt.type === 'RouteDeclaration') {
603
+ const routeKey = `${stmt.method} ${stmt.path}`;
604
+ if (serverDecls.route.has(routeKey)) addDup('route', routeKey, serverDecls.route.get(routeKey), loc);
605
+ else serverDecls.route.set(routeKey, loc);
606
+ } else if (SINGLETON_TYPES[stmt.type]) {
607
+ const sName = SINGLETON_TYPES[stmt.type];
608
+ if (singletons.has(sName)) addDup('server config', sName, singletons.get(sName), loc);
609
+ else singletons.set(sName, loc);
610
+ } else if (stmt.type === 'RouteGroupDeclaration') {
611
+ walkBody(stmt.body);
612
+ }
613
+ }
614
+ };
615
+ walkBody(block.body);
616
+ }
617
+ }
618
+
619
+ // Check shared blocks
620
+ const sharedDecls = { type: new Map(), fn: new Map(), interface: new Map() };
621
+ for (const block of mergedBlocks.sharedBlocks) {
622
+ for (const stmt of block.body) {
623
+ const loc = stmt.loc || block.loc;
624
+ if (stmt.type === 'TypeDeclaration') {
625
+ if (sharedDecls.type.has(stmt.name)) addDup('type', stmt.name, sharedDecls.type.get(stmt.name), loc);
626
+ else sharedDecls.type.set(stmt.name, loc);
627
+ } else if (stmt.type === 'FunctionDeclaration') {
628
+ if (sharedDecls.fn.has(stmt.name)) addDup('shared function', stmt.name, sharedDecls.fn.get(stmt.name), loc);
629
+ else sharedDecls.fn.set(stmt.name, loc);
630
+ } else if (stmt.type === 'InterfaceDeclaration' || stmt.type === 'TraitDeclaration') {
631
+ if (sharedDecls.interface.has(stmt.name)) addDup('interface/trait', stmt.name, sharedDecls.interface.get(stmt.name), loc);
632
+ else sharedDecls.interface.set(stmt.name, loc);
633
+ }
634
+ }
635
+ }
636
+
637
+ if (errors.length > 0) {
638
+ throw new Error('Merge validation failed:\n\n' + errors.join('\n\n'));
639
+ }
640
+ }
641
+
642
+ export function mergeDirectory(dir, srcDir, options = {}) {
643
+ // Find all .tova files in this directory only (non-recursive)
644
+ const entries = readdirSync(dir);
645
+ const tovaFiles = entries
646
+ .filter(e => e.endsWith('.tova') && !e.startsWith('.'))
647
+ .map(e => join(dir, e))
648
+ .filter(f => statSync(f).isFile())
649
+ .sort();
650
+
651
+ if (tovaFiles.length === 0) return null;
652
+ if (tovaFiles.length === 1) {
653
+ // Single file — use existing per-file compilation
654
+ const file = tovaFiles[0];
655
+ const source = readFileSync(file, 'utf-8');
656
+ return { output: compileWithImports(source, file, srcDir, { isDev: options.isDev }), files: [file], single: true };
657
+ }
658
+
659
+ // Parse all files in the directory
660
+ const parsedFiles = [];
661
+ for (const file of tovaFiles) {
662
+ const source = readFileSync(file, 'utf-8');
663
+ const lexer = new Lexer(source, file);
664
+ const tokens = lexer.tokenize();
665
+ const parser = new Parser(tokens, file);
666
+ const ast = parser.parse();
667
+
668
+ // Collect exports for cross-file import validation
669
+ collectExports(ast, file);
670
+
671
+ // Resolve imports: tova: prefix, @/ prefix, then cross-directory .tova
672
+ for (const node of ast.body) {
673
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('tova:')) {
674
+ node.source = './runtime/' + node.source.slice(5) + '.js';
675
+ continue;
676
+ }
677
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.startsWith('@/')) {
678
+ const relPath = node.source.slice(2);
679
+ let resolved = resolve(srcDir, relPath);
680
+ if (!resolved.endsWith('.tova')) resolved += '.tova';
681
+ const fromDir = dirname(file);
682
+ let rel = relative(fromDir, resolved);
683
+ if (!rel.startsWith('.')) rel = './' + rel;
684
+ node.source = rel;
685
+ // Fall through to .tova import handling below
686
+ }
687
+ if ((node.type === 'ImportDeclaration' || node.type === 'ImportDefault' || node.type === 'ImportWildcard') && node.source.endsWith('.tova')) {
688
+ const importPath = resolve(dirname(file), node.source);
689
+ // Only process imports from OTHER directories (same-dir files are merged)
690
+ if (dirname(importPath) !== dir) {
691
+ if (compilationInProgress.has(importPath)) {
692
+ const chain = [...compilationChain, importPath].map(f => basename(f)).join(' \u2192 ');
693
+ throw new Error(`Circular import detected: ${chain}`);
694
+ } else if (existsSync(importPath) && !compilationCache.has(importPath)) {
695
+ const importSource = readFileSync(importPath, 'utf-8');
696
+ compileWithImports(importSource, importPath, srcDir);
697
+ }
698
+ // Validate imported names
699
+ if (node.type === 'ImportDeclaration' && moduleExports.has(importPath)) {
700
+ const { publicExports, allNames } = moduleExports.get(importPath);
701
+ for (const spec of node.specifiers) {
702
+ if (!publicExports.has(spec.imported)) {
703
+ if (allNames.has(spec.imported)) {
704
+ throw new Error(`'${spec.imported}' is private in module '${node.source}'. Add 'pub' to export it.`);
705
+ } else {
706
+ throw new Error(`Module '${node.source}' does not export '${spec.imported}'`);
707
+ }
708
+ }
709
+ }
710
+ }
711
+ // Rewrite to .js (module) or .shared.js (app)
712
+ const ext = getCompiledExtension(importPath);
713
+ node.source = node.source.replace('.tova', ext);
714
+ } else {
715
+ // Same-directory import — remove it since files are merged
716
+ node._removed = true;
717
+ }
718
+ }
719
+ }
720
+
721
+ parsedFiles.push({ file, source, ast });
722
+ }
723
+
724
+ // Merge ASTs: collect blocks from all files, tagged with source file
725
+ const mergedBody = [];
726
+ const sharedBlocks = [];
727
+ const serverBlocks = [];
728
+ const browserBlocks = [];
729
+
730
+ for (const { file, ast } of parsedFiles) {
731
+ for (const node of ast.body) {
732
+ // Skip removed same-directory imports
733
+ if (node._removed) continue;
734
+
735
+ // Tag node with source file for source maps and error reporting
736
+ if (node.loc) node.loc.source = file;
737
+ else node.loc = { line: 1, column: 0, source: file };
738
+
739
+ // Tag children too
740
+ if (node.body && Array.isArray(node.body)) {
741
+ for (const child of node.body) {
742
+ if (child.loc) child.loc.source = file;
743
+ else child.loc = { line: 1, column: 0, source: file };
744
+ }
745
+ }
746
+
747
+ if (node.type === 'SharedBlock') sharedBlocks.push(node);
748
+ else if (node.type === 'ServerBlock') serverBlocks.push(node);
749
+ else if (node.type === 'BrowserBlock') browserBlocks.push(node);
750
+
751
+ mergedBody.push(node);
752
+ }
753
+ }
754
+
755
+ // Validate for duplicate declarations across files
756
+ validateMergedAST({ sharedBlocks, serverBlocks, browserBlocks }, tovaFiles);
757
+
758
+ // Build merged Program AST
759
+ const mergedAST = new Program(mergedBody);
760
+
761
+ // Run analyzer on merged AST
762
+ const analyzer = new Analyzer(mergedAST, dir, { strict: options.strict, strictSecurity: options.strictSecurity });
763
+ const { warnings } = analyzer.analyze();
764
+
765
+ if (warnings.length > 0) {
766
+ for (const w of warnings) {
767
+ console.warn(` Warning: ${w.message} (line ${w.line})`);
768
+ }
769
+ }
770
+
771
+ // Run codegen on merged AST
772
+ const codegen = new CodeGenerator(mergedAST, dir, { isDev: options.isDev });
773
+ const output = codegen.generate();
774
+
775
+ // Collect source content from all files for source maps
776
+ const sourceContents = new Map();
777
+ for (const { file, source } of parsedFiles) {
778
+ sourceContents.set(file, source);
779
+ }
780
+ output._sourceContents = sourceContents;
781
+ output._sourceFiles = tovaFiles;
782
+
783
+ // Extract security info for scorecard
784
+ const hasServer = mergedBody.some(n => n.type === 'ServerBlock');
785
+ const hasEdge = mergedBody.some(n => n.type === 'EdgeBlock');
786
+ const securityNode = mergedBody.find(n => n.type === 'SecurityBlock');
787
+ let securityConfig = null;
788
+ if (securityNode) {
789
+ securityConfig = {};
790
+ for (const child of securityNode.body || []) {
791
+ if (child.type === 'AuthDeclaration') securityConfig.auth = { authType: child.authType || 'jwt', storage: child.config?.storage?.value };
792
+ else if (child.type === 'CsrfDeclaration') securityConfig.csrf = { enabled: child.config?.enabled?.value !== false };
793
+ else if (child.type === 'RateLimitDeclaration') securityConfig.rateLimit = { max: child.config?.max?.value };
794
+ else if (child.type === 'CspDeclaration') securityConfig.csp = { default_src: true };
795
+ else if (child.type === 'CorsDeclaration') {
796
+ const origins = child.config?.origins;
797
+ securityConfig.cors = { origins: origins ? (origins.elements || []).map(e => e.value) : [] };
798
+ }
799
+ else if (child.type === 'AuditDeclaration') securityConfig.audit = { events: ['auth'] };
800
+ }
801
+ }
802
+
803
+ return { output, files: tovaFiles, single: false, warnings, securityConfig, hasServer, hasEdge };
804
+ }
805
+
806
+ // Group .tova files by their parent directory
807
+ export function groupFilesByDirectory(files) {
808
+ const groups = new Map();
809
+ for (const file of files) {
810
+ const dir = dirname(file);
811
+ if (!groups.has(dir)) groups.set(dir, []);
812
+ groups.get(dir).push(file);
813
+ }
814
+ return groups;
815
+ }