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.
- package/bin/tova.js +93 -6604
- package/package.json +1 -1
- package/src/analyzer/analyzer.js +3 -812
- package/src/analyzer/auth-analyzer.js +123 -0
- package/src/analyzer/cli-analyzer.js +91 -0
- package/src/analyzer/concurrency-analyzer.js +126 -0
- package/src/analyzer/edge-analyzer.js +215 -0
- package/src/analyzer/security-analyzer.js +297 -0
- package/src/cli/build.js +1044 -0
- package/src/cli/check.js +154 -0
- package/src/cli/compile.js +815 -0
- package/src/cli/completions.js +192 -0
- package/src/cli/deploy.js +15 -0
- package/src/cli/dev.js +612 -0
- package/src/cli/doctor.js +130 -0
- package/src/cli/format.js +52 -0
- package/src/cli/info.js +72 -0
- package/src/cli/migrate.js +386 -0
- package/src/cli/new.js +1435 -0
- package/src/cli/package.js +452 -0
- package/src/cli/repl.js +437 -0
- package/src/cli/run.js +137 -0
- package/src/cli/test.js +255 -0
- package/src/cli/upgrade.js +261 -0
- package/src/cli/utils.js +190 -0
- package/src/codegen/codegen.js +51 -17
- package/src/registry/plugins/auth-plugin.js +13 -2
- package/src/registry/plugins/cli-plugin.js +13 -2
- package/src/registry/plugins/concurrency-plugin.js +4 -0
- package/src/registry/plugins/edge-plugin.js +13 -2
- package/src/registry/plugins/security-plugin.js +13 -2
- package/src/version.js +1 -1
|
@@ -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
|
+
}
|