1use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10use crate::json_envelope::{JsonError, JsonWarning};
11
12use super::lint_report::LINT_SCHEMA_VERSION;
13
14#[derive(Debug, Clone, Copy, Default)]
16pub struct LintDecodeOptions {
17 pub exit_status: Option<i32>,
19 pub expected_schema_version: Option<u32>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DecodedLintEnvelope {
26 #[serde(rename = "schemaVersion")]
27 pub schema_version: u32,
28 pub ok: bool,
29 pub data: Option<LintReportWire>,
30 pub error: Option<JsonError>,
31 #[serde(default)]
32 pub warnings: Vec<JsonWarning>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct LintReportWire {
38 pub files: Vec<LintFileReportWire>,
39 pub summary: LintSummaryWire,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub changed: Option<ChangedLintScopeWire>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct LintFileReportWire {
46 pub path: String,
47 pub status: String,
48 pub diagnostics: Vec<CheckDiagnosticWire>,
49 pub fixable: u64,
50 pub fixed: u64,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct LintSummaryWire {
55 pub ok: u64,
56 pub warnings: u64,
57 pub errors: u64,
58 pub diagnostics: u64,
59 pub fixable: u64,
60 pub fixed: u64,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct CheckDiagnosticWire {
65 pub source: String,
66 pub severity: String,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub code: Option<String>,
69 pub message: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub span: Option<CheckSpanWire>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub help: Option<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CheckSpanWire {
79 pub start: u64,
80 pub end: u64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct ChangedLintScopeWire {
85 pub from: EvaluatedRevisionWire,
86 pub to: EvaluatedRevisionWire,
87 pub files: Vec<ChangedSourceFileWire>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EvaluatedRevisionWire {
92 pub requested: String,
93 pub commit: String,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct ChangedSourceFileWire {
98 pub path: String,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub previous_path: Option<String>,
101 pub status: String,
102 pub added_lines: Vec<AddedLineRangeWire>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub struct AddedLineRangeWire {
108 pub start: u64,
109 pub end: u64,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct LintDecodeError {
115 pub kind: &'static str,
116 pub message: String,
117}
118
119impl std::fmt::Display for LintDecodeError {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 write!(f, "{}: {}", self.kind, self.message)
122 }
123}
124
125impl std::error::Error for LintDecodeError {}
126
127impl LintDecodeError {
128 fn new(kind: &'static str, message: impl Into<String>) -> Self {
129 Self {
130 kind,
131 message: message.into(),
132 }
133 }
134}
135
136fn non_neg_int_schema() -> Value {
137 json!({ "type": "integer", "minimum": 0 })
138}
139
140fn revision_schema() -> Value {
141 json!({
142 "type": "object",
143 "additionalProperties": false,
144 "required": ["requested", "commit"],
145 "properties": {
146 "requested": { "type": "string", "minLength": 1 },
147 "commit": { "type": "string", "minLength": 1 },
148 }
149 })
150}
151
152pub fn lint_json_schema() -> Value {
154 let span = json!({
155 "type": "object",
156 "additionalProperties": false,
157 "required": ["start", "end"],
158 "properties": {
159 "start": non_neg_int_schema(),
160 "end": non_neg_int_schema(),
161 },
162 "description": "UTF-8 half-open byte span [start, end)."
163 });
164 let diagnostic = json!({
165 "type": "object",
166 "additionalProperties": false,
167 "required": ["source", "severity", "message"],
168 "properties": {
169 "source": { "type": "string", "minLength": 1 },
170 "severity": { "type": "string", "enum": ["info", "warning", "error"] },
171 "code": { "type": "string", "minLength": 1 },
172 "message": { "type": "string" },
173 "span": span,
174 "help": { "type": "string" },
175 }
176 });
177 let file_report = json!({
178 "type": "object",
179 "additionalProperties": false,
180 "required": ["path", "status", "diagnostics", "fixable", "fixed"],
181 "properties": {
182 "path": { "type": "string", "minLength": 1 },
183 "status": { "type": "string", "enum": ["ok", "warning", "error"] },
184 "diagnostics": { "type": "array", "items": diagnostic },
185 "fixable": non_neg_int_schema(),
186 "fixed": non_neg_int_schema(),
187 }
188 });
189 let summary = json!({
190 "type": "object",
191 "additionalProperties": false,
192 "required": ["ok", "warnings", "errors", "diagnostics", "fixable", "fixed"],
193 "properties": {
194 "ok": non_neg_int_schema(),
195 "warnings": non_neg_int_schema(),
196 "errors": non_neg_int_schema(),
197 "diagnostics": non_neg_int_schema(),
198 "fixable": non_neg_int_schema(),
199 "fixed": non_neg_int_schema(),
200 }
201 });
202 let added_line = json!({
203 "type": "object",
204 "additionalProperties": false,
205 "required": ["start", "end"],
206 "properties": {
207 "start": { "type": "integer", "minimum": 1 },
208 "end": { "type": "integer", "minimum": 1 },
209 },
210 "description": "Inclusive one-based physical line range."
211 });
212 let changed_file = json!({
213 "type": "object",
214 "additionalProperties": false,
215 "required": ["path", "status", "added_lines"],
216 "properties": {
217 "path": { "type": "string", "minLength": 1 },
218 "previous_path": { "type": "string", "minLength": 1 },
219 "status": {
220 "type": "string",
221 "enum": ["added", "copied", "deleted", "modified", "renamed"]
222 },
223 "added_lines": { "type": "array", "items": added_line },
224 }
225 });
226 let changed = json!({
227 "type": "object",
228 "additionalProperties": false,
229 "required": ["from", "to", "files"],
230 "properties": {
231 "from": revision_schema(),
232 "to": revision_schema(),
233 "files": { "type": "array", "items": changed_file },
234 }
235 });
236 let report = json!({
237 "type": "object",
238 "additionalProperties": false,
239 "required": ["files", "summary"],
240 "properties": {
241 "files": { "type": "array", "items": file_report },
242 "summary": summary,
243 "changed": changed,
244 }
245 });
246 let warning = json!({
247 "type": "object",
248 "additionalProperties": false,
249 "required": ["code", "message"],
250 "properties": {
251 "code": { "type": "string", "minLength": 1 },
252 "message": { "type": "string" },
253 }
254 });
255 let error = json!({
256 "type": "object",
257 "additionalProperties": false,
258 "required": ["code", "message", "details"],
259 "properties": {
260 "code": { "type": "string", "minLength": 1 },
261 "message": { "type": "string", "minLength": 1 },
262 "details": {},
263 }
264 });
265
266 json!({
267 "$schema": "https://json-schema.org/draft/2020-12/schema",
268 "title": "harn lint --json",
269 "type": "object",
270 "additionalProperties": false,
271 "required": ["schemaVersion", "ok", "data", "error", "warnings"],
272 "properties": {
273 "schemaVersion": { "const": LINT_SCHEMA_VERSION },
274 "ok": { "type": "boolean" },
275 "data": {
276 "anyOf": [
277 report,
278 { "type": "null" }
279 ]
280 },
281 "error": {
282 "anyOf": [
283 error,
284 { "type": "null" }
285 ]
286 },
287 "warnings": { "type": "array", "items": warning },
288 },
289 "description": "schema-v1 lint envelope. Diagnostic spans are UTF-8 half-open byte offsets [start, end)."
290 })
291}
292
293pub fn decode_lint_json(
295 text: &str,
296 options: LintDecodeOptions,
297) -> Result<DecodedLintEnvelope, LintDecodeError> {
298 let value: Value = serde_json::from_str(text)
299 .map_err(|err| LintDecodeError::new("json_parse", format!("malformed JSON: {err}")))?;
300 decode_lint_envelope(&value, options)
301}
302
303pub fn decode_lint_envelope(
305 value: &Value,
306 options: LintDecodeOptions,
307) -> Result<DecodedLintEnvelope, LintDecodeError> {
308 let expected = options
309 .expected_schema_version
310 .unwrap_or(LINT_SCHEMA_VERSION);
311 let schema_version = value
312 .get("schemaVersion")
313 .and_then(Value::as_u64)
314 .ok_or_else(|| LintDecodeError::new("schema", "missing or non-integer schemaVersion"))?;
315 if schema_version != u64::from(expected) {
316 return Err(LintDecodeError::new(
317 "unsupported_schema_version",
318 format!("unsupported schemaVersion {schema_version}; expected {expected}"),
319 ));
320 }
321
322 let envelope: DecodedLintEnvelope = serde_json::from_value(value.clone()).map_err(|err| {
323 LintDecodeError::new(
324 "schema",
325 format!("envelope does not match wire types: {err}"),
326 )
327 })?;
328
329 validate_envelope_invariants(&envelope)?;
330 if let Some(report) = &envelope.data {
331 validate_report(report)?;
332 }
333 if let Some(exit_status) = options.exit_status {
334 let exit_ok = exit_status == 0;
335 if exit_ok != envelope.ok {
336 return Err(LintDecodeError::new(
337 "exit_status_mismatch",
338 format!(
339 "process exit status {exit_status} disagrees with envelope.ok={}",
340 envelope.ok
341 ),
342 ));
343 }
344 }
345 Ok(envelope)
346}
347
348fn validate_envelope_invariants(envelope: &DecodedLintEnvelope) -> Result<(), LintDecodeError> {
349 if envelope.ok {
350 if envelope.error.is_some() {
351 return Err(LintDecodeError::new(
352 "envelope_invariant",
353 "ok=true requires error=null",
354 ));
355 }
356 if envelope.data.is_none() {
357 return Err(LintDecodeError::new(
358 "envelope_invariant",
359 "ok=true requires a lint report in data",
360 ));
361 }
362 } else {
363 let error = envelope.error.as_ref().ok_or_else(|| {
364 LintDecodeError::new("envelope_invariant", "ok=false requires an error object")
365 })?;
366 if error.code.is_empty() || error.message.is_empty() {
367 return Err(LintDecodeError::new(
368 "envelope_invariant",
369 "error.code and error.message must be non-empty",
370 ));
371 }
372 }
373 Ok(())
374}
375
376fn validate_report(report: &LintReportWire) -> Result<(), LintDecodeError> {
377 let mut ok = 0u64;
378 let mut warnings = 0u64;
379 let mut errors = 0u64;
380 let mut diagnostics = 0u64;
381 let mut fixable = 0u64;
382 let mut fixed = 0u64;
383
384 for (index, file) in report.files.iter().enumerate() {
385 validate_file(file, index)?;
386 match file.status.as_str() {
387 "ok" => ok += 1,
388 "warning" => warnings += 1,
389 "error" => errors += 1,
390 other => {
391 return Err(LintDecodeError::new(
392 "invalid_status",
393 format!("files[{index}].status has unsupported value {other:?}"),
394 ));
395 }
396 }
397 diagnostics += file.diagnostics.len() as u64;
398 fixable += file.fixable;
399 fixed += file.fixed;
400 }
401
402 let summary = &report.summary;
403 for (name, expected, actual) in [
404 ("ok", ok, summary.ok),
405 ("warnings", warnings, summary.warnings),
406 ("errors", errors, summary.errors),
407 ("diagnostics", diagnostics, summary.diagnostics),
408 ("fixable", fixable, summary.fixable),
409 ("fixed", fixed, summary.fixed),
410 ] {
411 if expected != actual {
412 return Err(LintDecodeError::new(
413 "inconsistent_aggregate",
414 format!("summary.{name}={actual} disagrees with file-derived count {expected}"),
415 ));
416 }
417 }
418
419 if let Some(changed) = &report.changed {
420 validate_changed(changed)?;
421 }
422 Ok(())
423}
424
425fn validate_file(file: &LintFileReportWire, index: usize) -> Result<(), LintDecodeError> {
426 if file.path.is_empty() {
427 return Err(LintDecodeError::new(
428 "schema",
429 format!("files[{index}].path must be non-empty"),
430 ));
431 }
432
433 let mut has_error = false;
434 let mut has_warning = false;
435 for (diag_index, diagnostic) in file.diagnostics.iter().enumerate() {
436 match diagnostic.severity.as_str() {
437 "error" => has_error = true,
438 "warning" => has_warning = true,
439 "info" => {}
440 other => {
441 return Err(LintDecodeError::new(
442 "invalid_severity",
443 format!(
444 "files[{index}].diagnostics[{diag_index}].severity has unsupported value {other:?}"
445 ),
446 ));
447 }
448 }
449 if diagnostic.source.is_empty() {
450 return Err(LintDecodeError::new(
451 "schema",
452 format!("files[{index}].diagnostics[{diag_index}].source must be non-empty"),
453 ));
454 }
455 if let Some(span) = diagnostic.span {
456 if span.start > span.end {
457 return Err(LintDecodeError::new(
458 "invalid_span",
459 format!(
460 "files[{index}].diagnostics[{diag_index}].span has start {} > end {}",
461 span.start, span.end
462 ),
463 ));
464 }
465 }
466 }
467
468 let expected_status = if has_error {
469 "error"
470 } else if has_warning {
471 "warning"
472 } else {
473 "ok"
474 };
475 if file.status != expected_status {
476 return Err(LintDecodeError::new(
477 "inconsistent_status",
478 format!(
479 "files[{index}].status={:?} disagrees with diagnostics (expected {expected_status:?})",
480 file.status
481 ),
482 ));
483 }
484 Ok(())
485}
486
487fn validate_changed(changed: &ChangedLintScopeWire) -> Result<(), LintDecodeError> {
488 for field in [
489 ("from.requested", changed.from.requested.as_str()),
490 ("from.commit", changed.from.commit.as_str()),
491 ("to.requested", changed.to.requested.as_str()),
492 ("to.commit", changed.to.commit.as_str()),
493 ] {
494 if field.1.is_empty() {
495 return Err(LintDecodeError::new(
496 "schema",
497 format!("changed.{} must be non-empty", field.0),
498 ));
499 }
500 }
501 for (index, file) in changed.files.iter().enumerate() {
502 match file.status.as_str() {
503 "added" | "copied" | "deleted" | "modified" | "renamed" => {}
504 other => {
505 return Err(LintDecodeError::new(
506 "schema",
507 format!("changed.files[{index}].status has unsupported value {other:?}"),
508 ));
509 }
510 }
511 for (range_index, range) in file.added_lines.iter().enumerate() {
512 if range.start == 0 || range.end == 0 || range.start > range.end {
513 return Err(LintDecodeError::new(
514 "invalid_span",
515 format!(
516 "changed.files[{index}].added_lines[{range_index}] must be inclusive 1-based with start <= end"
517 ),
518 ));
519 }
520 }
521 }
522 Ok(())
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use serde_json::json;
529
530 fn options_with_exit(exit_status: i32) -> LintDecodeOptions {
531 LintDecodeOptions {
532 exit_status: Some(exit_status),
533 ..LintDecodeOptions::default()
534 }
535 }
536
537 #[test]
538 fn schema_is_draft_2020_12_and_accepts_live_shapes() {
539 let schema = lint_json_schema();
540 jsonschema::draft202012::meta::validate(&schema).expect("meta-schema");
541 let validator = jsonschema::draft202012::new(&schema).expect("compile schema");
542
543 let ok = json!({
544 "schemaVersion": 1,
545 "ok": true,
546 "data": {
547 "files": [{
548 "path": "src/ok.harn",
549 "status": "ok",
550 "diagnostics": [],
551 "fixable": 0,
552 "fixed": 0
553 }],
554 "summary": {
555 "ok": 1,
556 "warnings": 0,
557 "errors": 0,
558 "diagnostics": 0,
559 "fixable": 0,
560 "fixed": 0
561 }
562 },
563 "error": null,
564 "warnings": []
565 });
566 validator.validate(&ok).expect("ok envelope validates");
567
568 let failed = json!({
569 "schemaVersion": 1,
570 "ok": false,
571 "data": {
572 "files": [{
573 "path": "src/agent.harn",
574 "status": "warning",
575 "diagnostics": [{
576 "source": "lint",
577 "severity": "warning",
578 "code": "HARN-LNT-032",
579 "message": "comparison to `false` is redundant",
580 "span": { "start": 128, "end": 142 }
581 }],
582 "fixable": 1,
583 "fixed": 0
584 }],
585 "summary": {
586 "ok": 0,
587 "warnings": 1,
588 "errors": 0,
589 "diagnostics": 1,
590 "fixable": 1,
591 "fixed": 0
592 }
593 },
594 "error": {
595 "code": "lint_failed",
596 "message": "one or more files failed `harn lint`",
597 "details": null
598 },
599 "warnings": []
600 });
601 validator
602 .validate(&failed)
603 .expect("lint_failed envelope validates");
604 }
605
606 #[test]
607 fn decoder_accepts_positive_and_rejects_adversarial() {
608 let positive = include_str!("lint_json_fixtures/positive/clean_ok.json");
609 decode_lint_json(positive, options_with_exit(0)).expect("clean ok");
610
611 let warning = include_str!("lint_json_fixtures/positive/warning_ok.json");
612 decode_lint_json(warning, options_with_exit(0)).expect("warning ok");
613
614 let failed = include_str!("lint_json_fixtures/positive/lint_failed_with_data.json");
615 decode_lint_json(failed, options_with_exit(1)).expect("lint_failed");
616
617 let utf8 = include_str!("lint_json_fixtures/positive/multiline_utf8_span.json");
618 let decoded = decode_lint_json(utf8, options_with_exit(0)).expect("utf8 span");
619 let span = decoded.data.as_ref().unwrap().files[0].diagnostics[0]
620 .span
621 .expect("span");
622 assert_eq!(span.start, 11);
623 assert_eq!(span.end, 24);
624
625 let changed = include_str!("lint_json_fixtures/positive/changed_scope.json");
626 decode_lint_json(changed, options_with_exit(0)).expect("changed scope");
627
628 assert_eq!(
629 decode_lint_json("{not json", LintDecodeOptions::default())
630 .unwrap_err()
631 .kind,
632 "json_parse"
633 );
634 assert_eq!(
635 decode_lint_json(
636 include_str!("lint_json_fixtures/adversarial/unsupported_schema_version.json"),
637 LintDecodeOptions::default()
638 )
639 .unwrap_err()
640 .kind,
641 "unsupported_schema_version"
642 );
643 assert_eq!(
644 decode_lint_json(
645 include_str!("lint_json_fixtures/adversarial/invalid_severity.json"),
646 LintDecodeOptions::default()
647 )
648 .unwrap_err()
649 .kind,
650 "invalid_severity"
651 );
652 assert_eq!(
653 decode_lint_json(
654 include_str!("lint_json_fixtures/adversarial/invalid_span.json"),
655 LintDecodeOptions::default()
656 )
657 .unwrap_err()
658 .kind,
659 "invalid_span"
660 );
661 assert_eq!(
662 decode_lint_json(
663 include_str!("lint_json_fixtures/adversarial/inconsistent_aggregate.json"),
664 LintDecodeOptions::default()
665 )
666 .unwrap_err()
667 .kind,
668 "inconsistent_aggregate"
669 );
670 assert_eq!(
671 decode_lint_json(
672 include_str!("lint_json_fixtures/adversarial/inconsistent_status.json"),
673 LintDecodeOptions::default()
674 )
675 .unwrap_err()
676 .kind,
677 "inconsistent_status"
678 );
679 assert_eq!(
680 decode_lint_json(
681 include_str!("lint_json_fixtures/adversarial/exit_status_mismatch.json"),
682 options_with_exit(1)
683 )
684 .unwrap_err()
685 .kind,
686 "exit_status_mismatch"
687 );
688 }
689}