pulse-js-framework 1.4.0 → 1.4.2

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/cli/lint.js CHANGED
@@ -1,642 +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
- }
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
+ }