amont_runtime/hooks/lint_json_yaml.rs
1//! pre-commit-lint-json-yaml — parse staged JSON/YAML so a syntax error never
2//! reaches the repo. Both linters soft-fail when absent: warn, don't block.
3
4use super::common::{fail, hl, ok, repo_root, staged_files, warn, which};
5use crate::check::Outcome;
6use std::path::Path;
7use std::process::{Command, Stdio};
8
9/// The extensions each half consumes, and their union.
10///
11/// EXPORTED so `registry.rs` can DECLARE the scope from the same constant the
12/// check FILTERS with. They drifted: the registry declared
13/// `[".json", ".yaml", ".yml"]` while this module asked for `[".yaml"]`, so
14/// `amont list` reported the check as covering `.yml` and a staged, broken
15/// `x.yml` returned `Outcome::Passed` with no output at all. A `const fn` over
16/// a `const` is legal in a const item, so the registry can reference these
17/// directly and the two cannot disagree again.
18pub const JSON: &[&str] = &[".json"];
19pub const YAML: &[&str] = &[".yaml", ".yml"];
20pub const EXTS: &[&str] = &[".json", ".yaml", ".yml"];
21
22/// Helm chart templates carry Go templating (`{{ }}`) and are not valid YAML
23/// until Helm renders them. A staged YAML under a chart's `templates/` — i.e.
24/// with a sibling `Chart.yaml` at the chart root — is skipped, or valid chart
25/// commits would need --no-verify just to get past this hook.
26pub fn is_helm_template(root: &str, file: &str) -> bool {
27 let Some(i) = file.find("/templates/") else {
28 return false;
29 };
30 Path::new(root)
31 .join(&file[..i])
32 .join("Chart.yaml")
33 .is_file()
34}
35
36fn parses(root: &str, tool: &str, args: &[&str]) -> bool {
37 Command::new(super::common::program(tool))
38 .args(args)
39 .current_dir(root)
40 .stdin(Stdio::null())
41 .stdout(Stdio::null())
42 .stderr(Stdio::null())
43 .status()
44 .map(|s| s.success())
45 .unwrap_or(false)
46}
47
48pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
49 let json: Vec<String> = staged_files(JSON);
50 let yaml: Vec<String> = staged_files(YAML);
51 if json.is_empty() && yaml.is_empty() {
52 return Outcome::Passed;
53 }
54 let root = repo_root();
55 // Two independent halves. A missing parser silences one of them, and a
56 // half that never ran must not be reported as a half that passed — so
57 // both a failure anywhere and a gap anywhere outrank the clean case.
58 let mut failed = false;
59 let mut unavailable = false;
60
61 if !json.is_empty() {
62 if which("node").is_some() {
63 for f in &json {
64 let script = r#"JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))"#;
65 // `--` before `f`: a staged file named e.g. `-e.json` would
66 // otherwise be read as another `-e` by node's own parser.
67 if !parses(&root, "node", &["-e", script, "--", f]) {
68 fail(&format!("Invalid JSON: {}", hl(f)));
69 failed = true;
70 }
71 }
72 } else {
73 warn(&format!(
74 "JSON files detected. To lint them, install {}",
75 hl("node")
76 ));
77 unavailable = true;
78 }
79 }
80
81 if !yaml.is_empty() {
82 if which("yq").is_some() {
83 for f in &yaml {
84 if is_helm_template(&root, f) {
85 continue;
86 }
87 // `--` before `f`: a staged file starting with `-` would
88 // otherwise be read as a flag by yq's own parser.
89 if !parses(&root, "yq", &["e", "true", "--", f]) {
90 fail(&format!("Invalid YAML: {}", hl(f)));
91 failed = true;
92 }
93 }
94 } else {
95 warn(&format!(
96 "YAML files detected. To lint them, install {}",
97 hl("yq")
98 ));
99 unavailable = true;
100 }
101 }
102
103 match (failed, unavailable) {
104 (true, _) => Outcome::Failed,
105 (false, true) => Outcome::Unavailable,
106 (false, false) => {
107 ok("Json/Yaml Lint passed");
108 Outcome::Passed
109 }
110 }
111}