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,123 @@
1
+ // Auth-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when auth { } blocks are encountered.
3
+
4
+ export function installAuthAnalyzer(AnalyzerClass) {
5
+ if (AnalyzerClass.prototype._authAnalyzerInstalled) return;
6
+ AnalyzerClass.prototype._authAnalyzerInstalled = true;
7
+
8
+ AnalyzerClass.prototype.visitAuthBlock = function(node) {
9
+ const validHookEvents = new Set(['signup', 'login', 'logout', 'oauth_link']);
10
+ const providerTypes = new Set();
11
+ let hasProvider = false;
12
+
13
+ for (const stmt of node.body) {
14
+ if (stmt.type === 'AuthConfigField') {
15
+ if (stmt.key === 'secret' && stmt.value.type === 'StringLiteral') {
16
+ this.warnings.push({
17
+ message: 'Auth secret should use env() — hardcoded secrets are insecure',
18
+ loc: stmt.loc, code: 'W_AUTH_HARDCODED_SECRET', category: 'auth',
19
+ });
20
+ }
21
+ if (stmt.key === 'token_expires' && stmt.value.type === 'NumberLiteral' && stmt.value.value < 300) {
22
+ this.warnings.push({
23
+ message: 'Access token expires too quickly (< 5 minutes) — may cause frequent logouts',
24
+ loc: stmt.loc, code: 'W_AUTH_SHORT_TOKEN', category: 'auth',
25
+ });
26
+ }
27
+ if (stmt.key === 'refresh_expires' && stmt.value.type === 'NumberLiteral' && stmt.value.value > 2592000) {
28
+ this.warnings.push({
29
+ message: 'Refresh token lives longer than 30 days — consider shorter lifetime',
30
+ loc: stmt.loc, code: 'W_AUTH_LONG_REFRESH', category: 'auth',
31
+ });
32
+ }
33
+ if (stmt.key === 'storage' && stmt.value.type === 'StringLiteral' && stmt.value.value === 'local') {
34
+ this.warnings.push({
35
+ message: 'localStorage tokens are vulnerable to XSS — prefer storage: "cookie"',
36
+ loc: stmt.loc, code: 'W_AUTH_LOCAL_STORAGE', category: 'auth',
37
+ });
38
+ }
39
+ }
40
+
41
+ if (stmt.type === 'AuthProviderDeclaration') {
42
+ hasProvider = true;
43
+ const key = stmt.providerType + (stmt.name ? ':' + stmt.name : '');
44
+ if (providerTypes.has(key)) {
45
+ this.warnings.push({
46
+ message: `Duplicate auth provider '${key}'`,
47
+ loc: stmt.loc, code: 'W_AUTH_DUPLICATE_PROVIDER', category: 'auth',
48
+ });
49
+ }
50
+ providerTypes.add(key);
51
+
52
+ if (stmt.providerType === 'email') {
53
+ if (!stmt.config.confirm_email) {
54
+ this.warnings.push({
55
+ message: 'Email provider without confirm_email — consider requiring email verification',
56
+ loc: stmt.loc, code: 'W_AUTH_NO_CONFIRM', category: 'auth',
57
+ });
58
+ }
59
+ if (stmt.config.password_min && stmt.config.password_min.type === 'NumberLiteral' && stmt.config.password_min.value < 8) {
60
+ this.warnings.push({
61
+ message: 'Minimum password length less than 8 — weak passwords allowed',
62
+ loc: stmt.loc, code: 'W_AUTH_WEAK_PASSWORD', category: 'auth',
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ if (stmt.type === 'AuthHookDeclaration') {
69
+ if (!validHookEvents.has(stmt.event)) {
70
+ this.warnings.push({
71
+ message: `Unknown auth hook event '${stmt.event}' — valid: ${[...validHookEvents].join(', ')}`,
72
+ loc: stmt.loc, code: 'W_AUTH_UNKNOWN_HOOK', category: 'auth',
73
+ });
74
+ }
75
+ }
76
+
77
+ if (stmt.type === 'AuthProtectedRoute') {
78
+ if (!stmt.config.redirect) {
79
+ this.warnings.push({
80
+ message: `Protected route '${stmt.pattern}' has no redirect`,
81
+ loc: stmt.loc, code: 'W_AUTH_PROTECTED_NO_REDIRECT', category: 'auth',
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ if (!hasProvider) {
88
+ this.warnings.push({
89
+ message: 'Auth block has no providers — add at least one',
90
+ loc: node.loc, code: 'W_AUTH_MISSING_PROVIDER', category: 'auth',
91
+ });
92
+ }
93
+ };
94
+
95
+ AnalyzerClass.prototype._validateAuthCrossBlock = function() {
96
+ const securityRoles = new Set();
97
+ for (const node of this.ast.body) {
98
+ if (node.type === 'SecurityBlock') {
99
+ for (const stmt of node.body) {
100
+ if (stmt.type === 'SecurityRoleDeclaration') {
101
+ securityRoles.add(stmt.name);
102
+ }
103
+ }
104
+ }
105
+ }
106
+
107
+ for (const node of this.ast.body) {
108
+ if (node.type === 'AuthBlock') {
109
+ for (const stmt of node.body) {
110
+ if (stmt.type === 'AuthProtectedRoute' && stmt.config.require) {
111
+ const roleName = stmt.config.require.type === 'Identifier' ? stmt.config.require.name : null;
112
+ if (roleName && securityRoles.size > 0 && !securityRoles.has(roleName)) {
113
+ this.warnings.push({
114
+ message: `Protected route requires role '${roleName}' not defined in security block`,
115
+ loc: stmt.loc, code: 'W_AUTH_UNKNOWN_ROLE', category: 'auth',
116
+ });
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ };
123
+ }
@@ -0,0 +1,91 @@
1
+ // CLI-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when cli { } blocks are encountered.
3
+
4
+ import { Symbol } from './scope.js';
5
+
6
+ export function installCliAnalyzer(AnalyzerClass) {
7
+ if (AnalyzerClass.prototype._cliAnalyzerInstalled) return;
8
+ AnalyzerClass.prototype._cliAnalyzerInstalled = true;
9
+
10
+ AnalyzerClass.prototype.visitCliBlock = function(node) {
11
+ const validKeys = new Set(['name', 'version', 'description']);
12
+
13
+ // Validate config keys
14
+ for (const field of node.config) {
15
+ if (!validKeys.has(field.key)) {
16
+ this.warnings.push({
17
+ message: `Unknown cli config key '${field.key}' — valid keys are: ${[...validKeys].join(', ')}`,
18
+ loc: field.loc,
19
+ code: 'W_UNKNOWN_CLI_CONFIG',
20
+ });
21
+ }
22
+ }
23
+
24
+ // Validate commands
25
+ const commandNames = new Set();
26
+ for (const cmd of node.commands) {
27
+ // Duplicate command names
28
+ if (commandNames.has(cmd.name)) {
29
+ this.warnings.push({
30
+ message: `Duplicate cli command '${cmd.name}'`,
31
+ loc: cmd.loc,
32
+ code: 'W_DUPLICATE_CLI_COMMAND',
33
+ });
34
+ }
35
+ commandNames.add(cmd.name);
36
+
37
+ // Check for positional args after flags
38
+ let seenFlag = false;
39
+ for (const param of cmd.params) {
40
+ if (param.isFlag) {
41
+ seenFlag = true;
42
+ } else if (seenFlag) {
43
+ this.warnings.push({
44
+ message: `Positional argument '${param.name}' after flag in command '${cmd.name}' — positionals should come before flags`,
45
+ loc: param.loc,
46
+ code: 'W_POSITIONAL_AFTER_FLAG',
47
+ });
48
+ }
49
+ }
50
+
51
+ // Visit command body with params in scope
52
+ this.pushScope('function');
53
+ for (const param of cmd.params) {
54
+ this.currentScope.define(param.name,
55
+ new Symbol(param.name, 'parameter', null, false, param.loc));
56
+ }
57
+ this.visitNode(cmd.body);
58
+ this.popScope();
59
+ }
60
+ };
61
+
62
+ AnalyzerClass.prototype._validateCliCrossBlock = function() {
63
+ const cliBlocks = this.ast.body.filter(n => n.type === 'CliBlock');
64
+ if (cliBlocks.length === 0) return;
65
+
66
+ // Warn if cli + server coexist
67
+ const hasServer = this.ast.body.some(n => n.type === 'ServerBlock');
68
+ if (hasServer) {
69
+ this.warnings.push({
70
+ message: 'cli {} and server {} blocks in the same file — cli produces a standalone executable, not a web server',
71
+ loc: cliBlocks[0].loc,
72
+ code: 'W_CLI_WITH_SERVER',
73
+ });
74
+ }
75
+
76
+ // Check for missing name across all cli blocks
77
+ let hasName = false;
78
+ for (const block of cliBlocks) {
79
+ for (const field of block.config) {
80
+ if (field.key === 'name') hasName = true;
81
+ }
82
+ }
83
+ if (!hasName) {
84
+ this.warnings.push({
85
+ message: 'cli block has no name: field — consider adding name: "your-tool"',
86
+ loc: cliBlocks[0].loc,
87
+ code: 'W_CLI_MISSING_NAME',
88
+ });
89
+ }
90
+ };
91
+ }
@@ -0,0 +1,126 @@
1
+ // Concurrency-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when concurrent { } blocks are encountered.
3
+
4
+ import { Symbol } from './scope.js';
5
+
6
+ export function installConcurrencyAnalyzer(AnalyzerClass) {
7
+ if (AnalyzerClass.prototype._concurrencyAnalyzerInstalled) return;
8
+ AnalyzerClass.prototype._concurrencyAnalyzerInstalled = true;
9
+
10
+ AnalyzerClass.prototype.visitConcurrentBlock = function(node) {
11
+ // Validate mode
12
+ const validModes = new Set(['all', 'cancel_on_error', 'first', 'timeout']);
13
+ if (!validModes.has(node.mode)) {
14
+ this.warn(`Unknown concurrent block mode '${node.mode}'`, node.loc, null, {
15
+ code: 'W_UNKNOWN_CONCURRENT_MODE',
16
+ });
17
+ }
18
+
19
+ // Validate timeout
20
+ if (node.mode === 'timeout' && !node.timeout) {
21
+ this.warn("concurrent timeout mode requires a timeout value", node.loc, null, {
22
+ code: 'W_MISSING_TIMEOUT',
23
+ });
24
+ }
25
+
26
+ // Warn on empty block
27
+ if (node.body.length === 0) {
28
+ this.warn("Empty concurrent block", node.loc, null, {
29
+ code: 'W_EMPTY_CONCURRENT',
30
+ });
31
+ }
32
+
33
+ // Track concurrent depth for spawn validation
34
+ this._concurrentDepth = (this._concurrentDepth || 0) + 1;
35
+
36
+ // Visit body statements (concurrent block does NOT create a new scope —
37
+ // variables assigned inside should be visible after the block)
38
+ for (const stmt of node.body) {
39
+ this.visitNode(stmt);
40
+ }
41
+
42
+ // Check spawned functions for WASM compatibility — warn if mixed WASM/non-WASM
43
+ let hasWasm = false;
44
+ let hasNonWasm = false;
45
+ for (const stmt of node.body) {
46
+ const spawn = (stmt.type === 'Assignment' && stmt.values && stmt.values[0] && stmt.values[0].type === 'SpawnExpression')
47
+ ? stmt.values[0]
48
+ : (stmt.type === 'ExpressionStatement' && stmt.expression && stmt.expression.type === 'SpawnExpression')
49
+ ? stmt.expression
50
+ : null;
51
+ if (!spawn) continue;
52
+ const calleeName = spawn.callee && spawn.callee.type === 'Identifier' ? spawn.callee.name : null;
53
+ if (calleeName) {
54
+ const sym = this.currentScope.lookup(calleeName);
55
+ if (sym && sym.isWasm) {
56
+ hasWasm = true;
57
+ } else {
58
+ hasNonWasm = true;
59
+ }
60
+ } else {
61
+ // Lambda or complex expression — always non-WASM
62
+ hasNonWasm = true;
63
+ }
64
+ }
65
+ if (hasWasm && hasNonWasm) {
66
+ this.warn(
67
+ "concurrent block mixes @wasm and non-WASM tasks — non-WASM tasks will fall back to async JS execution",
68
+ node.loc, null, { code: 'W_SPAWN_WASM_FALLBACK' }
69
+ );
70
+ }
71
+
72
+ this._concurrentDepth--;
73
+ };
74
+
75
+ AnalyzerClass.prototype.visitSelectStatement = function(node) {
76
+ if (node.cases.length === 0) {
77
+ this.warn("Empty select block", node.loc, null, {
78
+ code: 'W_EMPTY_SELECT',
79
+ });
80
+ }
81
+
82
+ let defaultCount = 0;
83
+ let timeoutCount = 0;
84
+ for (const c of node.cases) {
85
+ if (c.kind === 'default') defaultCount++;
86
+ if (c.kind === 'timeout') timeoutCount++;
87
+ }
88
+
89
+ if (defaultCount > 1) {
90
+ this.warn("select block has multiple default cases", node.loc, null, {
91
+ code: 'W_DUPLICATE_SELECT_DEFAULT',
92
+ });
93
+ }
94
+ if (timeoutCount > 1) {
95
+ this.warn("select block has multiple timeout cases", node.loc, null, {
96
+ code: 'W_DUPLICATE_SELECT_TIMEOUT',
97
+ });
98
+ }
99
+ if (defaultCount > 0 && timeoutCount > 0) {
100
+ this.warn("select block has both default and timeout — default makes timeout unreachable", node.loc, null, {
101
+ code: 'W_SELECT_DEFAULT_TIMEOUT',
102
+ });
103
+ }
104
+
105
+ // Visit each case's expressions and body
106
+ for (const c of node.cases) {
107
+ if (c.channel) this.visitNode(c.channel);
108
+ if (c.value) this.visitNode(c.value);
109
+
110
+ if (c.kind === 'receive' && c.binding) {
111
+ // Create scope for the binding variable
112
+ this.pushScope('select-case');
113
+ this.currentScope.define(c.binding,
114
+ new Symbol(c.binding, 'variable', null, false, c.loc));
115
+ for (const stmt of c.body) {
116
+ this.visitNode(stmt);
117
+ }
118
+ this.popScope();
119
+ } else {
120
+ for (const stmt of c.body) {
121
+ this.visitNode(stmt);
122
+ }
123
+ }
124
+ }
125
+ };
126
+ }
@@ -0,0 +1,215 @@
1
+ // Edge-specific analyzer methods for the Tova language
2
+ // Extracted from analyzer.js for lazy loading — only loaded when edge { } blocks are encountered.
3
+
4
+ export function installEdgeAnalyzer(AnalyzerClass) {
5
+ if (AnalyzerClass.prototype._edgeAnalyzerInstalled) return;
6
+ AnalyzerClass.prototype._edgeAnalyzerInstalled = true;
7
+
8
+ AnalyzerClass.prototype.visitEdgeBlock = function(node) {
9
+ const validTargets = new Set(['cloudflare', 'deno', 'vercel', 'lambda', 'bun']);
10
+ const validConfigKeys = new Set(['target']);
11
+ const bindingNames = new Set();
12
+
13
+ // Binding support matrix per target
14
+ const BINDING_SUPPORT = {
15
+ cloudflare: { kv: true, sql: true, storage: true, queue: true },
16
+ deno: { kv: true, sql: false, storage: false, queue: false },
17
+ vercel: { kv: false, sql: false, storage: false, queue: false },
18
+ lambda: { kv: false, sql: false, storage: false, queue: false },
19
+ bun: { kv: false, sql: true, storage: false, queue: false },
20
+ };
21
+
22
+ // Targets that support schedule/consume/middleware
23
+ const SCHEDULE_TARGETS = new Set(['cloudflare', 'deno']);
24
+ const CONSUME_TARGETS = new Set(['cloudflare']);
25
+
26
+ // Determine target from config fields
27
+ let target = 'cloudflare';
28
+ for (const stmt of node.body) {
29
+ if (stmt.type === 'EdgeConfigField' && stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
30
+ target = stmt.value.value;
31
+ }
32
+ }
33
+
34
+ this.pushScope('edge');
35
+
36
+ let kvCount = 0;
37
+ const queueNames = new Set();
38
+ const consumers = [];
39
+
40
+ for (const stmt of node.body) {
41
+ // Validate config fields
42
+ if (stmt.type === 'EdgeConfigField') {
43
+ if (!validConfigKeys.has(stmt.key)) {
44
+ this.warnings.push({
45
+ message: `Unknown edge config key '${stmt.key}' — valid keys are: ${[...validConfigKeys].join(', ')}`,
46
+ loc: stmt.loc,
47
+ code: 'W_UNKNOWN_EDGE_CONFIG',
48
+ });
49
+ }
50
+ if (stmt.key === 'target' && stmt.value.type === 'StringLiteral') {
51
+ if (!validTargets.has(stmt.value.value)) {
52
+ this.warnings.push({
53
+ message: `Unknown edge target '${stmt.value.value}' — valid targets are: ${[...validTargets].join(', ')}`,
54
+ loc: stmt.loc,
55
+ code: 'W_UNKNOWN_EDGE_TARGET',
56
+ });
57
+ }
58
+ }
59
+ continue;
60
+ }
61
+
62
+ // Check for duplicate binding names
63
+ if (stmt.type === 'EdgeKVDeclaration' || stmt.type === 'EdgeSQLDeclaration' ||
64
+ stmt.type === 'EdgeStorageDeclaration' || stmt.type === 'EdgeQueueDeclaration') {
65
+ if (bindingNames.has(stmt.name)) {
66
+ this.warnings.push({
67
+ message: `Duplicate edge binding '${stmt.name}'`,
68
+ loc: stmt.loc,
69
+ code: 'W_DUPLICATE_EDGE_BINDING',
70
+ });
71
+ }
72
+ bindingNames.add(stmt.name);
73
+ }
74
+
75
+ // Track queue names for consume validation
76
+ if (stmt.type === 'EdgeQueueDeclaration') {
77
+ queueNames.add(stmt.name);
78
+ }
79
+
80
+ // Check for duplicate env/secret names
81
+ if (stmt.type === 'EdgeEnvDeclaration' || stmt.type === 'EdgeSecretDeclaration') {
82
+ if (bindingNames.has(stmt.name)) {
83
+ this.warnings.push({
84
+ message: `Duplicate edge binding '${stmt.name}'`,
85
+ loc: stmt.loc,
86
+ code: 'W_DUPLICATE_EDGE_BINDING',
87
+ });
88
+ }
89
+ bindingNames.add(stmt.name);
90
+ }
91
+
92
+ // Unsupported binding warnings (per target)
93
+ const support = BINDING_SUPPORT[target] || BINDING_SUPPORT.cloudflare;
94
+ if (stmt.type === 'EdgeKVDeclaration' && !support.kv) {
95
+ this.warnings.push({
96
+ message: `KV binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
97
+ loc: stmt.loc,
98
+ code: 'W_UNSUPPORTED_KV',
99
+ });
100
+ }
101
+ if (stmt.type === 'EdgeSQLDeclaration' && !support.sql) {
102
+ this.warnings.push({
103
+ message: `SQL binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
104
+ loc: stmt.loc,
105
+ code: 'W_UNSUPPORTED_SQL',
106
+ });
107
+ }
108
+ if (stmt.type === 'EdgeStorageDeclaration' && !support.storage) {
109
+ this.warnings.push({
110
+ message: `Storage binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
111
+ loc: stmt.loc,
112
+ code: 'W_UNSUPPORTED_STORAGE',
113
+ });
114
+ }
115
+ if (stmt.type === 'EdgeQueueDeclaration' && !support.queue) {
116
+ this.warnings.push({
117
+ message: `Queue binding '${stmt.name}' is not supported on target '${target}' — it will be stubbed as null`,
118
+ loc: stmt.loc,
119
+ code: 'W_UNSUPPORTED_QUEUE',
120
+ });
121
+ }
122
+
123
+ // Deno multi-KV warning
124
+ if (stmt.type === 'EdgeKVDeclaration') {
125
+ kvCount++;
126
+ if (kvCount > 1 && target === 'deno') {
127
+ this.warnings.push({
128
+ message: `Deno Deploy supports only one KV store — '${stmt.name}' will share the same store as the first KV binding`,
129
+ loc: stmt.loc,
130
+ code: 'W_DENO_MULTI_KV',
131
+ });
132
+ }
133
+ }
134
+
135
+ // Validate schedule cron expressions + target support
136
+ if (stmt.type === 'EdgeScheduleDeclaration') {
137
+ const parts = stmt.cron.split(/\s+/);
138
+ if (parts.length < 5 || parts.length > 6) {
139
+ this.warnings.push({
140
+ message: `Invalid cron expression '${stmt.cron}' — expected 5 or 6 space-separated fields`,
141
+ loc: stmt.loc,
142
+ code: 'W_INVALID_CRON',
143
+ });
144
+ }
145
+ if (!SCHEDULE_TARGETS.has(target)) {
146
+ this.warnings.push({
147
+ message: `Scheduled tasks are not supported on target '${target}' — schedule '${stmt.name}' will be ignored. Supported targets: ${[...SCHEDULE_TARGETS].join(', ')}`,
148
+ loc: stmt.loc,
149
+ code: 'W_UNSUPPORTED_SCHEDULE',
150
+ });
151
+ }
152
+ }
153
+
154
+ // Collect consume declarations for post-loop validation
155
+ if (stmt.type === 'EdgeConsumeDeclaration') {
156
+ consumers.push(stmt);
157
+ if (!CONSUME_TARGETS.has(target)) {
158
+ this.warnings.push({
159
+ message: `Queue consumers are not supported on target '${target}' — consume '${stmt.queue}' will be ignored. Supported targets: ${[...CONSUME_TARGETS].join(', ')}`,
160
+ loc: stmt.loc,
161
+ code: 'W_UNSUPPORTED_CONSUME',
162
+ });
163
+ }
164
+ }
165
+
166
+ // Visit child nodes — edge-specific types are noop in the registry,
167
+ // so explicitly visit bodies that contain statements
168
+ if (stmt.type === 'EdgeScheduleDeclaration' && stmt.body) {
169
+ for (const s of stmt.body.body || []) this.visitNode(s);
170
+ } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'RouteDeclaration') {
171
+ this.visitNode(stmt);
172
+ }
173
+ }
174
+
175
+ // Post-loop: validate consume references a declared queue
176
+ for (const consumer of consumers) {
177
+ if (!queueNames.has(consumer.queue)) {
178
+ this.warnings.push({
179
+ message: `consume '${consumer.queue}' references undeclared queue binding — add 'queue ${consumer.queue}' to the edge block`,
180
+ loc: consumer.loc,
181
+ code: 'W_CONSUME_UNKNOWN_QUEUE',
182
+ });
183
+ }
184
+ }
185
+
186
+ // Warn if edge block has no route or schedule handlers
187
+ const hasRoutes = node.body.some(s => s.type === 'RouteDeclaration');
188
+ const hasSchedules = node.body.some(s => s.type === 'EdgeScheduleDeclaration');
189
+ const hasConsumers = consumers.length > 0;
190
+ if (!hasRoutes && !hasSchedules && !hasConsumers) {
191
+ this.warnings.push({
192
+ message: 'edge block has no routes, schedules, or consumers — it will produce no handlers',
193
+ loc: node.loc,
194
+ code: 'W_EDGE_NO_HANDLERS',
195
+ });
196
+ }
197
+
198
+ this.popScope();
199
+ };
200
+
201
+ AnalyzerClass.prototype._validateEdgeCrossBlock = function() {
202
+ const edgeBlocks = this.ast.body.filter(n => n.type === 'EdgeBlock');
203
+ if (edgeBlocks.length === 0) return;
204
+
205
+ // Warn if edge + cli coexist (cli takes over with earlyReturn)
206
+ const hasCli = this.ast.body.some(n => n.type === 'CliBlock');
207
+ if (hasCli) {
208
+ this.warnings.push({
209
+ message: 'edge {} and cli {} blocks in the same file — cli produces a standalone executable, edge block will be ignored',
210
+ loc: edgeBlocks[0].loc,
211
+ code: 'W_EDGE_WITH_CLI',
212
+ });
213
+ }
214
+ };
215
+ }