@ozsarman/clarityjs 0.6.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/src/linter.js ADDED
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Clarity.js — Formatter & Linter
3
+ *
4
+ * `clarity format` ve `clarity lint` CLI komutları için çekirdek modül.
5
+ *
6
+ * ── CLI kullanımı ─────────────────────────────────────────────────────────────
7
+ *
8
+ * clarity format src/ # tüm .clarity dosyalarını biçimlendir
9
+ * clarity format app.clarity # tek dosyayı biçimlendir
10
+ * clarity format --check src/ # biçim kontrolü (CI için, değişiklik yazmaz)
11
+ * clarity format --stdin # stdin → stdout (editör pipe desteği)
12
+ *
13
+ * clarity lint src/ # tüm .clarity dosyalarını lintla
14
+ * clarity lint app.clarity # tek dosya lint
15
+ * clarity lint --fix src/ # otomatik düzeltilebilir sorunları düzelt
16
+ * clarity lint --rule no-any src/ # belirli kural
17
+ *
18
+ * ── Programatik API ───────────────────────────────────────────────────────────
19
+ *
20
+ * import { formatCode, lintCode, formatFile, lintFile } from '@ozsarman/clarityjs/linter'
21
+ *
22
+ * const formatted = formatCode(source);
23
+ * const { errors, warnings } = lintCode(source, { rules: ['all'] });
24
+ *
25
+ * ── Yapılandırma (.clarity-lint.json veya package.json#clarity.lint) ──────────
26
+ *
27
+ * {
28
+ * "rules": {
29
+ * "no-unused-signals": "warn",
30
+ * "require-alt": "error",
31
+ * "no-direct-mutation": "error",
32
+ * "component-name-pascal": "warn"
33
+ * },
34
+ * "format": {
35
+ * "indent": 2,
36
+ * "singleQuote": true,
37
+ * "trailingComma": true,
38
+ * "printWidth": 100
39
+ * },
40
+ * "ignore": ["dist/", "node_modules/"]
41
+ * }
42
+ *
43
+ * Author: Claude (Anthropic) + Özdemir Sarman
44
+ */
45
+
46
+ // ─── Varsayılan yapılandırma ──────────────────────────────────────────────────
47
+
48
+ export const DEFAULT_FORMAT_OPTIONS = {
49
+ indent: 2,
50
+ useTabs: false,
51
+ singleQuote: true,
52
+ trailingComma: true,
53
+ printWidth: 100,
54
+ bracketSpacing: true,
55
+ jsxSingleQuote: false,
56
+ endOfLine: 'lf',
57
+ };
58
+
59
+ export const DEFAULT_LINT_RULES = {
60
+ 'no-unused-signals': 'warn',
61
+ 'no-direct-mutation': 'error',
62
+ 'require-alt': 'error',
63
+ 'component-name-pascal': 'warn',
64
+ 'no-missing-key': 'warn',
65
+ 'no-duplicate-signals': 'error',
66
+ 'render-block-required': 'error',
67
+ 'no-console': 'off',
68
+ 'prefer-computed': 'warn',
69
+ 'no-async-render': 'warn',
70
+ 'signal-naming-convention': 'off',
71
+ };
72
+
73
+ // ─── Yapılandırma yükleyici ────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Proje kök dizininden lint yapılandırmasını yükle.
77
+ *
78
+ * Arama sırası:
79
+ * 1. .clarity-lint.json
80
+ * 2. .clarityrc.json
81
+ * 3. package.json#clarity.lint
82
+ * 4. Varsayılanlar
83
+ *
84
+ * @param {string} [cwd=process.cwd()]
85
+ * @returns {Promise<{ rules: object, format: object, ignore: string[] }>}
86
+ */
87
+ export async function loadConfig(cwd = process.cwd()) {
88
+ const { existsSync } = await import('node:fs');
89
+ const { readFile } = await import('node:fs/promises');
90
+ const { join } = await import('node:path');
91
+
92
+ const candidates = [
93
+ join(cwd, '.clarity-lint.json'),
94
+ join(cwd, '.clarityrc.json'),
95
+ join(cwd, '.clarityrc'),
96
+ ];
97
+
98
+ for (const path of candidates) {
99
+ if (existsSync(path)) {
100
+ try {
101
+ const raw = await readFile(path, 'utf8');
102
+ const cfg = JSON.parse(raw);
103
+ return _mergeConfig(cfg);
104
+ } catch { /* devam */ }
105
+ }
106
+ }
107
+
108
+ // package.json'dan oku
109
+ const pkgPath = join(cwd, 'package.json');
110
+ if (existsSync(pkgPath)) {
111
+ try {
112
+ const raw = await readFile(pkgPath, 'utf8');
113
+ const pkg = JSON.parse(raw);
114
+ if (pkg.clarity?.lint) return _mergeConfig(pkg.clarity.lint);
115
+ } catch { /* devam */ }
116
+ }
117
+
118
+ return _mergeConfig({});
119
+ }
120
+
121
+ function _mergeConfig(cfg = {}) {
122
+ return {
123
+ rules: { ...DEFAULT_LINT_RULES, ...(cfg.rules ?? {}) },
124
+ format: { ...DEFAULT_FORMAT_OPTIONS, ...(cfg.format ?? {}) },
125
+ ignore: cfg.ignore ?? ['dist/', 'node_modules/', '.git/'],
126
+ };
127
+ }
128
+
129
+ // ─── FORMATTER ───────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * .clarity kaynak kodunu biçimlendir.
133
+ *
134
+ * @param {string} source – Ham .clarity kodu
135
+ * @param {object} [opts] – Format seçenekleri (DEFAULT_FORMAT_OPTIONS'ı geçer)
136
+ * @returns {string} Biçimlendirilmiş kod
137
+ */
138
+ export function formatCode(source, opts = {}) {
139
+ const options = { ...DEFAULT_FORMAT_OPTIONS, ...opts };
140
+
141
+ let code = source;
142
+
143
+ // 1. Satır sonu normalleştirme
144
+ code = _normalizeLineEndings(code, options.endOfLine);
145
+
146
+ // 2. Trailing whitespace temizle
147
+ code = code.replace(/[ \t]+$/gm, '');
148
+
149
+ // 3. Birden fazla boş satırı tek satıra indir
150
+ code = code.replace(/\n{3,}/g, '\n\n');
151
+
152
+ // 4. Import'ları sırala ve biçimlendir
153
+ code = _formatImports(code, options);
154
+
155
+ // 5. component bloklarını biçimlendir
156
+ code = _formatComponentBlocks(code, options);
157
+
158
+ // 6. JSX özniteliklerini biçimlendir
159
+ code = _formatJSXAttributes(code, options);
160
+
161
+ // 7. Tırnak normalizasyonu
162
+ code = _normalizeQuotes(code, options.singleQuote);
163
+
164
+ // 8. Trailing comma ekle/kaldır
165
+ code = _applyTrailingComma(code, options.trailingComma);
166
+
167
+ // 9. Son satırda newline
168
+ if (!code.endsWith('\n')) code += '\n';
169
+
170
+ return code;
171
+ }
172
+
173
+ function _normalizeLineEndings(code, endOfLine) {
174
+ if (endOfLine === 'crlf') return code.replace(/\r?\n/g, '\r\n');
175
+ if (endOfLine === 'cr') return code.replace(/\r?\n/g, '\r');
176
+ return code.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
177
+ }
178
+
179
+ function _formatImports(code, opts) {
180
+ // import bloklarını satır sonu ile ayır
181
+ return code.replace(/(import .+;\n)(import .+;)/g, '$1$2');
182
+ }
183
+
184
+ function _formatComponentBlocks(code, opts) {
185
+ const indent = opts.useTabs ? '\t' : ' '.repeat(opts.indent);
186
+
187
+ // component() { render { } } bloklarını normalize et
188
+ // Basit girinti düzeltme: render { açılışından sonra girinti
189
+ return code.replace(
190
+ /^([ \t]*)(render\s*\{)(\s*\n)/gm,
191
+ (_, baseIndent, keyword, nl) => `${baseIndent}${keyword}${nl}`
192
+ );
193
+ }
194
+
195
+ function _formatJSXAttributes(code, opts) {
196
+ // Uzun JSX tag'larını printWidth'e göre ayır
197
+ // Basit implementasyon — tam AST tabanlı yapıcı sonraki iterasyonda
198
+ if (!opts.printWidth) return code;
199
+
200
+ return code.replace(
201
+ /<(\w+)(\s[^>]{0,}?)>/g,
202
+ (match, tag, attrs) => {
203
+ if (match.length <= opts.printWidth) return match;
204
+ // Her özniteliği kendi satırına al
205
+ const attrParts = attrs.trim().split(/\s+(?=\w+=|[a-z-]+=|\/)/);
206
+ if (attrParts.length <= 1) return match;
207
+ const base = ' ';
208
+ const attrStr = attrParts.map(a => `${base} ${a.trim()}`).join('\n');
209
+ return `<${tag}\n${attrStr}\n${base}>`;
210
+ }
211
+ );
212
+ }
213
+
214
+ function _normalizeQuotes(code, useSingle) {
215
+ // JSX string attr'larına dokunma — sadece JS tarafını normalize et
216
+ // Basit yaklaşım: template literal dışı string'leri normalize et
217
+ if (!useSingle) return code;
218
+
219
+ // Tek tırnak → Çift tırnak içindeki JS string'leri (JSX dışı)
220
+ // Not: Bu basit bir versiyon — tam parser gerekir
221
+ return code.replace(/(?<!['"\\])(?<![a-zA-Z])"([^"\\]*)"(?![^<]*>)/g, (_, inner) => `'${inner}'`);
222
+ }
223
+
224
+ function _applyTrailingComma(code, enabled) {
225
+ if (!enabled) {
226
+ // Trailing comma'ları kaldır
227
+ return code.replace(/,(\s*[\)\]\}])/g, '$1');
228
+ }
229
+ // Trailing comma ekle (function parametreleri, object, array)
230
+ return code.replace(/([^,\s])(\s*\n\s*[\)\]\}])/g, '$1,$2');
231
+ }
232
+
233
+ // ─── LINTER ──────────────────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * @typedef {object} LintIssue
237
+ * @property {'error'|'warning'|'info'} severity
238
+ * @property {string} rule – Kural adı
239
+ * @property {string} message – Kullanıcıya gösterilen mesaj
240
+ * @property {number} line – 1-tabanlı satır numarası
241
+ * @property {number} column – 1-tabanlı sütun numarası
242
+ * @property {string} [fix] – Otomatik düzeltme önerisi (string replacement)
243
+ * @property {boolean} [fixable] – Otomatik düzeltilebilir mi?
244
+ */
245
+
246
+ /**
247
+ * .clarity kaynak kodunu lintla.
248
+ *
249
+ * @param {string} source – Ham .clarity kodu
250
+ * @param {object} [opts]
251
+ * @param {object} [opts.rules] – Kural overrides
252
+ * @param {boolean} [opts.fix] – Otomatik düzeltme (dönen code değişebilir)
253
+ * @returns {{ issues: LintIssue[], fixedCode: string|null, errorCount: number, warningCount: number }}
254
+ */
255
+ export function lintCode(source, opts = {}) {
256
+ const rules = { ...DEFAULT_LINT_RULES, ...(opts.rules ?? {}) };
257
+ const lines = source.split('\n');
258
+
259
+ const issues = [];
260
+
261
+ const addIssue = (rule, message, line, column, fixable = false, fix = null) => {
262
+ const severity = rules[rule];
263
+ if (!severity || severity === 'off') return;
264
+ issues.push({ severity, rule, message, line, column, fixable, fix });
265
+ };
266
+
267
+ // ─── Kurallar ────────────────────────────────────────────────────────────
268
+
269
+ // signal() kullanım takibi
270
+ const declaredSignals = new Set();
271
+ const usedSignals = new Set();
272
+
273
+ lines.forEach((line, i) => {
274
+ const lineNo = i + 1;
275
+
276
+ // render-block-required: component render bloğu içermeli
277
+ if (/^\s*component\s+\w+\s*\(/.test(line)) {
278
+ const blockStart = i;
279
+ let hasRender = false;
280
+ for (let j = i; j < Math.min(i + 50, lines.length); j++) {
281
+ if (/render\s*\{/.test(lines[j])) { hasRender = true; break; }
282
+ }
283
+ if (!hasRender) {
284
+ addIssue('render-block-required',
285
+ "component tanımı 'render { }' bloğu içermelidir",
286
+ lineNo, line.indexOf('component') + 1);
287
+ }
288
+ }
289
+
290
+ // component-name-pascal: PascalCase kontrol
291
+ const compNameMatch = line.match(/^\s*component\s+([a-z]\w*)\s*\(/);
292
+ if (compNameMatch) {
293
+ addIssue('component-name-pascal',
294
+ `component adı PascalCase olmalıdır: '${compNameMatch[1]}' → '${_toPascal(compNameMatch[1])}'`,
295
+ lineNo, line.indexOf(compNameMatch[1]) + 1,
296
+ true, line.replace(compNameMatch[1], _toPascal(compNameMatch[1])));
297
+ }
298
+
299
+ // signal tanımlarını kaydet
300
+ const sigDecl = line.match(/(?:const|let)\s+(\w+)\s*=\s*signal\s*\(/);
301
+ if (sigDecl) {
302
+ declaredSignals.add(sigDecl[1]);
303
+ }
304
+
305
+ // no-direct-mutation: signal.value = ... yerine setter kullan
306
+ // (signal() oluşturma satırından farklı bir satırda .value = doğrudan atama)
307
+ const directMutation = line.match(/(\w+)\.value\s*=/);
308
+ if (directMutation && !line.includes('signal(') && !line.trim().startsWith('//')) {
309
+ // Bu kuralı sadece uyarı seviyesinde tut — yaygın kullanım var
310
+ // Önerimiz: createSignal() pair veya useSignal() deseni
311
+ usedSignals.add(directMutation[1]);
312
+ }
313
+
314
+ // require-alt: <img> etiketlerinde alt özniteliği zorunlu
315
+ const imgNoAlt = line.match(/<img\s[^>]*(?!alt=)[^>]*\/?\s*>/);
316
+ if (imgNoAlt && !/alt=/.test(line)) {
317
+ addIssue('require-alt',
318
+ "<img> etiketinde 'alt' özniteliği zorunludur (erişilebilirlik)",
319
+ lineNo, line.indexOf('<img') + 1);
320
+ }
321
+
322
+ // no-console: console.log/warn/error kullanımı
323
+ const consoleMatch = line.match(/console\.(log|warn|error|info|debug)\s*\(/);
324
+ if (consoleMatch && !line.trim().startsWith('//')) {
325
+ addIssue('no-console',
326
+ `'console.${consoleMatch[1]}' kullanımından kaçının. Logger servisi kullanın.`,
327
+ lineNo, line.indexOf('console') + 1);
328
+ }
329
+
330
+ // no-async-render: render bloğu async olamaz
331
+ if (/render\s*\{/.test(line) && /async\s+render/.test(line)) {
332
+ addIssue('no-async-render',
333
+ "render bloğu async olamaz — async veri için createQuery() veya Suspense kullanın",
334
+ lineNo, 1);
335
+ }
336
+
337
+ // prefer-computed: birden fazla signal.value okuması olan computed yerine
338
+ const signalReads = (line.match(/\w+\.value/g) || []).length;
339
+ if (signalReads >= 3 && !line.includes('computed(') && !line.trim().startsWith('//')) {
340
+ addIssue('prefer-computed',
341
+ 'Birden fazla signal okuyorsanız computed() kullanmayı düşünün',
342
+ lineNo, 1);
343
+ }
344
+
345
+ // signal kullanımları
346
+ const valueAccess = line.matchAll(/(\w+)\.value/g);
347
+ for (const m of valueAccess) {
348
+ usedSignals.add(m[1]);
349
+ }
350
+ });
351
+
352
+ // no-unused-signals: tanımlanmış ama kullanılmamış signal'lar
353
+ for (const sigName of declaredSignals) {
354
+ if (!usedSignals.has(sigName)) {
355
+ const lineIdx = lines.findIndex(l => l.includes(`${sigName} = signal(`));
356
+ if (lineIdx !== -1) {
357
+ addIssue('no-unused-signals',
358
+ `'${sigName}' sinyali tanımlanmış ama kullanılmıyor`,
359
+ lineIdx + 1, lines[lineIdx].indexOf(sigName) + 1);
360
+ }
361
+ }
362
+ }
363
+
364
+ // no-duplicate-signals: aynı isimde birden fazla signal tanımı
365
+ const signalDecls = new Map();
366
+ lines.forEach((line, i) => {
367
+ const m = line.match(/(?:const|let)\s+(\w+)\s*=\s*signal\s*\(/);
368
+ if (m) {
369
+ if (signalDecls.has(m[1])) {
370
+ addIssue('no-duplicate-signals',
371
+ `'${m[1]}' sinyali birden fazla tanımlanmış`,
372
+ i + 1, line.indexOf(m[1]) + 1);
373
+ } else {
374
+ signalDecls.set(m[1], i + 1);
375
+ }
376
+ }
377
+ });
378
+
379
+ // ─── Sonuç ───────────────────────────────────────────────────────────────
380
+
381
+ let fixedCode = null;
382
+ if (opts.fix) {
383
+ fixedCode = _autoFix(source, issues.filter(i => i.fixable));
384
+ }
385
+
386
+ const errorCount = issues.filter(i => i.severity === 'error').length;
387
+ const warningCount = issues.filter(i => i.severity === 'warn' || i.severity === 'warning').length;
388
+
389
+ return { issues, fixedCode, errorCount, warningCount };
390
+ }
391
+
392
+ function _toPascal(str) {
393
+ return str.charAt(0).toUpperCase() + str.slice(1);
394
+ }
395
+
396
+ function _autoFix(source, fixableIssues) {
397
+ let code = source;
398
+ const lines = code.split('\n');
399
+
400
+ for (const issue of fixableIssues) {
401
+ if (issue.fix && issue.line) {
402
+ lines[issue.line - 1] = issue.fix;
403
+ }
404
+ }
405
+
406
+ return lines.join('\n');
407
+ }
408
+
409
+ // ─── Dosya API'si ─────────────────────────────────────────────────────────────
410
+
411
+ /**
412
+ * Tek bir dosyayı biçimlendir.
413
+ *
414
+ * @param {string} filePath
415
+ * @param {object} [opts]
416
+ * @param {boolean} [opts.write=true] – Değişiklikleri diske yaz
417
+ * @returns {Promise<{ changed: boolean, content: string }>}
418
+ */
419
+ export async function formatFile(filePath, opts = {}) {
420
+ const { readFile, writeFile } = await import('node:fs/promises');
421
+ const { write = true } = opts;
422
+
423
+ const original = await readFile(filePath, 'utf8');
424
+ const config = await loadConfig();
425
+ const formatted = formatCode(original, { ...config.format, ...opts });
426
+
427
+ const changed = formatted !== original;
428
+ if (changed && write) {
429
+ await writeFile(filePath, formatted, 'utf8');
430
+ }
431
+
432
+ return { changed, content: formatted };
433
+ }
434
+
435
+ /**
436
+ * Tek bir dosyayı lintle.
437
+ *
438
+ * @param {string} filePath
439
+ * @param {object} [opts]
440
+ * @param {boolean} [opts.fix=false] – Otomatik düzelt ve yaz
441
+ * @returns {Promise<LintResult>}
442
+ */
443
+ export async function lintFile(filePath, opts = {}) {
444
+ const { readFile, writeFile } = await import('node:fs/promises');
445
+ const { fix = false } = opts;
446
+
447
+ const source = await readFile(filePath, 'utf8');
448
+ const config = await loadConfig();
449
+ const result = lintCode(source, { rules: config.rules, fix });
450
+
451
+ if (fix && result.fixedCode && result.fixedCode !== source) {
452
+ await writeFile(filePath, result.fixedCode, 'utf8');
453
+ result.fixed = true;
454
+ }
455
+
456
+ return { ...result, filePath };
457
+ }
458
+
459
+ /**
460
+ * Dizindeki tüm .clarity dosyalarını biçimlendir/lintle.
461
+ *
462
+ * @param {string} dir
463
+ * @param {'format'|'lint'} mode
464
+ * @param {object} [opts]
465
+ * @returns {Promise<Results[]>}
466
+ */
467
+ export async function processDirectory(dir, mode = 'lint', opts = {}) {
468
+ const { readdir, stat } = await import('node:fs/promises');
469
+ const { join, resolve } = await import('node:path');
470
+ const config = await loadConfig();
471
+
472
+ const results = [];
473
+
474
+ async function walk(current) {
475
+ const entries = await readdir(current).catch(() => []);
476
+ for (const entry of entries) {
477
+ const full = join(current, entry);
478
+
479
+ // Ignore patterns
480
+ const rel = full.replace(resolve(dir), '').replace(/^[\\/]/, '');
481
+ if (config.ignore.some(ig => rel.startsWith(ig) || entry === ig)) continue;
482
+
483
+ const info = await stat(full).catch(() => null);
484
+ if (!info) continue;
485
+
486
+ if (info.isDirectory()) {
487
+ await walk(full);
488
+ } else if (entry.endsWith('.clarity') || (opts.js && /\.(js|mjs|cjs)$/.test(entry))) {
489
+ if (mode === 'format') {
490
+ results.push(await formatFile(full, opts));
491
+ } else {
492
+ results.push(await lintFile(full, opts));
493
+ }
494
+ }
495
+ }
496
+ }
497
+
498
+ await walk(resolve(dir));
499
+ return results;
500
+ }
501
+
502
+ // ─── Rapor formatları ─────────────────────────────────────────────────────────
503
+
504
+ /**
505
+ * Lint sonuçlarını terminale formatla.
506
+ *
507
+ * @param {LintResult[]} results
508
+ * @param {'pretty'|'compact'|'json'} [format='pretty']
509
+ * @returns {string}
510
+ */
511
+ export function formatLintReport(results, format = 'pretty') {
512
+ if (format === 'json') return JSON.stringify(results, null, 2);
513
+
514
+ const lines = [];
515
+ let totalErrors = 0, totalWarnings = 0;
516
+
517
+ for (const result of results) {
518
+ if (!result.issues || result.issues.length === 0) continue;
519
+
520
+ if (format === 'pretty') {
521
+ lines.push(`\n\x1b[4m${result.filePath}\x1b[0m`); // underline
522
+ }
523
+
524
+ for (const issue of result.issues) {
525
+ const sev = issue.severity === 'error' ? '\x1b[31m✖\x1b[0m' : '\x1b[33m⚠\x1b[0m';
526
+ const loc = `${issue.line}:${issue.column}`;
527
+ const rule = `\x1b[90m${issue.rule}\x1b[0m`;
528
+
529
+ if (format === 'pretty') {
530
+ lines.push(` ${sev} ${loc.padEnd(8)} ${issue.message} ${rule}`);
531
+ } else {
532
+ lines.push(`${result.filePath}:${loc}: ${issue.severity}: ${issue.message} (${issue.rule})`);
533
+ }
534
+
535
+ if (issue.severity === 'error') totalErrors++;
536
+ else totalWarnings++;
537
+ }
538
+ }
539
+
540
+ const summary = `\n${totalErrors ? `\x1b[31m${totalErrors} hata\x1b[0m` : ''}`
541
+ + `${totalErrors && totalWarnings ? ', ' : ''}`
542
+ + `${totalWarnings ? `\x1b[33m${totalWarnings} uyarı\x1b[0m` : ''}`
543
+ + ((!totalErrors && !totalWarnings) ? '\x1b[32m✅ Sorun bulunamadı\x1b[0m' : '');
544
+
545
+ lines.push(summary);
546
+ return lines.join('\n');
547
+ }