Skip to main content

harn_cli/commands/check/
lint_json_contract.rs

1//! Public JSON Schema and fail-closed decoder for `harn lint --json`.
2//!
3//! The schema is published through `harn --json-schemas --command lint`.
4//! Continuous verification against the Rust wire types lives in the unit
5//! tests and fixture matrix shared with `std/cli/envelope`.
6
7use 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/// Options for [`decode_lint_envelope`] / [`decode_lint_json`].
15#[derive(Debug, Clone, Copy, Default)]
16pub struct LintDecodeOptions {
17    /// When set, require `(exit_status == 0) == envelope.ok`.
18    pub exit_status: Option<i32>,
19    /// Defaults to [`LINT_SCHEMA_VERSION`] when `None`.
20    pub expected_schema_version: Option<u32>,
21}
22
23/// Successfully decoded schema-v1 lint envelope.
24#[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/// Wire shape of `JsonEnvelope.data` for `harn lint --json`.
36#[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/// UTF-8 half-open byte span `[start, end)`.
77#[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/// Inclusive one-based physical line range.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107pub struct AddedLineRangeWire {
108    pub start: u64,
109    pub end: u64,
110}
111
112/// Fail-closed decode error for lint JSON envelopes.
113#[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
152/// Complete Draft 2020-12 JSON Schema for the schema-v1 lint envelope.
153pub 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
293/// Parse JSON text and fail closed on structural or semantic contract violations.
294pub 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
303/// Decode a lint envelope value and fail closed on contract violations.
304pub 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}