Skip to main content

alint_rules/
structured_path.rs

1//! Structured-query rule family: `{json,yaml,toml}_path_{equals,matches}`.
2//!
3//! Six rule kinds share a single implementation that varies
4//! along two axes:
5//!
6//! - **Format** — `Json`, `Yaml`, or `Toml`. The file is parsed
7//!   into a `serde_json::Value` tree regardless (YAML and TOML
8//!   values coerce through serde), so the `JSONPath` engine only
9//!   has to reason about one tree shape.
10//! - **Op** — `Equals(value)` for exact equality or
11//!   `Matches(regex)` for regex on string values.
12//!
13//! All rule kinds require:
14//!
15//! - `paths` — which files to scan.
16//! - `path` — a `JSONPath` expression (RFC 9535) pointing at the
17//!   values to check.
18//! - Either `equals` (arbitrary YAML value) or `matches`
19//!   (regex string), according to the rule kind.
20//!
21//! ## Semantics
22//!
23//! `JSONPath` can return multiple matches (`$.deps[*].version`).
24//! Every match must satisfy the op; any single mismatch
25//! produces a violation at that match's location. If the query
26//! returns zero matches, that's one "path not found" violation
27//! — the option the user is enforcing doesn't exist.
28//!
29//! The optional **`if_present: true`** flag flips the zero-match
30//! case: under it, zero matches are silently OK, and only
31//! actual matches that fail the op produce violations. Useful
32//! for predicates that only apply when a field is present —
33//! e.g. "every `uses:` in a GitHub Actions workflow must be
34//! pinned to a commit SHA" (a workflow with only `run:` steps
35//! has no `uses:` at all and shouldn't be flagged).
36//!
37//! Unparseable files (bad JSON / YAML / TOML) produce one
38//! violation per file. An unparseable file is a documentation
39//! problem, not the structured rule's concern — but better to
40//! surface it than silently skip.
41
42use std::path::{Path, PathBuf};
43
44use alint_core::{
45    Context, Error, Level, PathsSpec, PerFileRule, Result, Rule, RuleSpec, Scope, Violation,
46};
47use regex::Regex;
48use serde::Deserialize;
49use serde_json::Value;
50use serde_json_path::JsonPath;
51
52/// True when `pattern` is a plain relative-path literal — no
53/// glob metacharacters, no `!` exclude prefix. Mirrors
54/// `file_exists::is_literal_path`; kept local to dodge a
55/// crate-wide pub-helper module just for two rules.
56fn is_literal_path(pattern: &str) -> bool {
57    !pattern.starts_with('!')
58        && !pattern
59            .chars()
60            .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
61}
62
63/// Collect every literal pattern from `spec` IFF every entry is
64/// a literal AND the spec carries no excludes. Returns `None`
65/// when any pattern is a glob or there are excludes — the slow
66/// path is still correct in those cases.
67fn extract_literal_paths(spec: &PathsSpec) -> Option<Vec<PathBuf>> {
68    let patterns: Vec<&str> = match spec {
69        PathsSpec::Single(s) => vec![s.as_str()],
70        PathsSpec::Many(v) => v.iter().map(String::as_str).collect(),
71        PathsSpec::IncludeExclude { include, exclude } if exclude.is_empty() => {
72            include.iter().map(String::as_str).collect()
73        }
74        PathsSpec::IncludeExclude { .. } => return None,
75    };
76    if patterns.iter().all(|p| is_literal_path(p)) {
77        Some(patterns.iter().map(PathBuf::from).collect())
78    } else {
79        None
80    }
81}
82
83/// Which YAML-flavoured parser to use on the target file.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Format {
86    Json,
87    Yaml,
88    Toml,
89}
90
91impl Format {
92    pub(crate) fn parse(self, text: &str) -> std::result::Result<Value, String> {
93        match self {
94            Self::Json => serde_json::from_str(text).map_err(|e| e.to_string()),
95            Self::Yaml => serde_yaml_ng::from_str(text).map_err(|e| e.to_string()),
96            Self::Toml => toml::from_str(text).map_err(|e| e.to_string()),
97        }
98    }
99
100    pub(crate) fn label(self) -> &'static str {
101        match self {
102            Self::Json => "JSON",
103            Self::Yaml => "YAML",
104            Self::Toml => "TOML",
105        }
106    }
107
108    /// Detect the format from a path's extension. Returns `None`
109    /// for unknown extensions; callers decide how to fall back
110    /// (require an explicit `format:` override, default to JSON,
111    /// emit a per-file violation, etc).
112    pub(crate) fn detect_from_path(path: &std::path::Path) -> Option<Self> {
113        match path.extension()?.to_str()? {
114            "json" => Some(Self::Json),
115            "yaml" | "yml" => Some(Self::Yaml),
116            "toml" => Some(Self::Toml),
117            _ => None,
118        }
119    }
120}
121
122/// Comparison op — keeps the rule builders thin.
123#[derive(Debug)]
124pub enum Op {
125    /// Value at `path` must serialize-compare equal to this
126    /// literal. Any JSON-representable value works (bool,
127    /// number, string, array, object, null).
128    Equals(Value),
129    /// Value at `path` must be a string that the regex matches.
130    /// A non-string match produces a violation with a clear
131    /// `expected string, got <kind>` message.
132    Matches(Regex),
133}
134
135// ---------------------------------------------------------------
136// Options — deserialized from the rule spec's `extra` map.
137// ---------------------------------------------------------------
138
139/// Options shared by every `*_path_equals` rule kind.
140#[derive(Debug, Deserialize)]
141#[serde(deny_unknown_fields)]
142struct EqualsOptions {
143    path: String,
144    equals: Value,
145    #[serde(default)]
146    if_present: bool,
147}
148
149/// Options shared by every `*_path_matches` rule kind.
150#[derive(Debug, Deserialize)]
151#[serde(deny_unknown_fields)]
152struct MatchesOptions {
153    path: String,
154    matches: String,
155    #[serde(default)]
156    if_present: bool,
157}
158
159// ---------------------------------------------------------------
160// Rule
161// ---------------------------------------------------------------
162
163#[derive(Debug)]
164pub struct StructuredPathRule {
165    id: String,
166    level: Level,
167    policy_url: Option<String>,
168    message: Option<String>,
169    scope: Scope,
170    /// `Some(paths)` when every `paths:` entry is a plain
171    /// literal (no glob metacharacters, no `!` excludes). The
172    /// fast path uses these to short-circuit through the
173    /// index's hash-set and skip the O(N) `scope.matches`
174    /// scan — same shape as `file_exists`'s fast path. Driven
175    /// by the bundled `monorepo/cargo-workspace@v1`'s
176    /// `cargo-workspace-member-declares-name` rule, which
177    /// `for_each_dir` instantiates with `paths:
178    /// "{path}/Cargo.toml"` (purely literal after token
179    /// substitution) for every `crates/*` directory; without
180    /// the fast path this is the dominant 1M-scale bottleneck.
181    literal_paths: Option<Vec<PathBuf>>,
182    format: Format,
183    path_expr: JsonPath,
184    path_src: String,
185    op: Op,
186    /// When `true`, a `JSONPath` query that produces zero matches
187    /// is silently OK. When `false` (default), a zero-match query
188    /// is reported as a single violation — the "value being
189    /// enforced doesn't exist" case. Use `true` for predicates
190    /// that are conditional on the field being present (e.g.
191    /// "every `uses:` in a workflow must be SHA-pinned" — a
192    /// workflow with no `uses:` at all shouldn't be flagged).
193    if_present: bool,
194}
195
196impl Rule for StructuredPathRule {
197    fn id(&self) -> &str {
198        &self.id
199    }
200    fn level(&self) -> Level {
201        self.level
202    }
203    fn policy_url(&self) -> Option<&str> {
204        self.policy_url.as_deref()
205    }
206
207    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> {
208        let mut violations = Vec::new();
209        if let Some(literals) = self.literal_paths.as_ref() {
210            // Fast path: each `paths:` entry is a literal
211            // relative path; we don't need to touch the entry
212            // list at all. `contains_file` is the cheap
213            // membership check; the absolute path comes from
214            // joining `root` with the literal directly.
215            // (`find_file` would re-scan the entries list to
216            // hand back a `&FileEntry`, which we don't need
217            // here — only the bytes — and which would
218            // re-introduce the O(N) work this fast path
219            // exists to avoid.)
220            for literal in literals {
221                if !ctx.index.contains_file(literal) {
222                    continue;
223                }
224                let full = ctx.root.join(literal);
225                let Ok(bytes) = std::fs::read(&full) else {
226                    continue;
227                };
228                violations.extend(self.evaluate_file(ctx, literal, &bytes)?);
229            }
230        } else {
231            for entry in ctx.index.files() {
232                if !self.scope.matches(&entry.path, ctx.index) {
233                    continue;
234                }
235                let full = ctx.root.join(&entry.path);
236                let Ok(bytes) = std::fs::read(&full) else {
237                    // permission / race — silent skip, like other
238                    // content rules
239                    continue;
240                };
241                violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
242            }
243        }
244        Ok(violations)
245    }
246
247    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
248        Some(self)
249    }
250}
251
252impl PerFileRule for StructuredPathRule {
253    fn path_scope(&self) -> &Scope {
254        &self.scope
255    }
256
257    fn evaluate_file(
258        &self,
259        _ctx: &Context<'_>,
260        path: &Path,
261        bytes: &[u8],
262    ) -> Result<Vec<Violation>> {
263        let Ok(text) = std::str::from_utf8(bytes) else {
264            return Ok(Vec::new());
265        };
266        let root_value = match self.format.parse(text) {
267            Ok(v) => v,
268            Err(err) => {
269                return Ok(vec![
270                    Violation::new(format!(
271                        "not a valid {} document: {err}",
272                        self.format.label()
273                    ))
274                    .with_path(std::sync::Arc::<Path>::from(path)),
275                ]);
276            }
277        };
278        let matches = self.path_expr.query(&root_value);
279        if matches.is_empty() {
280            if self.if_present {
281                return Ok(Vec::new());
282            }
283            let msg = self
284                .message
285                .clone()
286                .unwrap_or_else(|| format!("JSONPath `{}` produced no match", self.path_src));
287            return Ok(vec![
288                Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
289            ]);
290        }
291        let mut violations = Vec::new();
292        for m in matches.iter() {
293            if let Some(v) = check_match(m, &self.op) {
294                let base = self.message.clone().unwrap_or(v);
295                violations.push(Violation::new(base).with_path(std::sync::Arc::<Path>::from(path)));
296            }
297        }
298        Ok(violations)
299    }
300}
301
302/// Return `Some(message)` if the match fails the op; `None` if it passes.
303fn check_match(m: &Value, op: &Op) -> Option<String> {
304    match op {
305        Op::Equals(expected) => {
306            if m == expected {
307                None
308            } else {
309                Some(format!(
310                    "value at path does not equal expected: expected {}, got {}",
311                    short_render(expected),
312                    short_render(m),
313                ))
314            }
315        }
316        Op::Matches(re) => {
317            let Some(s) = m.as_str() else {
318                return Some(format!(
319                    "value at path is not a string (got {}), can't apply regex",
320                    kind_name(m)
321                ));
322            };
323            if re.is_match(s) {
324                None
325            } else {
326                Some(format!(
327                    "value at path {} does not match regex {}",
328                    short_render(m),
329                    re.as_str(),
330                ))
331            }
332        }
333    }
334}
335
336/// A stable, short rendering for error messages. Avoids
337/// dumping a whole object when the mismatch is on a sub-key.
338fn short_render(v: &Value) -> String {
339    let raw = v.to_string();
340    if raw.len() <= 80 {
341        raw
342    } else {
343        format!("{}…", &raw[..80])
344    }
345}
346
347fn kind_name(v: &Value) -> &'static str {
348    match v {
349        Value::Null => "null",
350        Value::Bool(_) => "bool",
351        Value::Number(_) => "number",
352        Value::String(_) => "string",
353        Value::Array(_) => "array",
354        Value::Object(_) => "object",
355    }
356}
357
358// ---------------------------------------------------------------
359// Builders
360//
361// Six thin wrappers per (Format, Op) combination. Each consumes
362// the spec, validates the structured-query options, and
363// constructs the shared `StructuredPathRule`.
364// ---------------------------------------------------------------
365
366pub fn json_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
367    build_equals(spec, Format::Json, "json_path_equals")
368}
369
370pub fn json_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
371    build_matches(spec, Format::Json, "json_path_matches")
372}
373
374pub fn yaml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
375    build_equals(spec, Format::Yaml, "yaml_path_equals")
376}
377
378pub fn yaml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
379    build_matches(spec, Format::Yaml, "yaml_path_matches")
380}
381
382pub fn toml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
383    build_equals(spec, Format::Toml, "toml_path_equals")
384}
385
386pub fn toml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
387    build_matches(spec, Format::Toml, "toml_path_matches")
388}
389
390fn build_equals(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
391    let paths = spec.paths.as_ref().ok_or_else(|| {
392        Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
393    })?;
394    let opts: EqualsOptions = spec
395        .deserialize_options()
396        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
397    let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
398        Error::rule_config(
399            &spec.id,
400            alint_core::jsonpath_diagnostics::format_parse_error(&opts.path, e),
401        )
402    })?;
403    Ok(Box::new(StructuredPathRule {
404        id: spec.id.clone(),
405        level: spec.level,
406        policy_url: spec.policy_url.clone(),
407        message: spec.message.clone(),
408        scope: Scope::from_spec(spec)?,
409        literal_paths: extract_literal_paths(paths),
410        format,
411        path_expr,
412        path_src: opts.path,
413        op: Op::Equals(opts.equals),
414        if_present: opts.if_present,
415    }))
416}
417
418fn build_matches(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
419    let paths = spec.paths.as_ref().ok_or_else(|| {
420        Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
421    })?;
422    let opts: MatchesOptions = spec
423        .deserialize_options()
424        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
425    let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
426        Error::rule_config(
427            &spec.id,
428            alint_core::jsonpath_diagnostics::format_parse_error(&opts.path, e),
429        )
430    })?;
431    let re = Regex::new(&opts.matches).map_err(|e| {
432        Error::rule_config(&spec.id, format!("invalid regex {:?}: {e}", opts.matches))
433    })?;
434    Ok(Box::new(StructuredPathRule {
435        id: spec.id.clone(),
436        level: spec.level,
437        policy_url: spec.policy_url.clone(),
438        message: spec.message.clone(),
439        scope: Scope::from_spec(spec)?,
440        literal_paths: extract_literal_paths(paths),
441        format,
442        path_expr,
443        path_src: opts.path,
444        op: Op::Matches(re),
445        if_present: opts.if_present,
446    }))
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
453
454    // ─── build-path errors ────────────────────────────────────
455
456    #[test]
457    fn build_rejects_missing_paths() {
458        let spec = spec_yaml(
459            "id: t\n\
460             kind: json_path_equals\n\
461             path: \"$.name\"\n\
462             equals: \"x\"\n\
463             level: error\n",
464        );
465        assert!(json_path_equals_build(&spec).is_err());
466    }
467
468    #[test]
469    fn build_rejects_invalid_jsonpath() {
470        let spec = spec_yaml(
471            "id: t\n\
472             kind: json_path_equals\n\
473             paths: \"package.json\"\n\
474             path: \"$..[invalid\"\n\
475             equals: \"x\"\n\
476             level: error\n",
477        );
478        assert!(json_path_equals_build(&spec).is_err());
479    }
480
481    #[test]
482    fn build_rejects_invalid_regex_in_matches() {
483        let spec = spec_yaml(
484            "id: t\n\
485             kind: json_path_matches\n\
486             paths: \"package.json\"\n\
487             path: \"$.version\"\n\
488             pattern: \"[unterminated\"\n\
489             level: error\n",
490        );
491        assert!(json_path_matches_build(&spec).is_err());
492    }
493
494    // ─── json_path_equals ─────────────────────────────────────
495
496    #[test]
497    fn json_path_equals_passes_when_value_matches() {
498        let spec = spec_yaml(
499            "id: t\n\
500             kind: json_path_equals\n\
501             paths: \"package.json\"\n\
502             path: \"$.name\"\n\
503             equals: \"demo\"\n\
504             level: error\n",
505        );
506        let rule = json_path_equals_build(&spec).unwrap();
507        let (tmp, idx) =
508            tempdir_with_files(&[("package.json", br#"{"name":"demo","version":"1.0.0"}"#)]);
509        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
510        assert!(v.is_empty(), "matching value should pass: {v:?}");
511    }
512
513    #[test]
514    fn json_path_equals_fires_on_mismatch() {
515        let spec = spec_yaml(
516            "id: t\n\
517             kind: json_path_equals\n\
518             paths: \"package.json\"\n\
519             path: \"$.name\"\n\
520             equals: \"demo\"\n\
521             level: error\n",
522        );
523        let rule = json_path_equals_build(&spec).unwrap();
524        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"name":"other"}"#)]);
525        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
526        assert_eq!(v.len(), 1);
527    }
528
529    #[test]
530    fn json_path_equals_fires_on_missing_path() {
531        let spec = spec_yaml(
532            "id: t\n\
533             kind: json_path_equals\n\
534             paths: \"package.json\"\n\
535             path: \"$.name\"\n\
536             equals: \"demo\"\n\
537             level: error\n",
538        );
539        let rule = json_path_equals_build(&spec).unwrap();
540        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
541        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
542        assert_eq!(v.len(), 1, "missing path should fire");
543    }
544
545    #[test]
546    fn json_path_if_present_silent_on_missing() {
547        // `if_present: true` → missing path is silent.
548        let spec = spec_yaml(
549            "id: t\n\
550             kind: json_path_equals\n\
551             paths: \"package.json\"\n\
552             path: \"$.name\"\n\
553             equals: \"demo\"\n\
554             if_present: true\n\
555             level: error\n",
556        );
557        let rule = json_path_equals_build(&spec).unwrap();
558        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
559        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
560        assert!(v.is_empty(), "if_present should silence: {v:?}");
561    }
562
563    // ─── json_path_matches ────────────────────────────────────
564
565    #[test]
566    fn json_path_matches_passes_on_pattern_hit() {
567        let spec = spec_yaml(
568            "id: t\n\
569             kind: json_path_matches\n\
570             paths: \"package.json\"\n\
571             path: \"$.version\"\n\
572             matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
573             level: error\n",
574        );
575        let rule = json_path_matches_build(&spec).unwrap();
576        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.2.3"}"#)]);
577        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
578        assert!(v.is_empty(), "matching version should pass: {v:?}");
579    }
580
581    #[test]
582    fn json_path_matches_fires_on_pattern_miss() {
583        let spec = spec_yaml(
584            "id: t\n\
585             kind: json_path_matches\n\
586             paths: \"package.json\"\n\
587             path: \"$.version\"\n\
588             matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
589             level: error\n",
590        );
591        let rule = json_path_matches_build(&spec).unwrap();
592        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"v1.x"}"#)]);
593        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
594        assert_eq!(v.len(), 1);
595    }
596
597    // ─── yaml_path_* ─────────────────────────────────────────
598
599    #[test]
600    fn yaml_path_equals_passes_when_value_matches() {
601        let spec = spec_yaml(
602            "id: t\n\
603             kind: yaml_path_equals\n\
604             paths: \".github/workflows/*.yml\"\n\
605             path: \"$.name\"\n\
606             equals: \"CI\"\n\
607             level: error\n",
608        );
609        let rule = yaml_path_equals_build(&spec).unwrap();
610        let (tmp, idx) = tempdir_with_files(&[(
611            ".github/workflows/ci.yml",
612            b"name: CI\non: push\njobs: {}\n",
613        )]);
614        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
615        assert!(v.is_empty(), "matching name should pass: {v:?}");
616    }
617
618    #[test]
619    fn yaml_path_matches_uses_bracket_notation_for_dashed_keys() {
620        // Per the memory note: dashed YAML keys need bracket
621        // notation (`$.foo['dashed-key']`) because the JSONPath
622        // dot-form can't parse them.
623        let spec = spec_yaml(
624            "id: t\n\
625             kind: yaml_path_matches\n\
626             paths: \"action.yml\"\n\
627             path: \"$.runs['using']\"\n\
628             matches: \"^node\\\\d+$\"\n\
629             level: error\n",
630        );
631        let rule = yaml_path_matches_build(&spec).unwrap();
632        let (tmp, idx) =
633            tempdir_with_files(&[("action.yml", b"runs:\n  using: node20\n  main: index.js\n")]);
634        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
635        assert!(v.is_empty(), "bracket notation should match: {v:?}");
636    }
637
638    // ─── toml_path_* ─────────────────────────────────────────
639
640    #[test]
641    fn toml_path_equals_passes_when_value_matches() {
642        let spec = spec_yaml(
643            "id: t\n\
644             kind: toml_path_equals\n\
645             paths: \"Cargo.toml\"\n\
646             path: \"$.package.edition\"\n\
647             equals: \"2024\"\n\
648             level: error\n",
649        );
650        let rule = toml_path_equals_build(&spec).unwrap();
651        let (tmp, idx) = tempdir_with_files(&[(
652            "Cargo.toml",
653            b"[package]\nname = \"x\"\nedition = \"2024\"\n",
654        )]);
655        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
656        assert!(v.is_empty(), "matching edition should pass: {v:?}");
657    }
658
659    #[test]
660    fn toml_path_matches_fires_on_floating_version() {
661        // Common policy: deps must be tilde-pinned, not bare.
662        let spec = spec_yaml(
663            "id: t\n\
664             kind: toml_path_matches\n\
665             paths: \"Cargo.toml\"\n\
666             path: \"$.dependencies.serde\"\n\
667             matches: \"^[~=]\"\n\
668             level: error\n",
669        );
670        let rule = toml_path_matches_build(&spec).unwrap();
671        let (tmp, idx) = tempdir_with_files(&[(
672            "Cargo.toml",
673            b"[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n",
674        )]);
675        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
676        assert_eq!(v.len(), 1, "floating `serde = \"1\"` should fire");
677    }
678
679    // ─── parse error path ─────────────────────────────────────
680
681    #[test]
682    fn evaluate_fires_on_malformed_input() {
683        let spec = spec_yaml(
684            "id: t\n\
685             kind: json_path_equals\n\
686             paths: \"package.json\"\n\
687             path: \"$.name\"\n\
688             equals: \"x\"\n\
689             level: error\n",
690        );
691        let rule = json_path_equals_build(&spec).unwrap();
692        let (tmp, idx) = tempdir_with_files(&[("package.json", b"{not valid json")]);
693        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
694        assert_eq!(v.len(), 1, "malformed JSON should fire one violation");
695    }
696}