@frozenproductions/niteo 0.1.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.
Files changed (38) hide show
  1. package/Cargo.lock +458 -0
  2. package/Cargo.toml +13 -0
  3. package/LICENSE +21 -0
  4. package/README.md +119 -0
  5. package/bin/niteo.js +51 -0
  6. package/package.json +27 -0
  7. package/scripts/build-binary.js +23 -0
  8. package/src/app.rs +132 -0
  9. package/src/cli.rs +43 -0
  10. package/src/config.rs +989 -0
  11. package/src/discovery.rs +42 -0
  12. package/src/git.rs +63 -0
  13. package/src/main.rs +13 -0
  14. package/src/report.rs +490 -0
  15. package/src/rules/max_directory_depth.rs +125 -0
  16. package/src/rules/max_file_exports.rs +478 -0
  17. package/src/rules/max_items_per_directory.rs +145 -0
  18. package/src/rules/min_items_per_directory.rs +146 -0
  19. package/src/rules/no_barrel_files.rs +284 -0
  20. package/src/rules/no_comments.rs +222 -0
  21. package/src/rules/no_console.rs +242 -0
  22. package/src/rules/no_debugger.rs +209 -0
  23. package/src/rules/no_default_export.rs +241 -0
  24. package/src/rules/no_duplicate_file_names.rs +196 -0
  25. package/src/rules/no_empty_directories.rs +467 -0
  26. package/src/rules/no_empty_interface.rs +280 -0
  27. package/src/rules/no_enums.rs +208 -0
  28. package/src/rules/no_eval.rs +268 -0
  29. package/src/rules/no_export_star.rs +252 -0
  30. package/src/rules/no_inline_types.rs +367 -0
  31. package/src/rules/no_interface.rs +570 -0
  32. package/src/rules/no_large_file.rs +98 -0
  33. package/src/rules/no_logic_in_barrel.rs +346 -0
  34. package/src/rules/no_logic_in_domain.rs +987 -0
  35. package/src/rules/no_mutable_exports.rs +253 -0
  36. package/src/rules/no_upward_import.rs +427 -0
  37. package/src/rules/prefer_satisfies.rs +319 -0
  38. package/src/rules.rs +247 -0
@@ -0,0 +1,987 @@
1
+ use std::path::{Component, Path};
2
+
3
+ use crate::config::{NoLogicInDomainRuleConfig, Severity};
4
+ use crate::rules::Violation;
5
+
6
+ const RULE_NAME: &str = "no-logic-in-domain";
7
+ const MESSAGE: &str =
8
+ "Keep domain files free of logic. Move implementation to feature or service files.";
9
+
10
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11
+ enum DomainKind {
12
+ Types,
13
+ Constants,
14
+ }
15
+
16
+ pub fn check_file(file: &Path, source: &str, config: &NoLogicInDomainRuleConfig) -> Vec<Violation> {
17
+ let Some(kind) = classify_domain_file(file, config) else {
18
+ return Vec::new();
19
+ };
20
+
21
+ find_logic_in_file(file, source, config.severity, kind)
22
+ }
23
+
24
+ fn classify_domain_file(file: &Path, config: &NoLogicInDomainRuleConfig) -> Option<DomainKind> {
25
+ if is_types_file(file, config) {
26
+ return Some(DomainKind::Types);
27
+ }
28
+
29
+ if is_constants_file(file, config) {
30
+ return Some(DomainKind::Constants);
31
+ }
32
+
33
+ None
34
+ }
35
+
36
+ fn is_types_file(file: &Path, config: &NoLogicInDomainRuleConfig) -> bool {
37
+ let mut folders = vec!["types".to_string()];
38
+ folders.extend(config.extra_folders.clone());
39
+
40
+ let in_types_folder = file.components().any(|component| {
41
+ matches!(
42
+ component,
43
+ Component::Normal(name) if folders.iter().any(|f| name.to_str() == Some(f))
44
+ )
45
+ });
46
+
47
+ let has_types_suffix = {
48
+ let file_name = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
49
+ let mut suffixes = vec![".type.ts".to_string(), ".types.ts".to_string()];
50
+ suffixes.extend(config.extra_file_suffixes.clone());
51
+ suffixes.iter().any(|suffix| file_name.ends_with(suffix))
52
+ };
53
+
54
+ in_types_folder || has_types_suffix
55
+ }
56
+
57
+ fn is_constants_file(file: &Path, config: &NoLogicInDomainRuleConfig) -> bool {
58
+ let mut folders = vec!["constants".to_string()];
59
+ folders.extend(config.extra_folders.clone());
60
+
61
+ let in_constants_folder = file.components().any(|component| {
62
+ matches!(
63
+ component,
64
+ Component::Normal(name) if folders.iter().any(|f| name.to_str() == Some(f))
65
+ )
66
+ });
67
+
68
+ let has_constants_suffix = {
69
+ let file_name = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
70
+ let mut suffixes = vec![".constant.ts".to_string(), ".constants.ts".to_string()];
71
+ suffixes.extend(config.extra_file_suffixes.clone());
72
+ suffixes.iter().any(|suffix| file_name.ends_with(suffix))
73
+ };
74
+
75
+ in_constants_folder || has_constants_suffix
76
+ }
77
+
78
+ fn find_logic_in_file(
79
+ file: &Path,
80
+ source: &str,
81
+ severity: Severity,
82
+ kind: DomainKind,
83
+ ) -> Vec<Violation> {
84
+ let bytes = source.as_bytes();
85
+ let mut violations = Vec::new();
86
+ let mut cursor = Cursor::default();
87
+ let mut string_quote: Option<u8> = None;
88
+
89
+ while cursor.index < bytes.len() {
90
+ let current = bytes[cursor.index];
91
+ let next = bytes.get(cursor.index + 1).copied();
92
+
93
+ if let Some(quote) = string_quote {
94
+ if current == b'\\' {
95
+ cursor.advance(bytes);
96
+ if cursor.index < bytes.len() {
97
+ cursor.advance(bytes);
98
+ }
99
+ continue;
100
+ }
101
+
102
+ if current == quote {
103
+ string_quote = None;
104
+ }
105
+
106
+ cursor.advance(bytes);
107
+ continue;
108
+ }
109
+
110
+ match (current, next) {
111
+ (b'\'', _) | (b'"', _) | (b'`', _) => {
112
+ string_quote = Some(current);
113
+ cursor.advance(bytes);
114
+ }
115
+ (b'/', Some(b'/')) => skip_line_comment(bytes, &mut cursor),
116
+ (b'/', Some(b'*')) => skip_block_comment(bytes, &mut cursor),
117
+ (b'/', _) if is_regex_start(bytes, cursor.index) => {
118
+ skip_regex_literal(bytes, &mut cursor);
119
+ }
120
+ _ if starts_logic_pattern(bytes, cursor.index, kind) => {
121
+ violations.push(logic_violation(file, &cursor, severity));
122
+ skip_statement(bytes, &mut cursor);
123
+ }
124
+ _ => cursor.advance(bytes),
125
+ }
126
+ }
127
+
128
+ violations
129
+ }
130
+
131
+ fn skip_statement(bytes: &[u8], cursor: &mut Cursor) {
132
+ let mut brace_depth = 0usize;
133
+ let mut paren_depth = 0usize;
134
+ let mut string_quote: Option<u8> = None;
135
+
136
+ while cursor.index < bytes.len() {
137
+ let current = bytes[cursor.index];
138
+ let next = bytes.get(cursor.index + 1).copied();
139
+
140
+ if let Some(quote) = string_quote {
141
+ if current == b'\\' {
142
+ cursor.advance(bytes);
143
+ if cursor.index < bytes.len() {
144
+ cursor.advance(bytes);
145
+ }
146
+ continue;
147
+ }
148
+
149
+ if current == quote {
150
+ string_quote = None;
151
+ }
152
+
153
+ cursor.advance(bytes);
154
+ continue;
155
+ }
156
+
157
+ match (current, next) {
158
+ (b'\'', _) | (b'"', _) | (b'`', _) => {
159
+ string_quote = Some(current);
160
+ cursor.advance(bytes);
161
+ }
162
+ (b'/', Some(b'/')) => skip_line_comment(bytes, cursor),
163
+ (b'/', Some(b'*')) => skip_block_comment(bytes, cursor),
164
+ (b'/', _) if is_regex_start(bytes, cursor.index) => {
165
+ skip_regex_literal(bytes, cursor);
166
+ }
167
+ (b'{', _) => {
168
+ brace_depth += 1;
169
+ cursor.advance(bytes);
170
+ }
171
+ (b'}', _) => {
172
+ brace_depth = brace_depth.saturating_sub(1);
173
+ cursor.advance(bytes);
174
+ }
175
+ (b'(', _) => {
176
+ paren_depth += 1;
177
+ cursor.advance(bytes);
178
+ }
179
+ (b')', _) => {
180
+ paren_depth = paren_depth.saturating_sub(1);
181
+ cursor.advance(bytes);
182
+ }
183
+ (b';', _) if brace_depth == 0 && paren_depth == 0 => {
184
+ cursor.advance(bytes);
185
+ break;
186
+ }
187
+ (b'\n', _) if brace_depth == 0 && paren_depth == 0 => {
188
+ cursor.advance(bytes);
189
+ break;
190
+ }
191
+ _ => cursor.advance(bytes),
192
+ }
193
+ }
194
+ }
195
+
196
+ fn starts_logic_pattern(bytes: &[u8], index: usize, kind: DomainKind) -> bool {
197
+ match kind {
198
+ DomainKind::Types => {
199
+ starts_function_declaration(bytes, index)
200
+ || starts_class_declaration(bytes, index)
201
+ || starts_const_declaration(bytes, index)
202
+ || starts_let_declaration(bytes, index)
203
+ || starts_var_declaration(bytes, index)
204
+ }
205
+ DomainKind::Constants => {
206
+ starts_function_declaration(bytes, index)
207
+ || starts_class_declaration(bytes, index)
208
+ || starts_let_declaration(bytes, index)
209
+ || starts_var_declaration(bytes, index)
210
+ || starts_const_with_logic(bytes, index)
211
+ }
212
+ }
213
+ }
214
+
215
+ fn starts_const_with_logic(bytes: &[u8], index: usize) -> bool {
216
+ let Some(const_pos) = find_const_position(bytes, index) else {
217
+ return false;
218
+ };
219
+
220
+ let after_const = const_pos + b"const".len();
221
+ let after_const_bytes = &bytes[after_const..];
222
+ if !after_const_bytes
223
+ .first()
224
+ .is_some_and(|&b| b.is_ascii_whitespace())
225
+ {
226
+ return false;
227
+ }
228
+
229
+ let after_name = skip_past_identifier(bytes, after_const);
230
+ let after_eq = skip_whitespace(&bytes[after_name..]);
231
+ if !after_eq.starts_with(b"=") {
232
+ return false;
233
+ }
234
+
235
+ let after_eq_pos = after_eq.as_ptr() as usize - bytes.as_ptr() as usize + b"=".len();
236
+ let after_eq_whitespace = skip_whitespace(&bytes[after_eq_pos..]);
237
+
238
+ starts_arrow_function(after_eq_whitespace) || starts_function_expression(after_eq_whitespace)
239
+ }
240
+
241
+ fn find_const_position(bytes: &[u8], index: usize) -> Option<usize> {
242
+ if starts_keyword(bytes, index, b"const") {
243
+ return Some(index);
244
+ }
245
+
246
+ if starts_keyword(bytes, index, b"export") {
247
+ let after_export = index + b"export".len();
248
+ let rest = skip_whitespace(&bytes[after_export..]);
249
+ if rest.starts_with(b"const") {
250
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
251
+ return Some(rest_offset);
252
+ }
253
+ }
254
+
255
+ None
256
+ }
257
+
258
+ fn starts_arrow_function(slice: &[u8]) -> bool {
259
+ if slice.starts_with(b"(") {
260
+ let after_paren = skip_past_paren_group(slice);
261
+ let after_paren_whitespace = skip_whitespace(after_paren);
262
+ return after_paren_whitespace.starts_with(b"=>");
263
+ }
264
+
265
+ if slice
266
+ .first()
267
+ .is_some_and(|&b| b.is_ascii_alphabetic() || b == b'_')
268
+ {
269
+ let after_param = skip_past_identifier(slice, 0);
270
+ let after_param_whitespace = skip_whitespace(&slice[after_param..]);
271
+ return after_param_whitespace.starts_with(b"=>");
272
+ }
273
+
274
+ false
275
+ }
276
+
277
+ fn starts_function_expression(slice: &[u8]) -> bool {
278
+ if slice.starts_with(b"function") {
279
+ let after_function = slice.as_ptr() as usize - slice.as_ptr() as usize + b"function".len();
280
+ return slice
281
+ .get(after_function)
282
+ .is_some_and(|&b| b.is_ascii_whitespace() || b == b'(');
283
+ }
284
+
285
+ if slice.starts_with(b"async") {
286
+ let after_async = skip_whitespace(&slice[b"async".len()..]);
287
+ return after_async.starts_with(b"function");
288
+ }
289
+
290
+ false
291
+ }
292
+
293
+ fn skip_past_paren_group(bytes: &[u8]) -> &[u8] {
294
+ if !bytes.starts_with(b"(") {
295
+ return bytes;
296
+ }
297
+
298
+ let mut depth = 0;
299
+ let mut i = 0;
300
+ let mut string_quote: Option<u8> = None;
301
+
302
+ while i < bytes.len() {
303
+ let current = bytes[i];
304
+
305
+ if let Some(quote) = string_quote {
306
+ if current == b'\\' {
307
+ i += 2;
308
+ continue;
309
+ }
310
+ if current == quote {
311
+ string_quote = None;
312
+ }
313
+ i += 1;
314
+ continue;
315
+ }
316
+
317
+ match current {
318
+ b'\'' | b'"' | b'`' => string_quote = Some(current),
319
+ b'(' => depth += 1,
320
+ b')' => {
321
+ depth -= 1;
322
+ if depth == 0 {
323
+ return &bytes[i + 1..];
324
+ }
325
+ }
326
+ _ => {}
327
+ }
328
+
329
+ i += 1;
330
+ }
331
+
332
+ &bytes[bytes.len()..]
333
+ }
334
+
335
+ fn skip_past_identifier(bytes: &[u8], start: usize) -> usize {
336
+ let mut i = start;
337
+ while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n') {
338
+ i += 1;
339
+ }
340
+
341
+ while i < bytes.len() && is_identifier_byte(Some(bytes[i])) {
342
+ i += 1;
343
+ }
344
+
345
+ i
346
+ }
347
+
348
+ fn starts_function_declaration(bytes: &[u8], index: usize) -> bool {
349
+ if starts_keyword(bytes, index, b"function") {
350
+ let after_function = index + b"function".len();
351
+ let next = bytes.get(after_function).copied();
352
+ return matches!(next, Some(b' ' | b'\t' | b'\n' | b'\r' | b'(' | b'<'));
353
+ }
354
+
355
+ if starts_keyword(bytes, index, b"export") {
356
+ let after_export = index + b"export".len();
357
+ let rest = skip_whitespace(&bytes[after_export..]);
358
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
359
+
360
+ if rest.starts_with(b"function") {
361
+ let after_function = rest_offset + b"function".len();
362
+ if bytes
363
+ .get(after_function)
364
+ .is_some_and(|&b| b.is_ascii_whitespace() || b == b'(' || b == b'<')
365
+ {
366
+ return true;
367
+ }
368
+ }
369
+
370
+ if rest.starts_with(b"async") {
371
+ let after_async = skip_whitespace(&bytes[rest_offset + b"async".len()..]);
372
+ if after_async.starts_with(b"function") {
373
+ return true;
374
+ }
375
+ }
376
+ }
377
+
378
+ if starts_keyword(bytes, index, b"async") {
379
+ let after_async = index + b"async".len();
380
+ let rest = skip_whitespace(&bytes[after_async..]);
381
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
382
+
383
+ if rest.starts_with(b"function") {
384
+ return true;
385
+ }
386
+
387
+ if rest.starts_with(b"export") {
388
+ let after_export = skip_whitespace(&bytes[rest_offset + b"export".len()..]);
389
+ if after_export.starts_with(b"async function") || after_export.starts_with(b"function")
390
+ {
391
+ return true;
392
+ }
393
+ }
394
+ }
395
+
396
+ false
397
+ }
398
+
399
+ fn starts_const_declaration(bytes: &[u8], index: usize) -> bool {
400
+ if starts_keyword(bytes, index, b"const") {
401
+ let after_const = index + b"const".len();
402
+ if bytes
403
+ .get(after_const)
404
+ .is_some_and(|&b| b.is_ascii_whitespace())
405
+ {
406
+ return true;
407
+ }
408
+ }
409
+
410
+ if starts_keyword(bytes, index, b"export") {
411
+ let after_export = index + b"export".len();
412
+ let rest = skip_whitespace(&bytes[after_export..]);
413
+ if rest.starts_with(b"const") {
414
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
415
+ let after_const = rest_offset + b"const".len();
416
+ if bytes
417
+ .get(after_const)
418
+ .is_some_and(|&b| b.is_ascii_whitespace())
419
+ {
420
+ return true;
421
+ }
422
+ }
423
+ }
424
+
425
+ false
426
+ }
427
+
428
+ fn starts_let_declaration(bytes: &[u8], index: usize) -> bool {
429
+ if starts_keyword(bytes, index, b"let") {
430
+ let after_let = index + b"let".len();
431
+ if bytes
432
+ .get(after_let)
433
+ .is_some_and(|&b| b.is_ascii_whitespace())
434
+ {
435
+ return true;
436
+ }
437
+ }
438
+
439
+ if starts_keyword(bytes, index, b"export") {
440
+ let after_export = index + b"export".len();
441
+ let rest = skip_whitespace(&bytes[after_export..]);
442
+ if rest.starts_with(b"let") {
443
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
444
+ let after_let = rest_offset + b"let".len();
445
+ if bytes
446
+ .get(after_let)
447
+ .is_some_and(|&b| b.is_ascii_whitespace())
448
+ {
449
+ return true;
450
+ }
451
+ }
452
+ }
453
+
454
+ false
455
+ }
456
+
457
+ fn starts_var_declaration(bytes: &[u8], index: usize) -> bool {
458
+ if starts_keyword(bytes, index, b"var") {
459
+ let after_var = index + b"var".len();
460
+ if bytes
461
+ .get(after_var)
462
+ .is_some_and(|&b| b.is_ascii_whitespace())
463
+ {
464
+ return true;
465
+ }
466
+ }
467
+
468
+ if starts_keyword(bytes, index, b"export") {
469
+ let after_export = index + b"export".len();
470
+ let rest = skip_whitespace(&bytes[after_export..]);
471
+ if rest.starts_with(b"var") {
472
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
473
+ let after_var = rest_offset + b"var".len();
474
+ if bytes
475
+ .get(after_var)
476
+ .is_some_and(|&b| b.is_ascii_whitespace())
477
+ {
478
+ return true;
479
+ }
480
+ }
481
+ }
482
+
483
+ false
484
+ }
485
+
486
+ fn starts_class_declaration(bytes: &[u8], index: usize) -> bool {
487
+ if starts_keyword(bytes, index, b"class") {
488
+ let after_class = index + b"class".len();
489
+ if bytes
490
+ .get(after_class)
491
+ .is_some_and(|&b| b.is_ascii_whitespace())
492
+ {
493
+ return true;
494
+ }
495
+ }
496
+
497
+ if starts_keyword(bytes, index, b"export") {
498
+ let after_export = index + b"export".len();
499
+ let rest = skip_whitespace(&bytes[after_export..]);
500
+ if rest.starts_with(b"class") {
501
+ let rest_offset = rest.as_ptr() as usize - bytes.as_ptr() as usize;
502
+ let after_class = rest_offset + b"class".len();
503
+ if bytes
504
+ .get(after_class)
505
+ .is_some_and(|&b| b.is_ascii_whitespace())
506
+ {
507
+ return true;
508
+ }
509
+ }
510
+ }
511
+
512
+ false
513
+ }
514
+
515
+ fn skip_whitespace(bytes: &[u8]) -> &[u8] {
516
+ let mut i = 0;
517
+ while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\r' | b'\n') {
518
+ i += 1;
519
+ }
520
+ &bytes[i..]
521
+ }
522
+
523
+ fn starts_keyword(bytes: &[u8], index: usize, keyword: &[u8]) -> bool {
524
+ bytes.get(index..index + keyword.len()) == Some(keyword)
525
+ && !is_identifier_byte(bytes.get(index.wrapping_sub(1)).copied())
526
+ && !is_identifier_byte(bytes.get(index + keyword.len()).copied())
527
+ }
528
+
529
+ fn is_identifier_byte(byte: Option<u8>) -> bool {
530
+ matches!(
531
+ byte,
532
+ Some(b'a'..=b'z') | Some(b'A'..=b'Z') | Some(b'0'..=b'9') | Some(b'_') | Some(b'$')
533
+ )
534
+ }
535
+
536
+ fn logic_violation(file: &Path, cursor: &Cursor, severity: Severity) -> Violation {
537
+ Violation {
538
+ file: file.to_path_buf(),
539
+ line: Some(cursor.line),
540
+ column: Some(cursor.column),
541
+ rule: RULE_NAME,
542
+ message: MESSAGE,
543
+ severity,
544
+ detail: None,
545
+ subject: None,
546
+ }
547
+ }
548
+
549
+ #[derive(Debug, Clone, Copy)]
550
+ struct Cursor {
551
+ index: usize,
552
+ line: usize,
553
+ column: usize,
554
+ }
555
+
556
+ impl Default for Cursor {
557
+ fn default() -> Self {
558
+ Self {
559
+ index: 0,
560
+ line: 1,
561
+ column: 1,
562
+ }
563
+ }
564
+ }
565
+
566
+ impl Cursor {
567
+ fn advance(&mut self, bytes: &[u8]) {
568
+ if bytes[self.index] == b'\n' {
569
+ self.line += 1;
570
+ self.column = 1;
571
+ } else {
572
+ self.column += 1;
573
+ }
574
+
575
+ self.index += 1;
576
+ }
577
+ }
578
+
579
+ fn skip_line_comment(bytes: &[u8], cursor: &mut Cursor) {
580
+ while cursor.index < bytes.len() && bytes[cursor.index] != b'\n' {
581
+ cursor.advance(bytes);
582
+ }
583
+ }
584
+
585
+ fn skip_block_comment(bytes: &[u8], cursor: &mut Cursor) {
586
+ cursor.advance(bytes);
587
+ cursor.advance(bytes);
588
+
589
+ while cursor.index < bytes.len() {
590
+ let current = bytes[cursor.index];
591
+ let next = bytes.get(cursor.index + 1).copied();
592
+
593
+ cursor.advance(bytes);
594
+
595
+ if current == b'*' && next == Some(b'/') {
596
+ cursor.advance(bytes);
597
+ break;
598
+ }
599
+ }
600
+ }
601
+
602
+ fn is_regex_start(bytes: &[u8], index: usize) -> bool {
603
+ if index == 0 {
604
+ return true;
605
+ }
606
+
607
+ let prev = bytes[index - 1];
608
+ matches!(
609
+ prev,
610
+ b'(' | b'['
611
+ | b'{'
612
+ | b','
613
+ | b';'
614
+ | b':'
615
+ | b'='
616
+ | b'+'
617
+ | b'-'
618
+ | b'!'
619
+ | b'~'
620
+ | b'&'
621
+ | b'|'
622
+ | b'^'
623
+ | b'?'
624
+ | b'>'
625
+ | b'<'
626
+ | b'%'
627
+ | b'*'
628
+ | b'/'
629
+ | b'\n'
630
+ | b'\r'
631
+ | b'\t'
632
+ | b' '
633
+ )
634
+ }
635
+
636
+ fn skip_regex_literal(bytes: &[u8], cursor: &mut Cursor) {
637
+ cursor.advance(bytes);
638
+
639
+ let mut in_char_class = false;
640
+
641
+ while cursor.index < bytes.len() {
642
+ let current = bytes[cursor.index];
643
+
644
+ if current == b'\\' {
645
+ cursor.advance(bytes);
646
+ if cursor.index < bytes.len() {
647
+ cursor.advance(bytes);
648
+ }
649
+ continue;
650
+ }
651
+
652
+ if current == b'[' {
653
+ in_char_class = true;
654
+ cursor.advance(bytes);
655
+ continue;
656
+ }
657
+
658
+ if current == b']' && in_char_class {
659
+ in_char_class = false;
660
+ cursor.advance(bytes);
661
+ continue;
662
+ }
663
+
664
+ if current == b'/' && !in_char_class {
665
+ cursor.advance(bytes);
666
+ while cursor.index < bytes.len()
667
+ && matches!(bytes[cursor.index], b'a'..=b'z' | b'A'..=b'Z')
668
+ {
669
+ cursor.advance(bytes);
670
+ }
671
+ return;
672
+ }
673
+
674
+ cursor.advance(bytes);
675
+ }
676
+ }
677
+
678
+ #[cfg(test)]
679
+ mod tests {
680
+ use super::check_file;
681
+ use crate::config::{NoLogicInDomainRuleConfig, Severity};
682
+ use std::path::Path;
683
+
684
+ #[test]
685
+ fn reports_function_in_types_folder() {
686
+ let config = NoLogicInDomainRuleConfig {
687
+ severity: Severity::Warn,
688
+ extra_folders: vec![],
689
+ extra_file_suffixes: vec![],
690
+ };
691
+
692
+ let violations = check_file(
693
+ Path::new("types/Button.ts"),
694
+ "function handleClick() {}\n",
695
+ &config,
696
+ );
697
+
698
+ assert_eq!(violations.len(), 1);
699
+ assert_eq!(violations[0].line, Some(1));
700
+ }
701
+
702
+ #[test]
703
+ fn allows_const_in_constants_folder() {
704
+ let config = NoLogicInDomainRuleConfig {
705
+ severity: Severity::Warn,
706
+ extra_folders: vec![],
707
+ extra_file_suffixes: vec![],
708
+ };
709
+
710
+ let violations = check_file(
711
+ Path::new("constants/routes.ts"),
712
+ "export const ROUTES = { HOME: '/' };\n",
713
+ &config,
714
+ );
715
+
716
+ assert!(violations.is_empty());
717
+ }
718
+
719
+ #[test]
720
+ fn reports_function_in_constants_folder() {
721
+ let config = NoLogicInDomainRuleConfig {
722
+ severity: Severity::Warn,
723
+ extra_folders: vec![],
724
+ extra_file_suffixes: vec![],
725
+ };
726
+
727
+ let violations = check_file(
728
+ Path::new("constants/routes.ts"),
729
+ "function getRoute() { return '/'; }\n",
730
+ &config,
731
+ );
732
+
733
+ assert_eq!(violations.len(), 1);
734
+ }
735
+
736
+ #[test]
737
+ fn reports_const_arrow_function_in_constants_folder() {
738
+ let config = NoLogicInDomainRuleConfig {
739
+ severity: Severity::Warn,
740
+ extra_folders: vec![],
741
+ extra_file_suffixes: vec![],
742
+ };
743
+
744
+ let violations = check_file(
745
+ Path::new("constants/routes.ts"),
746
+ "const getRoute = () => '/';\n",
747
+ &config,
748
+ );
749
+
750
+ assert_eq!(violations.len(), 1);
751
+ }
752
+
753
+ #[test]
754
+ fn reports_function_in_type_file() {
755
+ let config = NoLogicInDomainRuleConfig {
756
+ severity: Severity::Warn,
757
+ extra_folders: vec![],
758
+ extra_file_suffixes: vec![],
759
+ };
760
+
761
+ let violations = check_file(
762
+ Path::new("Button.type.ts"),
763
+ "export function handleClick() {}\n",
764
+ &config,
765
+ );
766
+
767
+ assert_eq!(violations.len(), 1);
768
+ }
769
+
770
+ #[test]
771
+ fn reports_class_in_constant_file() {
772
+ let config = NoLogicInDomainRuleConfig {
773
+ severity: Severity::Warn,
774
+ extra_folders: vec![],
775
+ extra_file_suffixes: vec![],
776
+ };
777
+
778
+ let violations = check_file(
779
+ Path::new("api.constants.ts"),
780
+ "export class ApiClient {}\n",
781
+ &config,
782
+ );
783
+
784
+ assert_eq!(violations.len(), 1);
785
+ }
786
+
787
+ #[test]
788
+ fn allows_type_declaration_in_type_file() {
789
+ let config = NoLogicInDomainRuleConfig {
790
+ severity: Severity::Warn,
791
+ extra_folders: vec![],
792
+ extra_file_suffixes: vec![],
793
+ };
794
+
795
+ let violations = check_file(
796
+ Path::new("Button.type.ts"),
797
+ "export type ButtonProps = { label: string };\n",
798
+ &config,
799
+ );
800
+
801
+ assert!(violations.is_empty());
802
+ }
803
+
804
+ #[test]
805
+ fn reports_const_in_type_file() {
806
+ let config = NoLogicInDomainRuleConfig {
807
+ severity: Severity::Warn,
808
+ extra_folders: vec![],
809
+ extra_file_suffixes: vec![],
810
+ };
811
+
812
+ let violations = check_file(Path::new("Button.type.ts"), "const VALUE = 1;\n", &config);
813
+
814
+ assert_eq!(violations.len(), 1);
815
+ }
816
+
817
+ #[test]
818
+ fn reports_let_declaration() {
819
+ let config = NoLogicInDomainRuleConfig {
820
+ severity: Severity::Warn,
821
+ extra_folders: vec![],
822
+ extra_file_suffixes: vec![],
823
+ };
824
+
825
+ let violations = check_file(Path::new("types/state.ts"), "let count = 0;\n", &config);
826
+
827
+ assert_eq!(violations.len(), 1);
828
+ }
829
+
830
+ #[test]
831
+ fn reports_async_function() {
832
+ let config = NoLogicInDomainRuleConfig {
833
+ severity: Severity::Warn,
834
+ extra_folders: vec![],
835
+ extra_file_suffixes: vec![],
836
+ };
837
+
838
+ let violations = check_file(
839
+ Path::new("types/api.ts"),
840
+ "async function fetchData() {}\n",
841
+ &config,
842
+ );
843
+
844
+ assert_eq!(violations.len(), 1);
845
+ }
846
+
847
+ #[test]
848
+ fn ignores_logic_in_comments() {
849
+ let config = NoLogicInDomainRuleConfig {
850
+ severity: Severity::Warn,
851
+ extra_folders: vec![],
852
+ extra_file_suffixes: vec![],
853
+ };
854
+
855
+ let source = "// function handleClick() {}\n/* const value = 1; */\n";
856
+ let violations = check_file(Path::new("types/test.ts"), source, &config);
857
+
858
+ assert!(violations.is_empty());
859
+ }
860
+
861
+ #[test]
862
+ fn ignores_logic_in_strings() {
863
+ let config = NoLogicInDomainRuleConfig {
864
+ severity: Severity::Warn,
865
+ extra_folders: vec![],
866
+ extra_file_suffixes: vec![],
867
+ };
868
+
869
+ let source = r#"const text = "function handleClick() {}";"#;
870
+ let violations = check_file(Path::new("constants/test.ts"), source, &config);
871
+
872
+ assert!(violations.is_empty());
873
+ }
874
+
875
+ #[test]
876
+ fn ignores_keywords_in_regex() {
877
+ let config = NoLogicInDomainRuleConfig {
878
+ severity: Severity::Warn,
879
+ extra_folders: vec![],
880
+ extra_file_suffixes: vec![],
881
+ };
882
+
883
+ let source = r#"export const PATTERN = /\b(function|const|local|export)\b/g;"#;
884
+ let violations = check_file(Path::new("constants/test.ts"), source, &config);
885
+
886
+ assert!(violations.is_empty());
887
+ }
888
+
889
+ #[test]
890
+ fn ignores_keywords_in_object_literals() {
891
+ let config = NoLogicInDomainRuleConfig {
892
+ severity: Severity::Warn,
893
+ extra_folders: vec![],
894
+ extra_file_suffixes: vec![],
895
+ };
896
+
897
+ let source = r#"export const DOCS = {
898
+ function: "Define a function body.",
899
+ local: "Declare a local variable.",
900
+ typeof: "Capture the inferred type.",
901
+ };"#;
902
+ let violations = check_file(Path::new("constants/test.ts"), source, &config);
903
+
904
+ assert!(violations.is_empty());
905
+ }
906
+
907
+ #[test]
908
+ fn respects_extra_folders_config() {
909
+ let config = NoLogicInDomainRuleConfig {
910
+ severity: Severity::Warn,
911
+ extra_folders: vec!["enums".to_string()],
912
+ extra_file_suffixes: vec![],
913
+ };
914
+
915
+ let violations = check_file(
916
+ Path::new("enums/status.ts"),
917
+ "function getStatus() {}\n",
918
+ &config,
919
+ );
920
+
921
+ assert_eq!(violations.len(), 1);
922
+ }
923
+
924
+ #[test]
925
+ fn respects_extra_file_suffixes_config() {
926
+ let config = NoLogicInDomainRuleConfig {
927
+ severity: Severity::Warn,
928
+ extra_folders: vec![],
929
+ extra_file_suffixes: vec![".model.ts".to_string()],
930
+ };
931
+
932
+ let violations = check_file(Path::new("User.model.ts"), "class User {}\n", &config);
933
+
934
+ assert_eq!(violations.len(), 1);
935
+ }
936
+
937
+ #[test]
938
+ fn ignores_non_domain_files() {
939
+ let config = NoLogicInDomainRuleConfig {
940
+ severity: Severity::Warn,
941
+ extra_folders: vec![],
942
+ extra_file_suffixes: vec![],
943
+ };
944
+
945
+ let violations = check_file(
946
+ Path::new("Button.tsx"),
947
+ "function handleClick() {}\n",
948
+ &config,
949
+ );
950
+
951
+ assert!(violations.is_empty());
952
+ }
953
+
954
+ #[test]
955
+ fn reports_exported_arrow_function_in_constants() {
956
+ let config = NoLogicInDomainRuleConfig {
957
+ severity: Severity::Warn,
958
+ extra_folders: vec![],
959
+ extra_file_suffixes: vec![],
960
+ };
961
+
962
+ let violations = check_file(
963
+ Path::new("constants/helpers.ts"),
964
+ "export const formatName = (name: string) => name.trim();\n",
965
+ &config,
966
+ );
967
+
968
+ assert_eq!(violations.len(), 1);
969
+ }
970
+
971
+ #[test]
972
+ fn reports_exported_function_expression_in_constants() {
973
+ let config = NoLogicInDomainRuleConfig {
974
+ severity: Severity::Warn,
975
+ extra_folders: vec![],
976
+ extra_file_suffixes: vec![],
977
+ };
978
+
979
+ let violations = check_file(
980
+ Path::new("constants/helpers.ts"),
981
+ "export const formatName = function(name: string) { return name.trim(); };\n",
982
+ &config,
983
+ );
984
+
985
+ assert_eq!(violations.len(), 1);
986
+ }
987
+ }