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, ScopeFilter,
46    Violation,
47};
48use regex::Regex;
49use serde::Deserialize;
50use serde_json::Value;
51use serde_json_path::JsonPath;
52
53/// True when `pattern` is a plain relative-path literal — no
54/// glob metacharacters, no `!` exclude prefix. Mirrors
55/// `file_exists::is_literal_path`; kept local to dodge a
56/// crate-wide pub-helper module just for two rules.
57fn is_literal_path(pattern: &str) -> bool {
58    !pattern.starts_with('!')
59        && !pattern
60            .chars()
61            .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}'))
62}
63
64/// Collect every literal pattern from `spec` IFF every entry is
65/// a literal AND the spec carries no excludes. Returns `None`
66/// when any pattern is a glob or there are excludes — the slow
67/// path is still correct in those cases.
68fn extract_literal_paths(spec: &PathsSpec) -> Option<Vec<PathBuf>> {
69    let patterns: Vec<&str> = match spec {
70        PathsSpec::Single(s) => vec![s.as_str()],
71        PathsSpec::Many(v) => v.iter().map(String::as_str).collect(),
72        PathsSpec::IncludeExclude { include, exclude } if exclude.is_empty() => {
73            include.iter().map(String::as_str).collect()
74        }
75        PathsSpec::IncludeExclude { .. } => return None,
76    };
77    if patterns.iter().all(|p| is_literal_path(p)) {
78        Some(patterns.iter().map(PathBuf::from).collect())
79    } else {
80        None
81    }
82}
83
84/// Which YAML-flavoured parser to use on the target file.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Format {
87    Json,
88    Yaml,
89    Toml,
90}
91
92impl Format {
93    pub(crate) fn parse(self, text: &str) -> std::result::Result<Value, String> {
94        match self {
95            Self::Json => serde_json::from_str(text).map_err(|e| e.to_string()),
96            Self::Yaml => serde_yaml_ng::from_str(text).map_err(|e| e.to_string()),
97            Self::Toml => toml::from_str(text).map_err(|e| e.to_string()),
98        }
99    }
100
101    pub(crate) fn label(self) -> &'static str {
102        match self {
103            Self::Json => "JSON",
104            Self::Yaml => "YAML",
105            Self::Toml => "TOML",
106        }
107    }
108
109    /// Detect the format from a path's extension. Returns `None`
110    /// for unknown extensions; callers decide how to fall back
111    /// (require an explicit `format:` override, default to JSON,
112    /// emit a per-file violation, etc).
113    pub(crate) fn detect_from_path(path: &std::path::Path) -> Option<Self> {
114        match path.extension()?.to_str()? {
115            "json" => Some(Self::Json),
116            "yaml" | "yml" => Some(Self::Yaml),
117            "toml" => Some(Self::Toml),
118            _ => None,
119        }
120    }
121}
122
123/// Comparison op — keeps the rule builders thin.
124#[derive(Debug)]
125pub enum Op {
126    /// Value at `path` must serialize-compare equal to this
127    /// literal. Any JSON-representable value works (bool,
128    /// number, string, array, object, null).
129    Equals(Value),
130    /// Value at `path` must be a string that the regex matches.
131    /// A non-string match produces a violation with a clear
132    /// `expected string, got <kind>` message.
133    Matches(Regex),
134}
135
136// ---------------------------------------------------------------
137// Options — deserialized from the rule spec's `extra` map.
138// ---------------------------------------------------------------
139
140/// Options shared by every `*_path_equals` rule kind.
141#[derive(Debug, Deserialize)]
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)]
151struct MatchesOptions {
152    path: String,
153    matches: String,
154    #[serde(default)]
155    if_present: bool,
156}
157
158// ---------------------------------------------------------------
159// Rule
160// ---------------------------------------------------------------
161
162#[derive(Debug)]
163pub struct StructuredPathRule {
164    id: String,
165    level: Level,
166    policy_url: Option<String>,
167    message: Option<String>,
168    scope: Scope,
169    scope_filter: Option<ScopeFilter>,
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                if let Some(filter) = &self.scope_filter
225                    && !filter.matches(literal, ctx.index)
226                {
227                    continue;
228                }
229                let full = ctx.root.join(literal);
230                let Ok(bytes) = std::fs::read(&full) else {
231                    continue;
232                };
233                violations.extend(self.evaluate_file(ctx, literal, &bytes)?);
234            }
235        } else {
236            for entry in ctx.index.files() {
237                if !self.scope.matches(&entry.path) {
238                    continue;
239                }
240                if let Some(filter) = &self.scope_filter
241                    && !filter.matches(&entry.path, ctx.index)
242                {
243                    continue;
244                }
245                let full = ctx.root.join(&entry.path);
246                let Ok(bytes) = std::fs::read(&full) else {
247                    // permission / race — silent skip, like other
248                    // content rules
249                    continue;
250                };
251                violations.extend(self.evaluate_file(ctx, &entry.path, &bytes)?);
252            }
253        }
254        Ok(violations)
255    }
256
257    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
258        Some(self)
259    }
260
261    fn scope_filter(&self) -> Option<&ScopeFilter> {
262        self.scope_filter.as_ref()
263    }
264}
265
266impl PerFileRule for StructuredPathRule {
267    fn path_scope(&self) -> &Scope {
268        &self.scope
269    }
270
271    fn evaluate_file(
272        &self,
273        _ctx: &Context<'_>,
274        path: &Path,
275        bytes: &[u8],
276    ) -> Result<Vec<Violation>> {
277        let Ok(text) = std::str::from_utf8(bytes) else {
278            return Ok(Vec::new());
279        };
280        let root_value = match self.format.parse(text) {
281            Ok(v) => v,
282            Err(err) => {
283                return Ok(vec![
284                    Violation::new(format!(
285                        "not a valid {} document: {err}",
286                        self.format.label()
287                    ))
288                    .with_path(std::sync::Arc::<Path>::from(path)),
289                ]);
290            }
291        };
292        let matches = self.path_expr.query(&root_value);
293        if matches.is_empty() {
294            if self.if_present {
295                return Ok(Vec::new());
296            }
297            let msg = self
298                .message
299                .clone()
300                .unwrap_or_else(|| format!("JSONPath `{}` produced no match", self.path_src));
301            return Ok(vec![
302                Violation::new(msg).with_path(std::sync::Arc::<Path>::from(path)),
303            ]);
304        }
305        let mut violations = Vec::new();
306        for m in matches.iter() {
307            if let Some(v) = check_match(m, &self.op) {
308                let base = self.message.clone().unwrap_or(v);
309                violations.push(Violation::new(base).with_path(std::sync::Arc::<Path>::from(path)));
310            }
311        }
312        Ok(violations)
313    }
314}
315
316/// Return `Some(message)` if the match fails the op; `None` if it passes.
317fn check_match(m: &Value, op: &Op) -> Option<String> {
318    match op {
319        Op::Equals(expected) => {
320            if m == expected {
321                None
322            } else {
323                Some(format!(
324                    "value at path does not equal expected: expected {}, got {}",
325                    short_render(expected),
326                    short_render(m),
327                ))
328            }
329        }
330        Op::Matches(re) => {
331            let Some(s) = m.as_str() else {
332                return Some(format!(
333                    "value at path is not a string (got {}), can't apply regex",
334                    kind_name(m)
335                ));
336            };
337            if re.is_match(s) {
338                None
339            } else {
340                Some(format!(
341                    "value at path {} does not match regex {}",
342                    short_render(m),
343                    re.as_str(),
344                ))
345            }
346        }
347    }
348}
349
350/// A stable, short rendering for error messages. Avoids
351/// dumping a whole object when the mismatch is on a sub-key.
352fn short_render(v: &Value) -> String {
353    let raw = v.to_string();
354    if raw.len() <= 80 {
355        raw
356    } else {
357        format!("{}…", &raw[..80])
358    }
359}
360
361fn kind_name(v: &Value) -> &'static str {
362    match v {
363        Value::Null => "null",
364        Value::Bool(_) => "bool",
365        Value::Number(_) => "number",
366        Value::String(_) => "string",
367        Value::Array(_) => "array",
368        Value::Object(_) => "object",
369    }
370}
371
372// ---------------------------------------------------------------
373// Builders
374//
375// Six thin wrappers per (Format, Op) combination. Each consumes
376// the spec, validates the structured-query options, and
377// constructs the shared `StructuredPathRule`.
378// ---------------------------------------------------------------
379
380pub fn json_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
381    build_equals(spec, Format::Json, "json_path_equals")
382}
383
384pub fn json_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
385    build_matches(spec, Format::Json, "json_path_matches")
386}
387
388pub fn yaml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
389    build_equals(spec, Format::Yaml, "yaml_path_equals")
390}
391
392pub fn yaml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
393    build_matches(spec, Format::Yaml, "yaml_path_matches")
394}
395
396pub fn toml_path_equals_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
397    build_equals(spec, Format::Toml, "toml_path_equals")
398}
399
400pub fn toml_path_matches_build(spec: &RuleSpec) -> Result<Box<dyn Rule>> {
401    build_matches(spec, Format::Toml, "toml_path_matches")
402}
403
404fn build_equals(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
405    let paths = spec.paths.as_ref().ok_or_else(|| {
406        Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
407    })?;
408    let opts: EqualsOptions = spec
409        .deserialize_options()
410        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
411    let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
412        Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
413    })?;
414    Ok(Box::new(StructuredPathRule {
415        id: spec.id.clone(),
416        level: spec.level,
417        policy_url: spec.policy_url.clone(),
418        message: spec.message.clone(),
419        scope: Scope::from_paths_spec(paths)?,
420        scope_filter: spec.parse_scope_filter()?,
421        literal_paths: extract_literal_paths(paths),
422        format,
423        path_expr,
424        path_src: opts.path,
425        op: Op::Equals(opts.equals),
426        if_present: opts.if_present,
427    }))
428}
429
430fn build_matches(spec: &RuleSpec, format: Format, kind_label: &str) -> Result<Box<dyn Rule>> {
431    let paths = spec.paths.as_ref().ok_or_else(|| {
432        Error::rule_config(&spec.id, format!("{kind_label} requires a `paths` field"))
433    })?;
434    let opts: MatchesOptions = spec
435        .deserialize_options()
436        .map_err(|e| Error::rule_config(&spec.id, format!("invalid options: {e}")))?;
437    let path_expr = JsonPath::parse(&opts.path).map_err(|e| {
438        Error::rule_config(&spec.id, format!("invalid JSONPath {:?}: {e}", opts.path))
439    })?;
440    let re = Regex::new(&opts.matches).map_err(|e| {
441        Error::rule_config(&spec.id, format!("invalid regex {:?}: {e}", opts.matches))
442    })?;
443    Ok(Box::new(StructuredPathRule {
444        id: spec.id.clone(),
445        level: spec.level,
446        policy_url: spec.policy_url.clone(),
447        message: spec.message.clone(),
448        scope: Scope::from_paths_spec(paths)?,
449        scope_filter: spec.parse_scope_filter()?,
450        literal_paths: extract_literal_paths(paths),
451        format,
452        path_expr,
453        path_src: opts.path,
454        op: Op::Matches(re),
455        if_present: opts.if_present,
456    }))
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::test_support::{ctx, spec_yaml, tempdir_with_files};
463
464    // ─── build-path errors ────────────────────────────────────
465
466    #[test]
467    fn build_rejects_missing_paths() {
468        let spec = spec_yaml(
469            "id: t\n\
470             kind: json_path_equals\n\
471             path: \"$.name\"\n\
472             equals: \"x\"\n\
473             level: error\n",
474        );
475        assert!(json_path_equals_build(&spec).is_err());
476    }
477
478    #[test]
479    fn build_rejects_invalid_jsonpath() {
480        let spec = spec_yaml(
481            "id: t\n\
482             kind: json_path_equals\n\
483             paths: \"package.json\"\n\
484             path: \"$..[invalid\"\n\
485             equals: \"x\"\n\
486             level: error\n",
487        );
488        assert!(json_path_equals_build(&spec).is_err());
489    }
490
491    #[test]
492    fn build_rejects_invalid_regex_in_matches() {
493        let spec = spec_yaml(
494            "id: t\n\
495             kind: json_path_matches\n\
496             paths: \"package.json\"\n\
497             path: \"$.version\"\n\
498             pattern: \"[unterminated\"\n\
499             level: error\n",
500        );
501        assert!(json_path_matches_build(&spec).is_err());
502    }
503
504    // ─── json_path_equals ─────────────────────────────────────
505
506    #[test]
507    fn json_path_equals_passes_when_value_matches() {
508        let spec = spec_yaml(
509            "id: t\n\
510             kind: json_path_equals\n\
511             paths: \"package.json\"\n\
512             path: \"$.name\"\n\
513             equals: \"demo\"\n\
514             level: error\n",
515        );
516        let rule = json_path_equals_build(&spec).unwrap();
517        let (tmp, idx) =
518            tempdir_with_files(&[("package.json", br#"{"name":"demo","version":"1.0.0"}"#)]);
519        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
520        assert!(v.is_empty(), "matching value should pass: {v:?}");
521    }
522
523    #[test]
524    fn json_path_equals_fires_on_mismatch() {
525        let spec = spec_yaml(
526            "id: t\n\
527             kind: json_path_equals\n\
528             paths: \"package.json\"\n\
529             path: \"$.name\"\n\
530             equals: \"demo\"\n\
531             level: error\n",
532        );
533        let rule = json_path_equals_build(&spec).unwrap();
534        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"name":"other"}"#)]);
535        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
536        assert_eq!(v.len(), 1);
537    }
538
539    #[test]
540    fn json_path_equals_fires_on_missing_path() {
541        let spec = spec_yaml(
542            "id: t\n\
543             kind: json_path_equals\n\
544             paths: \"package.json\"\n\
545             path: \"$.name\"\n\
546             equals: \"demo\"\n\
547             level: error\n",
548        );
549        let rule = json_path_equals_build(&spec).unwrap();
550        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
551        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
552        assert_eq!(v.len(), 1, "missing path should fire");
553    }
554
555    #[test]
556    fn json_path_if_present_silent_on_missing() {
557        // `if_present: true` → missing path is silent.
558        let spec = spec_yaml(
559            "id: t\n\
560             kind: json_path_equals\n\
561             paths: \"package.json\"\n\
562             path: \"$.name\"\n\
563             equals: \"demo\"\n\
564             if_present: true\n\
565             level: error\n",
566        );
567        let rule = json_path_equals_build(&spec).unwrap();
568        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.0"}"#)]);
569        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
570        assert!(v.is_empty(), "if_present should silence: {v:?}");
571    }
572
573    // ─── json_path_matches ────────────────────────────────────
574
575    #[test]
576    fn json_path_matches_passes_on_pattern_hit() {
577        let spec = spec_yaml(
578            "id: t\n\
579             kind: json_path_matches\n\
580             paths: \"package.json\"\n\
581             path: \"$.version\"\n\
582             matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
583             level: error\n",
584        );
585        let rule = json_path_matches_build(&spec).unwrap();
586        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"1.2.3"}"#)]);
587        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
588        assert!(v.is_empty(), "matching version should pass: {v:?}");
589    }
590
591    #[test]
592    fn json_path_matches_fires_on_pattern_miss() {
593        let spec = spec_yaml(
594            "id: t\n\
595             kind: json_path_matches\n\
596             paths: \"package.json\"\n\
597             path: \"$.version\"\n\
598             matches: \"^\\\\d+\\\\.\\\\d+\\\\.\\\\d+$\"\n\
599             level: error\n",
600        );
601        let rule = json_path_matches_build(&spec).unwrap();
602        let (tmp, idx) = tempdir_with_files(&[("package.json", br#"{"version":"v1.x"}"#)]);
603        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
604        assert_eq!(v.len(), 1);
605    }
606
607    // ─── yaml_path_* ─────────────────────────────────────────
608
609    #[test]
610    fn yaml_path_equals_passes_when_value_matches() {
611        let spec = spec_yaml(
612            "id: t\n\
613             kind: yaml_path_equals\n\
614             paths: \".github/workflows/*.yml\"\n\
615             path: \"$.name\"\n\
616             equals: \"CI\"\n\
617             level: error\n",
618        );
619        let rule = yaml_path_equals_build(&spec).unwrap();
620        let (tmp, idx) = tempdir_with_files(&[(
621            ".github/workflows/ci.yml",
622            b"name: CI\non: push\njobs: {}\n",
623        )]);
624        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
625        assert!(v.is_empty(), "matching name should pass: {v:?}");
626    }
627
628    #[test]
629    fn yaml_path_matches_uses_bracket_notation_for_dashed_keys() {
630        // Per the memory note: dashed YAML keys need bracket
631        // notation (`$.foo['dashed-key']`) because the JSONPath
632        // dot-form can't parse them.
633        let spec = spec_yaml(
634            "id: t\n\
635             kind: yaml_path_matches\n\
636             paths: \"action.yml\"\n\
637             path: \"$.runs['using']\"\n\
638             matches: \"^node\\\\d+$\"\n\
639             level: error\n",
640        );
641        let rule = yaml_path_matches_build(&spec).unwrap();
642        let (tmp, idx) =
643            tempdir_with_files(&[("action.yml", b"runs:\n  using: node20\n  main: index.js\n")]);
644        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
645        assert!(v.is_empty(), "bracket notation should match: {v:?}");
646    }
647
648    // ─── toml_path_* ─────────────────────────────────────────
649
650    #[test]
651    fn toml_path_equals_passes_when_value_matches() {
652        let spec = spec_yaml(
653            "id: t\n\
654             kind: toml_path_equals\n\
655             paths: \"Cargo.toml\"\n\
656             path: \"$.package.edition\"\n\
657             equals: \"2024\"\n\
658             level: error\n",
659        );
660        let rule = toml_path_equals_build(&spec).unwrap();
661        let (tmp, idx) = tempdir_with_files(&[(
662            "Cargo.toml",
663            b"[package]\nname = \"x\"\nedition = \"2024\"\n",
664        )]);
665        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
666        assert!(v.is_empty(), "matching edition should pass: {v:?}");
667    }
668
669    #[test]
670    fn toml_path_matches_fires_on_floating_version() {
671        // Common policy: deps must be tilde-pinned, not bare.
672        let spec = spec_yaml(
673            "id: t\n\
674             kind: toml_path_matches\n\
675             paths: \"Cargo.toml\"\n\
676             path: \"$.dependencies.serde\"\n\
677             matches: \"^[~=]\"\n\
678             level: error\n",
679        );
680        let rule = toml_path_matches_build(&spec).unwrap();
681        let (tmp, idx) = tempdir_with_files(&[(
682            "Cargo.toml",
683            b"[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n",
684        )]);
685        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
686        assert_eq!(v.len(), 1, "floating `serde = \"1\"` should fire");
687    }
688
689    // ─── parse error path ─────────────────────────────────────
690
691    #[test]
692    fn evaluate_fires_on_malformed_input() {
693        let spec = spec_yaml(
694            "id: t\n\
695             kind: json_path_equals\n\
696             paths: \"package.json\"\n\
697             path: \"$.name\"\n\
698             equals: \"x\"\n\
699             level: error\n",
700        );
701        let rule = json_path_equals_build(&spec).unwrap();
702        let (tmp, idx) = tempdir_with_files(&[("package.json", b"{not valid json")]);
703        let v = rule.evaluate(&ctx(tmp.path(), &idx)).unwrap();
704        assert_eq!(v.len(), 1, "malformed JSON should fire one violation");
705    }
706}