@frozenproductions/niteo 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,13 +23,20 @@ Exact rules, configuration options, and report details are still changing during
23
23
 
24
24
  ## Installation
25
25
 
26
- After publishing the npm package, run it with `npx`:
26
+ Run directly with `npx`:
27
27
 
28
28
  ```sh
29
29
  npx @frozenproductions/niteo lint
30
30
  ```
31
31
 
32
- The npm package builds the Rust binary during installation, so Rust and Cargo must be installed on the machine running `npx`.
32
+ Or install globally and use the `niteo` command:
33
+
34
+ ```sh
35
+ npm i -g @frozenproductions/niteo
36
+ niteo lint
37
+ ```
38
+
39
+ The npm package builds the Rust binary during installation, so Rust and Cargo must be installed on the machine running `npx` or `npm i -g`.
33
40
 
34
41
  For local development, run it from source:
35
42
 
@@ -55,30 +62,52 @@ Scan the default project root:
55
62
 
56
63
  ```sh
57
64
  npx @frozenproductions/niteo lint
65
+ # or, after npm i -g @frozenproductions/niteo
66
+ niteo lint
58
67
  ```
59
68
 
60
69
  Generate a starter config:
61
70
 
62
71
  ```sh
63
72
  npx @frozenproductions/niteo init
73
+ # or
74
+ niteo init
64
75
  ```
65
76
 
66
77
  Scan a specific root:
67
78
 
68
79
  ```sh
69
80
  npx @frozenproductions/niteo lint --root src
81
+ # or
82
+ niteo lint --root src
70
83
  ```
71
84
 
72
85
  Restrict the scan to a path:
73
86
 
74
87
  ```sh
75
88
  npx @frozenproductions/niteo lint --scope src/components
89
+ # or
90
+ niteo lint --scope src/components
91
+ ```
92
+
93
+ Write JSON output to a file:
94
+
95
+ ```sh
96
+ niteo lint --format json --output niteo-report.json
97
+ ```
98
+
99
+ Write SARIF output for code scanning tools:
100
+
101
+ ```sh
102
+ niteo lint --format sarif --output niteo-report.sarif
76
103
  ```
77
104
 
78
105
  Show help:
79
106
 
80
107
  ```sh
81
108
  npx @frozenproductions/niteo --help
109
+ # or
110
+ niteo --help
82
111
  ```
83
112
 
84
113
  ## Configuration
@@ -89,6 +118,8 @@ You can generate a starter config with:
89
118
 
90
119
  ```sh
91
120
  npx @frozenproductions/niteo init
121
+ # or, after npm i -g @frozenproductions/niteo
122
+ niteo init
92
123
  ```
93
124
 
94
125
  The config format is not stable yet, so prefer generating it from the CLI instead of copying old examples.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frozenproductions/niteo",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Standalone Rust CLI for TypeScript structural linting.",
5
5
  "license": "MIT",
6
6
  "private": false,
package/src/app.rs CHANGED
@@ -1,9 +1,10 @@
1
- use anyhow::Result;
1
+ use anyhow::{Context, Result};
2
2
  use clap::Parser;
3
3
  use std::env;
4
+ use std::fs;
4
5
  use std::path::{Path, PathBuf};
5
6
 
6
- use crate::cli::{Cli, Command};
7
+ use crate::cli::{Cli, Command, OutputFormat};
7
8
  use crate::{config, discovery, git, report, rules};
8
9
 
9
10
  pub fn run() -> Result<()> {
@@ -18,6 +19,8 @@ pub fn run() -> Result<()> {
18
19
  cli.options.scope,
19
20
  cli.options.verbose,
20
21
  cli.options.git,
22
+ cli.options.format,
23
+ cli.options.output,
21
24
  ),
22
25
  }
23
26
  }
@@ -35,6 +38,8 @@ fn lint_workspace(
35
38
  scope_override: Option<PathBuf>,
36
39
  verbose: bool,
37
40
  git_flag: bool,
41
+ output_format: OutputFormat,
42
+ output_path: Option<PathBuf>,
38
43
  ) -> Result<()> {
39
44
  let project_config = config::ProjectConfig::resolve(workspace, root_override)?;
40
45
  let scan_scope = scope_override.map(|scope| resolve_path(workspace, scope));
@@ -103,8 +108,34 @@ fn lint_workspace(
103
108
  all_violations.append(&mut depth_violations);
104
109
 
105
110
  let report = report::Report::new(files, all_violations);
111
+ let rendered_report = match output_format {
112
+ OutputFormat::Text => report.render_text(verbose),
113
+ OutputFormat::Json => report.render_json()?,
114
+ OutputFormat::Sarif => report.render_sarif()?,
115
+ };
116
+
117
+ write_report(workspace, output_path, &rendered_report)?;
118
+
119
+ Ok(())
120
+ }
106
121
 
107
- println!("{}", report.render_text(verbose));
122
+ fn write_report(
123
+ workspace: &Path,
124
+ output_path: Option<PathBuf>,
125
+ rendered_report: &str,
126
+ ) -> Result<()> {
127
+ let Some(output_path) = output_path else {
128
+ println!("{rendered_report}");
129
+ return Ok(());
130
+ };
131
+
132
+ let resolved_output_path = resolve_path(workspace, output_path);
133
+ if let Some(parent) = resolved_output_path.parent() {
134
+ fs::create_dir_all(parent)
135
+ .with_context(|| format!("failed to create {}", parent.display()))?;
136
+ }
137
+ fs::write(&resolved_output_path, rendered_report)
138
+ .with_context(|| format!("failed to write {}", resolved_output_path.display()))?;
108
139
 
109
140
  Ok(())
110
141
  }
package/src/cli.rs CHANGED
@@ -1,4 +1,4 @@
1
- use clap::{Args, Parser, Subcommand};
1
+ use clap::{Args, Parser, Subcommand, ValueEnum};
2
2
  use std::path::PathBuf;
3
3
 
4
4
  #[derive(Debug, Parser)]
@@ -32,6 +32,14 @@ pub struct CliOptions {
32
32
  /// Scan only changed TypeScript files (skips prompt).
33
33
  #[arg(long, global = true)]
34
34
  pub git: bool,
35
+
36
+ /// Report output format.
37
+ #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)]
38
+ pub format: OutputFormat,
39
+
40
+ /// Write the report to a file instead of stdout.
41
+ #[arg(short, long, global = true)]
42
+ pub output: Option<PathBuf>,
35
43
  }
36
44
 
37
45
  #[derive(Debug, Subcommand)]
@@ -41,3 +49,10 @@ pub enum Command {
41
49
  /// Scan the project for structural issues.
42
50
  Lint,
43
51
  }
52
+
53
+ #[derive(Debug, Clone, Copy, ValueEnum)]
54
+ pub enum OutputFormat {
55
+ Text,
56
+ Json,
57
+ Sarif,
58
+ }
package/src/report.rs CHANGED
@@ -1,3 +1,5 @@
1
+ use anyhow::Result;
2
+ use serde_json::{Value, json};
1
3
  use std::path::PathBuf;
2
4
 
3
5
  use crate::config::Severity;
@@ -58,6 +60,70 @@ impl Report {
58
60
  output
59
61
  }
60
62
 
63
+ pub fn render_json(&self) -> Result<String> {
64
+ let report = json!({
65
+ "summary": self.summary_json(),
66
+ "files": self
67
+ .files
68
+ .iter()
69
+ .map(|file| path_to_string(file))
70
+ .collect::<Vec<String>>(),
71
+ "violations": self
72
+ .violations
73
+ .iter()
74
+ .map(violation_json)
75
+ .collect::<Vec<Value>>(),
76
+ });
77
+
78
+ Ok(serde_json::to_string_pretty(&report)?)
79
+ }
80
+
81
+ pub fn render_sarif(&self) -> Result<String> {
82
+ let rules = group_by_rule(&self.violations)
83
+ .iter()
84
+ .map(|group| {
85
+ json!({
86
+ "id": group.rule,
87
+ "name": group.rule,
88
+ "shortDescription": {
89
+ "text": group.message,
90
+ },
91
+ "defaultConfiguration": {
92
+ "level": sarif_level(group.severity),
93
+ },
94
+ })
95
+ })
96
+ .collect::<Vec<Value>>();
97
+
98
+ let results = self
99
+ .violations
100
+ .iter()
101
+ .map(sarif_result_json)
102
+ .collect::<Vec<Value>>();
103
+
104
+ let report = json!({
105
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
106
+ "version": "2.1.0",
107
+ "runs": [
108
+ {
109
+ "tool": {
110
+ "driver": {
111
+ "name": "Niteo",
112
+ "informationUri": "https://github.com/FrozenProductions/Niteo",
113
+ "rules": rules,
114
+ },
115
+ },
116
+ "results": results,
117
+ "properties": {
118
+ "summary": self.summary_json(),
119
+ },
120
+ },
121
+ ],
122
+ });
123
+
124
+ Ok(serde_json::to_string_pretty(&report)?)
125
+ }
126
+
61
127
  fn count_by_severity(&self, severity: Severity) -> usize {
62
128
  self.violations
63
129
  .iter()
@@ -76,6 +142,90 @@ impl Report {
76
142
 
77
143
  100usize.saturating_sub(penalty)
78
144
  }
145
+
146
+ fn summary_json(&self) -> Value {
147
+ let warning_count = self.count_by_severity(Severity::Warn);
148
+ let error_count = self.count_by_severity(Severity::Error);
149
+ let info_count = self.count_by_severity(Severity::Info);
150
+
151
+ json!({
152
+ "filesScanned": self.files.len(),
153
+ "violations": self.violations.len(),
154
+ "errors": error_count,
155
+ "warnings": warning_count,
156
+ "info": info_count,
157
+ "score": self.score(error_count, warning_count),
158
+ "status": status_label(error_count, warning_count, info_count),
159
+ })
160
+ }
161
+ }
162
+
163
+ fn violation_json(violation: &Violation) -> Value {
164
+ json!({
165
+ "file": path_to_string(&violation.file),
166
+ "line": violation.line,
167
+ "column": violation.column,
168
+ "rule": violation.rule,
169
+ "message": violation.message,
170
+ "severity": severity_label(violation.severity),
171
+ "detail": violation.detail,
172
+ "subject": violation.subject,
173
+ })
174
+ }
175
+
176
+ fn sarif_result_json(violation: &Violation) -> Value {
177
+ let mut message = violation.message.to_string();
178
+ if let Some(detail) = &violation.detail {
179
+ message.push(' ');
180
+ message.push_str(detail);
181
+ }
182
+
183
+ json!({
184
+ "ruleId": violation.rule,
185
+ "level": sarif_level(violation.severity),
186
+ "message": {
187
+ "text": message,
188
+ },
189
+ "locations": [
190
+ {
191
+ "physicalLocation": {
192
+ "artifactLocation": {
193
+ "uri": path_to_string(&violation.file),
194
+ },
195
+ "region": {
196
+ "startLine": violation.line.unwrap_or(1),
197
+ "startColumn": violation.column.unwrap_or(1),
198
+ },
199
+ },
200
+ },
201
+ ],
202
+ "properties": {
203
+ "severity": severity_label(violation.severity),
204
+ "subject": violation.subject,
205
+ },
206
+ })
207
+ }
208
+
209
+ fn path_to_string(path: &PathBuf) -> String {
210
+ path.display().to_string()
211
+ }
212
+
213
+ fn severity_label(severity: Severity) -> &'static str {
214
+ match severity {
215
+ Severity::Error => "error",
216
+ Severity::Warn => "warning",
217
+ Severity::Info => "info",
218
+ Severity::Off => "off",
219
+ }
220
+ }
221
+
222
+ fn sarif_level(severity: Severity) -> &'static str {
223
+ match severity {
224
+ Severity::Error => "error",
225
+ Severity::Warn => "warning",
226
+ Severity::Info => "note",
227
+ Severity::Off => "none",
228
+ }
79
229
  }
80
230
 
81
231
  #[derive(Debug)]