@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,42 @@
1
+ use anyhow::Result;
2
+ use ignore::WalkBuilder;
3
+ use std::path::{Path, PathBuf};
4
+
5
+ use crate::config::GitignoreConfig;
6
+
7
+ pub fn discover_files(
8
+ root: &Path,
9
+ scope: Option<&Path>,
10
+ gitignore_config: &GitignoreConfig,
11
+ ) -> Result<Vec<PathBuf>> {
12
+ if !root.exists() {
13
+ return Ok(Vec::new());
14
+ }
15
+
16
+ let mut builder = WalkBuilder::new(root);
17
+ builder.git_ignore(gitignore_config.enabled);
18
+ builder.hidden(false);
19
+ builder.follow_links(false);
20
+
21
+ if let Some(scope) = scope {
22
+ let scope = scope.to_path_buf();
23
+ builder.filter_entry(move |entry| entry.path().starts_with(&scope));
24
+ }
25
+
26
+ let mut files = Vec::new();
27
+ for entry in builder.build() {
28
+ let entry = entry?;
29
+ if entry.path().is_file() && matches_typescript_file(entry.path()) {
30
+ files.push(entry.path().to_path_buf());
31
+ }
32
+ }
33
+
34
+ Ok(files)
35
+ }
36
+
37
+ fn matches_typescript_file(path: &Path) -> bool {
38
+ matches!(
39
+ path.extension().and_then(|ext| ext.to_str()),
40
+ Some("ts") | Some("tsx")
41
+ )
42
+ }
package/src/git.rs ADDED
@@ -0,0 +1,63 @@
1
+ use std::io::{self, Write};
2
+ use std::path::PathBuf;
3
+ use std::process::Command;
4
+
5
+ pub fn get_changed_typescript_files() -> Vec<PathBuf> {
6
+ let output = Command::new("git")
7
+ .args(["diff", "--name-only", "HEAD"])
8
+ .output();
9
+
10
+ let staged_output = Command::new("git")
11
+ .args(["diff", "--name-only", "--cached"])
12
+ .output();
13
+
14
+ let mut files: Vec<PathBuf> = Vec::new();
15
+
16
+ if let Ok(output) = output {
17
+ let stdout = String::from_utf8_lossy(&output.stdout);
18
+ for line in stdout.lines() {
19
+ if is_typescript_file(line) {
20
+ files.push(PathBuf::from(line));
21
+ }
22
+ }
23
+ }
24
+
25
+ if let Ok(output) = staged_output {
26
+ let stdout = String::from_utf8_lossy(&output.stdout);
27
+ for line in stdout.lines() {
28
+ if is_typescript_file(line) {
29
+ let path = PathBuf::from(line);
30
+ if !files.contains(&path) {
31
+ files.push(path);
32
+ }
33
+ }
34
+ }
35
+ }
36
+
37
+ files
38
+ }
39
+
40
+ pub fn prompt_scan_changed_files(changed_files: &[PathBuf]) -> bool {
41
+ if changed_files.is_empty() {
42
+ return false;
43
+ }
44
+
45
+ println!("Found {} changed TypeScript file(s):", changed_files.len());
46
+ for file in changed_files {
47
+ println!(" {}", file.display());
48
+ }
49
+ println!();
50
+
51
+ print!("Scan only changed files? [Y/n] ");
52
+ io::stdout().flush().ok();
53
+
54
+ let mut input = String::new();
55
+ io::stdin().read_line(&mut input).ok();
56
+
57
+ let input = input.trim().to_lowercase();
58
+ input.is_empty() || input == "y" || input == "yes"
59
+ }
60
+
61
+ fn is_typescript_file(path: &str) -> bool {
62
+ path.ends_with(".ts") || path.ends_with(".tsx")
63
+ }
package/src/main.rs ADDED
@@ -0,0 +1,13 @@
1
+ mod app;
2
+ mod cli;
3
+ mod config;
4
+ mod discovery;
5
+ mod git;
6
+ mod report;
7
+ mod rules;
8
+
9
+ use anyhow::Result;
10
+
11
+ fn main() -> Result<()> {
12
+ app::run()
13
+ }
package/src/report.rs ADDED
@@ -0,0 +1,490 @@
1
+ use std::path::PathBuf;
2
+
3
+ use crate::config::Severity;
4
+ use crate::rules::Violation;
5
+
6
+ const RED: &str = "\x1b[31m";
7
+ const YELLOW: &str = "\x1b[33m";
8
+ const GREEN: &str = "\x1b[32m";
9
+ const CYAN: &str = "\x1b[36m";
10
+ const BLUE: &str = "\x1b[34m";
11
+ const BOLD: &str = "\x1b[1m";
12
+ const DIM: &str = "\x1b[2m";
13
+ const RESET: &str = "\x1b[0m";
14
+ const DEFAULT_MAX_RULE_GROUPS: usize = 6;
15
+ const DEFAULT_MAX_FILES_PER_RULE: usize = 6;
16
+ const DEFAULT_MAX_LINES_PER_FILE: usize = 8;
17
+
18
+ #[derive(Debug, Clone)]
19
+ pub struct Report {
20
+ files: Vec<PathBuf>,
21
+ violations: Vec<Violation>,
22
+ }
23
+
24
+ impl Report {
25
+ pub fn new(files: Vec<PathBuf>, violations: Vec<Violation>) -> Self {
26
+ Self { files, violations }
27
+ }
28
+
29
+ pub fn render_text(&self, verbose: bool) -> String {
30
+ let warning_count = self.count_by_severity(Severity::Warn);
31
+ let error_count = self.count_by_severity(Severity::Error);
32
+ let info_count = self.count_by_severity(Severity::Info);
33
+ let score = self.score(error_count, warning_count);
34
+ let mut output = String::new();
35
+
36
+ output.push_str(&render_header());
37
+
38
+ if self.violations.is_empty() {
39
+ output.push_str(&format!(
40
+ "{GREEN}{BOLD}No structural issues found.{RESET}\n"
41
+ ));
42
+ } else {
43
+ let rule_groups = group_by_rule(&self.violations);
44
+ output.push_str(&render_findings(&rule_groups, verbose));
45
+ output.push('\n');
46
+ output.push_str(&render_end_summary(
47
+ self.files.len(),
48
+ self.violations.len(),
49
+ error_count,
50
+ warning_count,
51
+ info_count,
52
+ score,
53
+ &rule_groups,
54
+ verbose,
55
+ ));
56
+ }
57
+
58
+ output
59
+ }
60
+
61
+ fn count_by_severity(&self, severity: Severity) -> usize {
62
+ self.violations
63
+ .iter()
64
+ .filter(|violation| violation.severity == severity)
65
+ .count()
66
+ }
67
+
68
+ fn score(&self, error_count: usize, warning_count: usize) -> usize {
69
+ if self.files.is_empty() || self.violations.is_empty() {
70
+ return 100;
71
+ }
72
+
73
+ let weighted_findings = error_count.saturating_mul(2) + warning_count;
74
+ let files_scanned = self.files.len().max(1);
75
+ let penalty = weighted_findings.saturating_mul(100) / files_scanned;
76
+
77
+ 100usize.saturating_sub(penalty)
78
+ }
79
+ }
80
+
81
+ #[derive(Debug)]
82
+ struct RuleGroup<'a> {
83
+ severity: Severity,
84
+ rule: &'static str,
85
+ message: &'static str,
86
+ violations: Vec<&'a Violation>,
87
+ }
88
+
89
+ #[derive(Debug)]
90
+ struct FileGroup<'a> {
91
+ file: PathBuf,
92
+ violations: Vec<&'a Violation>,
93
+ }
94
+
95
+ fn render_header() -> String {
96
+ format!("{BOLD}Niteo Structure Health{RESET}\n\n")
97
+ }
98
+
99
+ fn render_end_summary(
100
+ file_count: usize,
101
+ violation_count: usize,
102
+ error_count: usize,
103
+ warning_count: usize,
104
+ info_count: usize,
105
+ score: usize,
106
+ rule_groups: &[RuleGroup<'_>],
107
+ verbose: bool,
108
+ ) -> String {
109
+ let status = status_label(error_count, warning_count, info_count);
110
+ let status_color = status_color(error_count, warning_count, info_count);
111
+ let score_color = score_color(score);
112
+ let mut output = format!(
113
+ "{score_color}{BOLD}Score {score}/100{RESET} {status_color}{BOLD}{status}{RESET}\n\
114
+ {DIM}{file_count} files scanned | {violation_count} findings | {error_count} errors | {warning_count} warnings | {info_count} info{RESET}\n\n"
115
+ );
116
+ output.push_str(&render_rule_overview(rule_groups, verbose));
117
+ output
118
+ }
119
+
120
+ fn render_rule_overview(rule_groups: &[RuleGroup<'_>], verbose: bool) -> String {
121
+ let visible_count = visible_rule_group_count(rule_groups.len(), verbose);
122
+ let hidden_count = rule_groups.len().saturating_sub(visible_count);
123
+ let max_count_width = rule_groups
124
+ .iter()
125
+ .take(visible_count)
126
+ .map(|g| g.violations.len().to_string().len())
127
+ .max()
128
+ .unwrap_or(1);
129
+ let max_rule_width = rule_groups
130
+ .iter()
131
+ .take(visible_count)
132
+ .map(|g| g.rule.len())
133
+ .max()
134
+ .unwrap_or(1);
135
+ let mut output = format!("{BOLD}Rule Overview{RESET}\n");
136
+
137
+ for (index, group) in rule_groups.iter().take(visible_count).enumerate() {
138
+ let rank = index + 1;
139
+ let color = severity_color(group.severity);
140
+ let label = pluralized_label(group.severity);
141
+ let count_str = format!("{:>cw$}", group.violations.len(), cw = max_count_width);
142
+ let rule_str = format!("{:<rw$}", group.rule, rw = max_rule_width);
143
+ output.push_str(&format!(
144
+ " {DIM}{rank:>2}.{RESET} {color}{count_str}{RESET} {label} \
145
+ {rule_str} {message}\n",
146
+ message = group.message,
147
+ ));
148
+ }
149
+
150
+ if hidden_count > 0 {
151
+ output.push_str(&format!(
152
+ " {DIM}... {hidden_count} more rules hidden. Run with --verbose to show all.{RESET}\n"
153
+ ));
154
+ }
155
+
156
+ output
157
+ }
158
+
159
+ fn render_findings(rule_groups: &[RuleGroup<'_>], verbose: bool) -> String {
160
+ let visible_count = visible_rule_group_count(rule_groups.len(), verbose);
161
+ let hidden_count = rule_groups.len().saturating_sub(visible_count);
162
+ let mut output = format!("{BOLD}Findings{RESET}\n");
163
+
164
+ for group in rule_groups.iter().take(visible_count) {
165
+ output.push_str(&render_rule_group(group, verbose));
166
+ }
167
+
168
+ if hidden_count > 0 {
169
+ output.push_str(&format!(
170
+ "{DIM}Hidden rule groups: {hidden_count}. Run with --verbose for the full report.{RESET}\n"
171
+ ));
172
+ }
173
+
174
+ output
175
+ }
176
+
177
+ fn render_rule_group(group: &RuleGroup<'_>, verbose: bool) -> String {
178
+ let color = severity_color(group.severity);
179
+ let label = pluralized_label(group.severity);
180
+ let file_groups = group_by_file(&group.violations);
181
+ let visible_file_count = visible_file_count(file_groups.len(), verbose);
182
+ let mut output = format!(
183
+ "\n{color}{BOLD}{rule}{RESET} {DIM}{count} {label} in {file_count} files{RESET}\n\
184
+ {DIM}{message}{RESET}\n",
185
+ rule = group.rule,
186
+ count = group.violations.len(),
187
+ file_count = file_groups.len(),
188
+ message = group.message,
189
+ );
190
+
191
+ for file_group in file_groups.iter().take(visible_file_count) {
192
+ let line_count = visible_line_count(file_group.violations.len(), verbose);
193
+ let has_details = file_group
194
+ .violations
195
+ .iter()
196
+ .any(|v| v.detail.is_some() || v.subject.is_some());
197
+
198
+ if has_details {
199
+ let visible_violations = file_group.violations.iter().take(line_count);
200
+ for violation in visible_violations {
201
+ let subject = violation
202
+ .subject
203
+ .as_ref()
204
+ .map(|s| format!("{BOLD}{s}{RESET} "))
205
+ .unwrap_or_default();
206
+ let detail = violation
207
+ .detail
208
+ .as_ref()
209
+ .map(|d| format!(" {DIM}{d}{RESET}"))
210
+ .unwrap_or_default();
211
+ let location = match (violation.line, violation.column) {
212
+ (Some(line), Some(column)) => format!("lines {line}:{column}"),
213
+ (Some(line), None) => format!("line {line}"),
214
+ _ => String::new(),
215
+ };
216
+ let location_suffix = if location.is_empty() {
217
+ String::new()
218
+ } else {
219
+ format!(" {location}")
220
+ };
221
+ output.push_str(&format!(
222
+ " {CYAN}{}{RESET} {subject}{location_suffix}{detail}\n",
223
+ file_group.file.display(),
224
+ ));
225
+ }
226
+ } else {
227
+ output.push_str(&format!(
228
+ " {CYAN}{}{RESET} {DIM}({} findings){RESET} {}\n",
229
+ file_group.file.display(),
230
+ file_group.violations.len(),
231
+ render_line_numbers(
232
+ file_group.violations.iter().take(line_count).copied(),
233
+ file_group.violations.len(),
234
+ verbose,
235
+ ),
236
+ ));
237
+ }
238
+
239
+ let hidden_lines = file_group.violations.len().saturating_sub(line_count);
240
+ if hidden_lines > 0 && !has_details {
241
+ output.push_str(&format!(
242
+ " {DIM}+ {hidden_lines} more locations in this file. Use --verbose to show all.{RESET}\n"
243
+ ));
244
+ } else if hidden_lines > 0 {
245
+ output.push_str(&format!(
246
+ " {DIM}+ {hidden_lines} more findings in this file. Use --verbose to show all.{RESET}\n"
247
+ ));
248
+ }
249
+ }
250
+
251
+ let hidden_file_count = file_groups.len().saturating_sub(visible_file_count);
252
+ if hidden_file_count > 0 {
253
+ let hidden_violation_count = file_groups
254
+ .iter()
255
+ .skip(visible_file_count)
256
+ .map(|file_group| file_group.violations.len())
257
+ .sum::<usize>();
258
+ output.push_str(&format!(
259
+ " {DIM}+ {hidden_file_count} more files with {hidden_violation_count} findings. Use --verbose to show all files.{RESET}\n"
260
+ ));
261
+ }
262
+
263
+ output
264
+ }
265
+
266
+ fn group_by_rule<'a>(violations: &'a [Violation]) -> Vec<RuleGroup<'a>> {
267
+ let mut groups: Vec<RuleGroup<'a>> = Vec::new();
268
+
269
+ for violation in violations {
270
+ if let Some(group) = groups
271
+ .iter_mut()
272
+ .find(|group| group.severity == violation.severity && group.rule == violation.rule)
273
+ {
274
+ group.violations.push(violation);
275
+ continue;
276
+ }
277
+
278
+ groups.push(RuleGroup {
279
+ severity: violation.severity,
280
+ rule: violation.rule,
281
+ message: violation.message,
282
+ violations: vec![violation],
283
+ });
284
+ }
285
+
286
+ for group in &mut groups {
287
+ group.violations.sort_by(|left, right| {
288
+ left.file
289
+ .cmp(&right.file)
290
+ .then(left.line.cmp(&right.line))
291
+ .then(left.column.cmp(&right.column))
292
+ .then(left.rule.cmp(right.rule))
293
+ });
294
+ }
295
+
296
+ groups.sort_by(|left, right| {
297
+ severity_rank(left.severity)
298
+ .cmp(&severity_rank(right.severity))
299
+ .then(right.violations.len().cmp(&left.violations.len()))
300
+ .then(left.rule.cmp(right.rule))
301
+ });
302
+ groups
303
+ }
304
+
305
+ fn group_by_file<'a>(violations: &[&'a Violation]) -> Vec<FileGroup<'a>> {
306
+ let mut groups: Vec<FileGroup<'a>> = Vec::new();
307
+
308
+ for violation in violations {
309
+ if let Some(group) = groups.iter_mut().find(|group| group.file == violation.file) {
310
+ group.violations.push(violation);
311
+ continue;
312
+ }
313
+
314
+ groups.push(FileGroup {
315
+ file: violation.file.clone(),
316
+ violations: vec![violation],
317
+ });
318
+ }
319
+
320
+ groups.sort_by(|left, right| left.file.cmp(&right.file));
321
+ groups
322
+ }
323
+
324
+ fn visible_rule_group_count(group_count: usize, verbose: bool) -> usize {
325
+ if verbose {
326
+ return group_count;
327
+ }
328
+
329
+ group_count.min(DEFAULT_MAX_RULE_GROUPS)
330
+ }
331
+
332
+ fn visible_file_count(file_count: usize, verbose: bool) -> usize {
333
+ if verbose {
334
+ return file_count;
335
+ }
336
+
337
+ file_count.min(DEFAULT_MAX_FILES_PER_RULE)
338
+ }
339
+
340
+ fn visible_line_count(line_count: usize, verbose: bool) -> usize {
341
+ if verbose {
342
+ return line_count;
343
+ }
344
+
345
+ line_count.min(DEFAULT_MAX_LINES_PER_FILE)
346
+ }
347
+
348
+ fn render_line_numbers<'a>(
349
+ violations: impl Iterator<Item = &'a Violation>,
350
+ total_count: usize,
351
+ verbose: bool,
352
+ ) -> String {
353
+ let violations: Vec<&'a Violation> = violations.collect();
354
+ let positioned: Vec<&'a Violation> = violations
355
+ .iter()
356
+ .filter(|v| v.line.is_some())
357
+ .copied()
358
+ .collect();
359
+ let ranges = if verbose {
360
+ positioned
361
+ .iter()
362
+ .filter_map(|v| {
363
+ v.line
364
+ .map(|line| format!("{}:{}", line, v.column.unwrap_or(1)))
365
+ })
366
+ .collect::<Vec<String>>()
367
+ } else {
368
+ group_line_ranges(&positioned)
369
+ };
370
+ let lines = ranges.join(", ");
371
+
372
+ let suffix = if !verbose && total_count > DEFAULT_MAX_LINES_PER_FILE {
373
+ format!(
374
+ ", {DIM}...and {} more{RESET}",
375
+ total_count.saturating_sub(DEFAULT_MAX_LINES_PER_FILE)
376
+ )
377
+ } else {
378
+ String::new()
379
+ };
380
+
381
+ if lines.is_empty() {
382
+ format!("{DIM}{suffix}{RESET}").trim().to_string()
383
+ } else {
384
+ format!("{DIM}lines {lines}{suffix}{RESET}")
385
+ }
386
+ }
387
+
388
+ fn group_line_ranges(violations: &[&Violation]) -> Vec<String> {
389
+ if violations.is_empty() {
390
+ return Vec::new();
391
+ }
392
+
393
+ let mut ranges: Vec<String> = Vec::new();
394
+ let mut start = violations[0].line.unwrap_or(1);
395
+ let mut end = violations[0].line.unwrap_or(1);
396
+
397
+ for violation in violations.iter().skip(1) {
398
+ let line = violation.line.unwrap_or(1);
399
+ if line == end + 1 {
400
+ end = line;
401
+ } else {
402
+ if start == end {
403
+ ranges.push(format!("{start}"));
404
+ } else {
405
+ ranges.push(format!("{start}-{end}"));
406
+ }
407
+ start = line;
408
+ end = line;
409
+ }
410
+ }
411
+
412
+ if start == end {
413
+ ranges.push(format!("{start}"));
414
+ } else {
415
+ ranges.push(format!("{start}-{end}"));
416
+ }
417
+
418
+ ranges
419
+ }
420
+
421
+ fn status_label(error_count: usize, warning_count: usize, info_count: usize) -> &'static str {
422
+ if error_count > 0 {
423
+ return "Needs attention";
424
+ }
425
+
426
+ if warning_count > 0 {
427
+ return "Review recommended";
428
+ }
429
+
430
+ if info_count > 0 {
431
+ return "Suggestions available";
432
+ }
433
+
434
+ "Healthy"
435
+ }
436
+
437
+ fn status_color(error_count: usize, warning_count: usize, info_count: usize) -> &'static str {
438
+ if error_count > 0 {
439
+ return RED;
440
+ }
441
+
442
+ if warning_count > 0 {
443
+ return YELLOW;
444
+ }
445
+
446
+ if info_count > 0 {
447
+ return BLUE;
448
+ }
449
+
450
+ GREEN
451
+ }
452
+
453
+ fn severity_color(severity: Severity) -> &'static str {
454
+ match severity {
455
+ Severity::Error => RED,
456
+ Severity::Warn => YELLOW,
457
+ Severity::Info => BLUE,
458
+ Severity::Off => DIM,
459
+ }
460
+ }
461
+
462
+ fn severity_rank(severity: Severity) -> usize {
463
+ match severity {
464
+ Severity::Error => 0,
465
+ Severity::Warn => 1,
466
+ Severity::Info => 2,
467
+ Severity::Off => 3,
468
+ }
469
+ }
470
+
471
+ fn pluralized_label(severity: Severity) -> &'static str {
472
+ match severity {
473
+ Severity::Error => "errors",
474
+ Severity::Warn => "warnings",
475
+ Severity::Info => "suggestions",
476
+ Severity::Off => "off",
477
+ }
478
+ }
479
+
480
+ fn score_color(score: usize) -> &'static str {
481
+ if score >= 75 {
482
+ return GREEN;
483
+ }
484
+
485
+ if score >= 50 {
486
+ return YELLOW;
487
+ }
488
+
489
+ RED
490
+ }