pulse-js-framework 1.0.0 → 1.4.0
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/README.md +414 -182
- package/cli/analyze.js +499 -0
- package/cli/build.js +341 -199
- package/cli/format.js +704 -0
- package/cli/index.js +398 -324
- package/cli/lint.js +642 -0
- package/cli/mobile.js +1473 -0
- package/cli/utils/file-utils.js +298 -0
- package/compiler/lexer.js +766 -581
- package/compiler/parser.js +1797 -900
- package/compiler/transformer.js +1332 -552
- package/index.js +1 -1
- package/mobile/bridge/pulse-native.js +420 -0
- package/package.json +68 -58
- package/runtime/dom.js +363 -33
- package/runtime/index.js +2 -0
- package/runtime/native.js +368 -0
- package/runtime/pulse.js +247 -13
- package/runtime/router.js +596 -392
package/cli/lint.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse CLI - Lint Command
|
|
3
|
+
* Validates .pulse files for errors and style issues
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { findPulseFiles, parseArgs, relativePath } from './utils/file-utils.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Lint rules configuration
|
|
11
|
+
*/
|
|
12
|
+
export const LintRules = {
|
|
13
|
+
// Semantic rules (errors)
|
|
14
|
+
'undefined-reference': { severity: 'error', fixable: false },
|
|
15
|
+
'duplicate-declaration': { severity: 'error', fixable: false },
|
|
16
|
+
|
|
17
|
+
// Usage rules (warnings)
|
|
18
|
+
'unused-import': { severity: 'warning', fixable: true },
|
|
19
|
+
'unused-state': { severity: 'warning', fixable: false },
|
|
20
|
+
'unused-action': { severity: 'warning', fixable: false },
|
|
21
|
+
|
|
22
|
+
// Style rules (info)
|
|
23
|
+
'naming-page': { severity: 'info', fixable: false },
|
|
24
|
+
'naming-state': { severity: 'info', fixable: false },
|
|
25
|
+
'empty-block': { severity: 'info', fixable: false },
|
|
26
|
+
'import-order': { severity: 'info', fixable: true }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Symbol table for tracking declarations and references
|
|
31
|
+
*/
|
|
32
|
+
class SymbolTable {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.imports = new Map(); // name -> { source, line, column, used }
|
|
35
|
+
this.state = new Map(); // name -> { line, column, used }
|
|
36
|
+
this.actions = new Map(); // name -> { line, column, used }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declareImport(name, source, line, column) {
|
|
40
|
+
if (this.imports.has(name)) {
|
|
41
|
+
return { error: 'duplicate', existing: this.imports.get(name) };
|
|
42
|
+
}
|
|
43
|
+
this.imports.set(name, { source, line, column, used: false });
|
|
44
|
+
return { success: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
declareState(name, line, column) {
|
|
48
|
+
if (this.state.has(name)) {
|
|
49
|
+
return { error: 'duplicate', existing: this.state.get(name) };
|
|
50
|
+
}
|
|
51
|
+
this.state.set(name, { line, column, used: false });
|
|
52
|
+
return { success: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
declareAction(name, line, column) {
|
|
56
|
+
if (this.actions.has(name)) {
|
|
57
|
+
return { error: 'duplicate', existing: this.actions.get(name) };
|
|
58
|
+
}
|
|
59
|
+
this.actions.set(name, { line, column, used: false });
|
|
60
|
+
return { success: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
reference(name) {
|
|
64
|
+
// Check state first
|
|
65
|
+
if (this.state.has(name)) {
|
|
66
|
+
this.state.get(name).used = true;
|
|
67
|
+
return { found: true, kind: 'state' };
|
|
68
|
+
}
|
|
69
|
+
// Check actions
|
|
70
|
+
if (this.actions.has(name)) {
|
|
71
|
+
this.actions.get(name).used = true;
|
|
72
|
+
return { found: true, kind: 'action' };
|
|
73
|
+
}
|
|
74
|
+
// Check imports
|
|
75
|
+
if (this.imports.has(name)) {
|
|
76
|
+
this.imports.get(name).used = true;
|
|
77
|
+
return { found: true, kind: 'import' };
|
|
78
|
+
}
|
|
79
|
+
return { found: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
getUnused() {
|
|
83
|
+
const unused = [];
|
|
84
|
+
for (const [name, info] of this.imports) {
|
|
85
|
+
if (!info.used) {
|
|
86
|
+
unused.push({ kind: 'import', name, ...info });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const [name, info] of this.state) {
|
|
90
|
+
if (!info.used) {
|
|
91
|
+
unused.push({ kind: 'state', name, ...info });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [name, info] of this.actions) {
|
|
95
|
+
if (!info.used) {
|
|
96
|
+
unused.push({ kind: 'action', name, ...info });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return unused;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Semantic analyzer for .pulse files
|
|
105
|
+
*/
|
|
106
|
+
export class SemanticAnalyzer {
|
|
107
|
+
constructor(ast, source) {
|
|
108
|
+
this.ast = ast;
|
|
109
|
+
this.source = source;
|
|
110
|
+
this.symbols = new SymbolTable();
|
|
111
|
+
this.diagnostics = [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Run all analysis passes
|
|
116
|
+
*/
|
|
117
|
+
analyze() {
|
|
118
|
+
// Phase 1: Collect declarations
|
|
119
|
+
this.collectDeclarations();
|
|
120
|
+
|
|
121
|
+
// Phase 2: Check references in view/actions
|
|
122
|
+
this.checkReferences();
|
|
123
|
+
|
|
124
|
+
// Phase 3: Check unused symbols
|
|
125
|
+
this.checkUnused();
|
|
126
|
+
|
|
127
|
+
// Phase 4: Style checks
|
|
128
|
+
this.checkStyle();
|
|
129
|
+
|
|
130
|
+
return this.diagnostics;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Collect all declarations from AST
|
|
135
|
+
*/
|
|
136
|
+
collectDeclarations() {
|
|
137
|
+
// Process imports
|
|
138
|
+
for (const imp of this.ast.imports || []) {
|
|
139
|
+
for (const spec of imp.specifiers || []) {
|
|
140
|
+
const result = this.symbols.declareImport(
|
|
141
|
+
spec.local,
|
|
142
|
+
imp.source,
|
|
143
|
+
imp.line || 1,
|
|
144
|
+
imp.column || 1
|
|
145
|
+
);
|
|
146
|
+
if (result.error === 'duplicate') {
|
|
147
|
+
this.addDiagnostic('error', 'duplicate-declaration',
|
|
148
|
+
`'${spec.local}' is already declared`,
|
|
149
|
+
imp.line || 1, imp.column || 1);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Process state block
|
|
155
|
+
if (this.ast.state && this.ast.state.properties) {
|
|
156
|
+
for (const prop of this.ast.state.properties) {
|
|
157
|
+
const result = this.symbols.declareState(
|
|
158
|
+
prop.name,
|
|
159
|
+
prop.line || 1,
|
|
160
|
+
prop.column || 1
|
|
161
|
+
);
|
|
162
|
+
if (result.error === 'duplicate') {
|
|
163
|
+
this.addDiagnostic('error', 'duplicate-declaration',
|
|
164
|
+
`State variable '${prop.name}' is already declared`,
|
|
165
|
+
prop.line || 1, prop.column || 1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Process actions block
|
|
171
|
+
if (this.ast.actions && this.ast.actions.functions) {
|
|
172
|
+
for (const fn of this.ast.actions.functions) {
|
|
173
|
+
const result = this.symbols.declareAction(
|
|
174
|
+
fn.name,
|
|
175
|
+
fn.line || 1,
|
|
176
|
+
fn.column || 1
|
|
177
|
+
);
|
|
178
|
+
if (result.error === 'duplicate') {
|
|
179
|
+
this.addDiagnostic('error', 'duplicate-declaration',
|
|
180
|
+
`Action '${fn.name}' is already declared`,
|
|
181
|
+
fn.line || 1, fn.column || 1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Check all references in view and actions
|
|
189
|
+
*/
|
|
190
|
+
checkReferences() {
|
|
191
|
+
// Check view block
|
|
192
|
+
if (this.ast.view) {
|
|
193
|
+
this.checkViewReferences(this.ast.view);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check action bodies (simplified - just look for identifiers)
|
|
197
|
+
if (this.ast.actions && this.ast.actions.functions) {
|
|
198
|
+
for (const fn of this.ast.actions.functions) {
|
|
199
|
+
if (fn.bodyTokens) {
|
|
200
|
+
this.checkTokensForReferences(fn.bodyTokens);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Recursively check references in view block
|
|
208
|
+
*/
|
|
209
|
+
checkViewReferences(node) {
|
|
210
|
+
if (!node) return;
|
|
211
|
+
|
|
212
|
+
// Check children array
|
|
213
|
+
const children = node.children || [];
|
|
214
|
+
for (const child of children) {
|
|
215
|
+
this.checkViewNode(child);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check a single view node
|
|
221
|
+
*/
|
|
222
|
+
checkViewNode(node) {
|
|
223
|
+
if (!node) return;
|
|
224
|
+
|
|
225
|
+
switch (node.type) {
|
|
226
|
+
case 'Element':
|
|
227
|
+
// Check if it's a component reference (starts with uppercase)
|
|
228
|
+
// Extract tag name from selector (e.g., "Button.class#id" -> "Button")
|
|
229
|
+
const selector = node.selector || node.tag || '';
|
|
230
|
+
const tagMatch = selector.match(/^([A-Za-z][A-Za-z0-9]*)/);
|
|
231
|
+
const tagName = tagMatch ? tagMatch[1] : '';
|
|
232
|
+
|
|
233
|
+
if (tagName && /^[A-Z]/.test(tagName)) {
|
|
234
|
+
const ref = this.symbols.reference(tagName);
|
|
235
|
+
if (!ref.found) {
|
|
236
|
+
this.addDiagnostic('error', 'undefined-reference',
|
|
237
|
+
`Component '${tagName}' is not defined. Did you forget to import it?`,
|
|
238
|
+
node.line || 1, node.column || 1);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Check directives
|
|
243
|
+
for (const directive of node.directives || []) {
|
|
244
|
+
this.checkExpression(directive.handler || directive.expression, directive.line, directive.column);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check text content for interpolations
|
|
248
|
+
for (const text of node.textContent || []) {
|
|
249
|
+
if (typeof text === 'object' && text.type === 'Interpolation') {
|
|
250
|
+
this.checkExpression(text.expression, text.line, text.column);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Recurse into children
|
|
255
|
+
this.checkViewReferences(node);
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case 'TextNode':
|
|
259
|
+
// Check for interpolations in text
|
|
260
|
+
if (node.interpolations) {
|
|
261
|
+
for (const interp of node.interpolations) {
|
|
262
|
+
this.checkExpression(interp.expression, interp.line, interp.column);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
break;
|
|
266
|
+
|
|
267
|
+
case 'IfDirective':
|
|
268
|
+
this.checkExpression(node.condition, node.line, node.column);
|
|
269
|
+
this.checkViewReferences(node.consequent);
|
|
270
|
+
if (node.alternate) {
|
|
271
|
+
this.checkViewReferences(node.alternate);
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
|
|
275
|
+
case 'EachDirective':
|
|
276
|
+
// The iterator variable is local scope, but the array should be checked
|
|
277
|
+
this.checkExpression(node.iterable, node.line, node.column);
|
|
278
|
+
// Note: node.item is the loop variable, it's a new declaration
|
|
279
|
+
this.checkViewReferences(node.body);
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'SlotElement':
|
|
283
|
+
// Slots are fine, check fallback content if any
|
|
284
|
+
if (node.fallback) {
|
|
285
|
+
for (const child of node.fallback) {
|
|
286
|
+
this.checkViewNode(child);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
|
|
291
|
+
default:
|
|
292
|
+
// Generic handling for other node types
|
|
293
|
+
if (node.children) {
|
|
294
|
+
this.checkViewReferences(node);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Check an expression for undefined references
|
|
301
|
+
*/
|
|
302
|
+
checkExpression(expr, line, column) {
|
|
303
|
+
if (!expr) return;
|
|
304
|
+
|
|
305
|
+
if (typeof expr === 'string') {
|
|
306
|
+
// Expression as string - extract identifiers
|
|
307
|
+
const identifiers = this.extractIdentifiers(expr);
|
|
308
|
+
for (const id of identifiers) {
|
|
309
|
+
// Skip built-in globals and common patterns
|
|
310
|
+
if (this.isBuiltIn(id)) continue;
|
|
311
|
+
|
|
312
|
+
const ref = this.symbols.reference(id);
|
|
313
|
+
if (!ref.found) {
|
|
314
|
+
this.addDiagnostic('error', 'undefined-reference',
|
|
315
|
+
`'${id}' is not defined`,
|
|
316
|
+
line || 1, column || 1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else if (typeof expr === 'object') {
|
|
320
|
+
// Expression as AST node
|
|
321
|
+
this.checkExpressionNode(expr);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Check an expression AST node
|
|
327
|
+
*/
|
|
328
|
+
checkExpressionNode(node) {
|
|
329
|
+
if (!node) return;
|
|
330
|
+
|
|
331
|
+
switch (node.type) {
|
|
332
|
+
case 'Identifier':
|
|
333
|
+
if (!this.isBuiltIn(node.name)) {
|
|
334
|
+
const ref = this.symbols.reference(node.name);
|
|
335
|
+
if (!ref.found) {
|
|
336
|
+
this.addDiagnostic('error', 'undefined-reference',
|
|
337
|
+
`'${node.name}' is not defined`,
|
|
338
|
+
node.line || 1, node.column || 1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
|
|
343
|
+
case 'MemberExpression':
|
|
344
|
+
// Only check the base object
|
|
345
|
+
this.checkExpressionNode(node.object);
|
|
346
|
+
break;
|
|
347
|
+
|
|
348
|
+
case 'CallExpression':
|
|
349
|
+
this.checkExpressionNode(node.callee);
|
|
350
|
+
for (const arg of node.arguments || []) {
|
|
351
|
+
this.checkExpressionNode(arg);
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
|
|
355
|
+
case 'BinaryExpression':
|
|
356
|
+
case 'LogicalExpression':
|
|
357
|
+
this.checkExpressionNode(node.left);
|
|
358
|
+
this.checkExpressionNode(node.right);
|
|
359
|
+
break;
|
|
360
|
+
|
|
361
|
+
case 'UnaryExpression':
|
|
362
|
+
case 'UpdateExpression':
|
|
363
|
+
this.checkExpressionNode(node.argument);
|
|
364
|
+
break;
|
|
365
|
+
|
|
366
|
+
case 'ConditionalExpression':
|
|
367
|
+
this.checkExpressionNode(node.test);
|
|
368
|
+
this.checkExpressionNode(node.consequent);
|
|
369
|
+
this.checkExpressionNode(node.alternate);
|
|
370
|
+
break;
|
|
371
|
+
|
|
372
|
+
case 'ArrayExpression':
|
|
373
|
+
for (const el of node.elements || []) {
|
|
374
|
+
this.checkExpressionNode(el);
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
|
|
378
|
+
case 'ObjectExpression':
|
|
379
|
+
for (const prop of node.properties || []) {
|
|
380
|
+
this.checkExpressionNode(prop.value);
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Extract identifiers from expression string
|
|
388
|
+
*/
|
|
389
|
+
extractIdentifiers(expr) {
|
|
390
|
+
// Match identifiers (not preceded by . and not part of keywords)
|
|
391
|
+
const identifiers = new Set();
|
|
392
|
+
const regex = /(?<![.\w])([a-zA-Z_$][a-zA-Z0-9_$]*)/g;
|
|
393
|
+
let match;
|
|
394
|
+
|
|
395
|
+
while ((match = regex.exec(expr)) !== null) {
|
|
396
|
+
const id = match[1];
|
|
397
|
+
// Skip keywords and common globals
|
|
398
|
+
if (!this.isKeyword(id)) {
|
|
399
|
+
identifiers.add(id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return identifiers;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Check if identifier is a JavaScript keyword
|
|
408
|
+
*/
|
|
409
|
+
isKeyword(id) {
|
|
410
|
+
const keywords = new Set([
|
|
411
|
+
'true', 'false', 'null', 'undefined', 'NaN', 'Infinity',
|
|
412
|
+
'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break',
|
|
413
|
+
'continue', 'return', 'throw', 'try', 'catch', 'finally',
|
|
414
|
+
'function', 'class', 'const', 'let', 'var', 'new', 'this',
|
|
415
|
+
'typeof', 'instanceof', 'in', 'of', 'delete', 'void'
|
|
416
|
+
]);
|
|
417
|
+
return keywords.has(id);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Check if identifier is a built-in global
|
|
422
|
+
*/
|
|
423
|
+
isBuiltIn(id) {
|
|
424
|
+
const builtIns = new Set([
|
|
425
|
+
'console', 'window', 'document', 'navigator', 'location',
|
|
426
|
+
'localStorage', 'sessionStorage', 'fetch', 'setTimeout', 'setInterval',
|
|
427
|
+
'clearTimeout', 'clearInterval', 'Promise', 'Array', 'Object',
|
|
428
|
+
'String', 'Number', 'Boolean', 'Date', 'Math', 'JSON', 'Map', 'Set',
|
|
429
|
+
'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'encodeURI', 'decodeURI',
|
|
430
|
+
'encodeURIComponent', 'decodeURIComponent', 'alert', 'confirm', 'prompt',
|
|
431
|
+
'event', 'e', 'item', 'index', 'key', 'value' // Common loop/event variables
|
|
432
|
+
]);
|
|
433
|
+
return builtIns.has(id) || this.isKeyword(id);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Check tokens for references (simplified)
|
|
438
|
+
*/
|
|
439
|
+
checkTokensForReferences(tokens) {
|
|
440
|
+
for (const token of tokens) {
|
|
441
|
+
if (token.type === 'IDENTIFIER') {
|
|
442
|
+
// Reference the identifier to mark it as used
|
|
443
|
+
this.symbols.reference(token.value);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Check for unused symbols
|
|
450
|
+
*/
|
|
451
|
+
checkUnused() {
|
|
452
|
+
for (const unused of this.symbols.getUnused()) {
|
|
453
|
+
const code = `unused-${unused.kind}`;
|
|
454
|
+
const message = unused.kind === 'import'
|
|
455
|
+
? `'${unused.name}' is imported but never used`
|
|
456
|
+
: unused.kind === 'state'
|
|
457
|
+
? `State variable '${unused.name}' is declared but never used`
|
|
458
|
+
: `Action '${unused.name}' is declared but never called`;
|
|
459
|
+
|
|
460
|
+
this.addDiagnostic('warning', code, message, unused.line, unused.column);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Check style conventions
|
|
466
|
+
*/
|
|
467
|
+
checkStyle() {
|
|
468
|
+
// Check page name is PascalCase
|
|
469
|
+
if (this.ast.page && this.ast.page.name) {
|
|
470
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(this.ast.page.name)) {
|
|
471
|
+
this.addDiagnostic('info', 'naming-page',
|
|
472
|
+
`Page name '${this.ast.page.name}' should be PascalCase (e.g., 'MyComponent')`,
|
|
473
|
+
this.ast.page.line || 1, this.ast.page.column || 1);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Check state properties are camelCase
|
|
478
|
+
if (this.ast.state && this.ast.state.properties) {
|
|
479
|
+
for (const prop of this.ast.state.properties) {
|
|
480
|
+
if (!/^[a-z][a-zA-Z0-9]*$/.test(prop.name) && !/^[a-z]$/.test(prop.name)) {
|
|
481
|
+
// Allow single lowercase letter
|
|
482
|
+
if (prop.name.length > 1 && /^[A-Z]/.test(prop.name)) {
|
|
483
|
+
this.addDiagnostic('info', 'naming-state',
|
|
484
|
+
`State variable '${prop.name}' should be camelCase (e.g., 'myVariable')`,
|
|
485
|
+
prop.line || 1, prop.column || 1);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Check for empty blocks
|
|
492
|
+
if (this.ast.state && (!this.ast.state.properties || this.ast.state.properties.length === 0)) {
|
|
493
|
+
this.addDiagnostic('info', 'empty-block',
|
|
494
|
+
'Empty state block - consider removing if not needed',
|
|
495
|
+
this.ast.state.line || 1, this.ast.state.column || 1);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (this.ast.view && (!this.ast.view.children || this.ast.view.children.length === 0)) {
|
|
499
|
+
this.addDiagnostic('info', 'empty-block',
|
|
500
|
+
'Empty view block - component will render nothing',
|
|
501
|
+
this.ast.view.line || 1, this.ast.view.column || 1);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (this.ast.actions && (!this.ast.actions.functions || this.ast.actions.functions.length === 0)) {
|
|
505
|
+
this.addDiagnostic('info', 'empty-block',
|
|
506
|
+
'Empty actions block - consider removing if not needed',
|
|
507
|
+
this.ast.actions.line || 1, this.ast.actions.column || 1);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Check import order
|
|
511
|
+
if (this.ast.imports && this.ast.imports.length > 1) {
|
|
512
|
+
const sources = this.ast.imports.map(i => i.source);
|
|
513
|
+
const sorted = [...sources].sort();
|
|
514
|
+
if (JSON.stringify(sources) !== JSON.stringify(sorted)) {
|
|
515
|
+
this.addDiagnostic('info', 'import-order',
|
|
516
|
+
'Imports should be sorted alphabetically',
|
|
517
|
+
this.ast.imports[0].line || 1, this.ast.imports[0].column || 1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Add a diagnostic message
|
|
524
|
+
*/
|
|
525
|
+
addDiagnostic(severity, code, message, line, column) {
|
|
526
|
+
this.diagnostics.push({
|
|
527
|
+
severity,
|
|
528
|
+
code,
|
|
529
|
+
message,
|
|
530
|
+
line: line || 1,
|
|
531
|
+
column: column || 1
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Format a diagnostic for console output
|
|
538
|
+
*/
|
|
539
|
+
export function formatDiagnostic(diag, file = null) {
|
|
540
|
+
const prefix = file ? `${file}:` : '';
|
|
541
|
+
const location = `${prefix}${diag.line}:${diag.column}`;
|
|
542
|
+
const severity = diag.severity.toUpperCase().padEnd(7);
|
|
543
|
+
return ` ${location.padEnd(20)} ${severity} ${diag.message} (${diag.code})`;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Lint a single file
|
|
548
|
+
*/
|
|
549
|
+
export async function lintFile(filePath, options = {}) {
|
|
550
|
+
const { parse } = await import('../compiler/index.js');
|
|
551
|
+
|
|
552
|
+
const source = readFileSync(filePath, 'utf-8');
|
|
553
|
+
|
|
554
|
+
// Parse the file
|
|
555
|
+
let ast;
|
|
556
|
+
const errors = [];
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
ast = parse(source);
|
|
560
|
+
} catch (e) {
|
|
561
|
+
// Syntax error
|
|
562
|
+
return {
|
|
563
|
+
file: filePath,
|
|
564
|
+
diagnostics: [{
|
|
565
|
+
severity: 'error',
|
|
566
|
+
code: 'syntax-error',
|
|
567
|
+
message: e.message,
|
|
568
|
+
line: e.line || 1,
|
|
569
|
+
column: e.column || 1
|
|
570
|
+
}]
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Run semantic analysis
|
|
575
|
+
const analyzer = new SemanticAnalyzer(ast, source);
|
|
576
|
+
const diagnostics = analyzer.analyze();
|
|
577
|
+
|
|
578
|
+
return {
|
|
579
|
+
file: filePath,
|
|
580
|
+
diagnostics,
|
|
581
|
+
ast // Return AST for potential --fix operations
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Main lint command handler
|
|
587
|
+
*/
|
|
588
|
+
export async function runLint(args) {
|
|
589
|
+
const { options, patterns } = parseArgs(args);
|
|
590
|
+
const fix = options.fix || false;
|
|
591
|
+
|
|
592
|
+
// Find files to lint
|
|
593
|
+
const files = findPulseFiles(patterns);
|
|
594
|
+
|
|
595
|
+
if (files.length === 0) {
|
|
596
|
+
console.log('No .pulse files found to lint.');
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
console.log(`Linting ${files.length} file(s)...\n`);
|
|
601
|
+
|
|
602
|
+
let totalErrors = 0;
|
|
603
|
+
let totalWarnings = 0;
|
|
604
|
+
let totalInfo = 0;
|
|
605
|
+
|
|
606
|
+
for (const file of files) {
|
|
607
|
+
const result = await lintFile(file, { fix });
|
|
608
|
+
const relPath = relativePath(file);
|
|
609
|
+
|
|
610
|
+
if (result.diagnostics.length > 0) {
|
|
611
|
+
console.log(`\n${relPath}`);
|
|
612
|
+
|
|
613
|
+
for (const diag of result.diagnostics) {
|
|
614
|
+
console.log(formatDiagnostic(diag));
|
|
615
|
+
|
|
616
|
+
switch (diag.severity) {
|
|
617
|
+
case 'error': totalErrors++; break;
|
|
618
|
+
case 'warning': totalWarnings++; break;
|
|
619
|
+
case 'info': totalInfo++; break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Summary
|
|
626
|
+
console.log('\n' + '─'.repeat(60));
|
|
627
|
+
const parts = [];
|
|
628
|
+
if (totalErrors > 0) parts.push(`${totalErrors} error(s)`);
|
|
629
|
+
if (totalWarnings > 0) parts.push(`${totalWarnings} warning(s)`);
|
|
630
|
+
if (totalInfo > 0) parts.push(`${totalInfo} info`);
|
|
631
|
+
|
|
632
|
+
if (parts.length === 0) {
|
|
633
|
+
console.log(`✓ ${files.length} file(s) passed`);
|
|
634
|
+
} else {
|
|
635
|
+
console.log(`✗ ${parts.join(', ')} in ${files.length} file(s)`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Exit with error code if errors found
|
|
639
|
+
if (totalErrors > 0) {
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
}
|