fraiseql_cli/commands/
lint.rs1use std::{fs, path::Path};
9
10use anyhow::Result;
11use fraiseql_core::design::DesignAudit;
12use serde::Serialize;
13
14use crate::output::CommandResult;
15
16#[derive(Debug, Clone, Default)]
22pub struct LintCategoryFilter {
23 pub federation: bool,
25 pub cost: bool,
27 pub cache: bool,
29 pub auth: bool,
31 pub compilation: bool,
33}
34
35impl LintCategoryFilter {
36 pub fn is_all(&self) -> bool {
38 !self.federation && !self.cost && !self.cache && !self.auth && !self.compilation
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct LintOptions {
45 pub fail_on_critical: bool,
47 pub fail_on_warning: bool,
49 pub filter: LintCategoryFilter,
51}
52
53#[derive(Debug, Serialize)]
55pub struct LintResponse {
56 pub overall_score: u8,
58 pub severity_counts: SeverityCounts,
60 pub categories: CategoryScores,
62}
63
64#[derive(Debug, Serialize)]
66pub struct SeverityCounts {
67 pub critical: usize,
69 pub warning: usize,
71 pub info: usize,
73}
74
75#[derive(Debug, Serialize)]
77pub struct CategoryScores {
78 pub federation: u8,
80 pub cost: u8,
82 pub cache: u8,
84 pub authorization: u8,
86 pub compilation: u8,
88}
89
90pub fn run(schema_path: &str, opts: LintOptions) -> Result<CommandResult> {
97 if !Path::new(schema_path).exists() {
99 return Err(anyhow::anyhow!("Schema file not found: {schema_path}"));
100 }
101
102 let schema_json = fs::read_to_string(schema_path)?;
104
105 let _schema: serde_json::Value = serde_json::from_str(&schema_json)?;
107
108 let audit = DesignAudit::from_schema_json(&schema_json)?;
110
111 let f = &opts.filter;
112 let show_all = f.is_all();
113
114 let fed_issues = if show_all || f.federation {
117 audit.federation_issues.len()
118 } else {
119 0
120 };
121 let cost_issues = if show_all || f.cost {
122 audit.cost_warnings.len()
123 } else {
124 0
125 };
126 let cache_issues = if show_all || f.cache {
127 audit.cache_issues.len()
128 } else {
129 0
130 };
131 let auth_issues = if show_all || f.auth {
132 audit.auth_issues.len()
133 } else {
134 0
135 };
136 let comp_issues = if show_all || f.compilation {
137 audit.schema_issues.len()
138 } else {
139 0
140 };
141
142 let visible_critical = if show_all {
144 audit.severity_count(fraiseql_core::design::IssueSeverity::Critical)
145 } else {
146 use fraiseql_core::design::IssueSeverity;
150 let mut n = 0;
151 if f.federation {
152 n += audit
153 .federation_issues
154 .iter()
155 .filter(|i| i.severity == IssueSeverity::Critical)
156 .count();
157 }
158 if f.cost {
159 n += audit
160 .cost_warnings
161 .iter()
162 .filter(|i| i.severity == IssueSeverity::Critical)
163 .count();
164 }
165 if f.cache {
166 n += audit
167 .cache_issues
168 .iter()
169 .filter(|i| i.severity == IssueSeverity::Critical)
170 .count();
171 }
172 if f.auth {
173 n += audit
174 .auth_issues
175 .iter()
176 .filter(|i| i.severity == IssueSeverity::Critical)
177 .count();
178 }
179 if f.compilation {
180 n += audit
181 .schema_issues
182 .iter()
183 .filter(|i| i.severity == IssueSeverity::Critical)
184 .count();
185 }
186 n
187 };
188
189 if opts.fail_on_critical && visible_critical > 0 {
190 return Ok(CommandResult::error(
191 "lint",
192 "Design audit failed: critical issues found",
193 "DESIGN_AUDIT_FAILED",
194 ));
195 }
196
197 let visible_warning = if show_all {
198 audit.severity_count(fraiseql_core::design::IssueSeverity::Warning)
199 } else {
200 use fraiseql_core::design::IssueSeverity;
201 let mut n = 0;
202 if f.federation {
203 n += audit
204 .federation_issues
205 .iter()
206 .filter(|i| i.severity == IssueSeverity::Warning)
207 .count();
208 }
209 if f.cost {
210 n += audit
211 .cost_warnings
212 .iter()
213 .filter(|i| i.severity == IssueSeverity::Warning)
214 .count();
215 }
216 if f.cache {
217 n += audit
218 .cache_issues
219 .iter()
220 .filter(|i| i.severity == IssueSeverity::Warning)
221 .count();
222 }
223 if f.auth {
224 n += audit
225 .auth_issues
226 .iter()
227 .filter(|i| i.severity == IssueSeverity::Warning)
228 .count();
229 }
230 if f.compilation {
231 n += audit
232 .schema_issues
233 .iter()
234 .filter(|i| i.severity == IssueSeverity::Warning)
235 .count();
236 }
237 n
238 };
239
240 if opts.fail_on_warning && visible_warning > 0 {
241 return Ok(CommandResult::error(
242 "lint",
243 "Design audit failed: warning issues found",
244 "DESIGN_AUDIT_FAILED",
245 ));
246 }
247
248 let score_from_count = |count: usize, penalty: u32| -> u8 {
250 let n = u32::try_from(count).unwrap_or(u32::MAX);
251 #[allow(clippy::cast_possible_truncation)] let score = 100u32.saturating_sub(n * penalty) as u8;
254 score
255 };
256
257 let fed_score = if fed_issues == 0 {
258 100
259 } else {
260 score_from_count(fed_issues, 10)
261 };
262 let cost_score = if cost_issues == 0 {
263 100
264 } else {
265 score_from_count(cost_issues, 8)
266 };
267 let cache_score = if cache_issues == 0 {
268 100
269 } else {
270 score_from_count(cache_issues, 6)
271 };
272 let auth_score = if auth_issues == 0 {
273 100
274 } else {
275 score_from_count(auth_issues, 12)
276 };
277 let comp_score = if comp_issues == 0 {
278 100
279 } else {
280 score_from_count(comp_issues, 10)
281 };
282
283 let severity_counts = SeverityCounts {
284 critical: visible_critical,
285 warning: visible_warning,
286 info: if show_all {
287 audit.severity_count(fraiseql_core::design::IssueSeverity::Info)
288 } else {
289 0 },
291 };
292
293 let response = LintResponse {
294 overall_score: audit.score(),
295 severity_counts,
296 categories: CategoryScores {
297 federation: fed_score,
298 cost: cost_score,
299 cache: cache_score,
300 authorization: auth_score,
301 compilation: comp_score,
302 },
303 };
304
305 Ok(CommandResult::success("lint", serde_json::to_value(&response)?))
306}