Skip to main content

fallow_config/
rule_pack.rs

1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashSet;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::config::Severity;
8use crate::config::glob_validation::compile_user_glob;
9
10/// Supported rule-pack file extensions. TOML is intentionally not supported:
11/// JSON Schema autocomplete is the headline authoring feature and TOML
12/// editors do not consume it.
13const RULE_PACK_EXTENSIONS: &[&str] = &["json", "jsonc"];
14
15/// The rule-pack format version this fallow build understands.
16const SUPPORTED_PACK_VERSION: u32 = 1;
17
18/// Which check a rule-pack rule performs.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
20#[serde(rename_all = "kebab-case")]
21pub enum RulePackRuleKind {
22    /// Ban call sites whose callee path matches one of `callees`.
23    BannedCall,
24    /// Ban imports and re-exports whose raw specifier matches one of
25    /// `specifiers`.
26    BannedImport,
27}
28
29/// One declarative policy rule inside a rule pack.
30///
31/// `callees` applies only to `banned-call` rules; `specifiers` and
32/// `ignoreTypeOnly` apply only to `banned-import` rules. Setting a field on
33/// the wrong kind is a load error (fail loud, never silently ignore policy).
34#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
35#[serde(deny_unknown_fields, rename_all = "camelCase")]
36pub struct RulePackRule {
37    /// Rule id, unique within the pack. `"<pack>/<id>"` is the finding's
38    /// policy identity across output formats and baselines.
39    pub id: String,
40    /// Which check this rule performs.
41    pub kind: RulePackRuleKind,
42    /// Callee patterns to ban (`banned-call` only). Matching is segment-aware
43    /// and import-resolved, identical to `boundaries.calls.forbidden`:
44    /// `child_process.*` covers `import { exec } from "node:child_process"`,
45    /// the bare specifier, and namespace/default imports; `fetch` matches only
46    /// the global `fetch`; a leading `*.member` matches any object.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub callees: Vec<String>,
49    /// Import specifiers to ban (`banned-import` only). Matched segment-aware
50    /// against the RAW specifier: `moment` covers `moment` and
51    /// `moment/locale/nl` but not `moment-timezone`. Aliased or rewritten
52    /// specifiers (e.g. `npm:moment`) are not matched.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub specifiers: Vec<String>,
55    /// When `true`, type-only imports (`import type ...` and type-only
56    /// re-exports) are ignored by this `banned-import` rule. Defaults to
57    /// `false`: type-only imports are flagged too.
58    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
59    pub ignore_type_only: bool,
60    /// Optional include globs (project-root-relative). Empty or absent means
61    /// the rule applies to every analyzed file.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub files: Vec<String>,
64    /// Optional exclude globs (project-root-relative), applied after `files`.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub exclude: Vec<String>,
67    /// Author-provided message naming the sanctioned alternative. Rendered
68    /// next to each finding.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub message: Option<String>,
71    /// Per-rule severity overriding the `rules."policy-violation"` master.
72    /// `off` disables this rule. When the master itself is `off`, the whole
73    /// evaluator is disabled and per-rule severity cannot resurrect it.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub severity: Option<Severity>,
76}
77
78/// A declarative rule pack loaded from a standalone JSON or JSONC file listed
79/// in the `rulePacks` config key.
80///
81/// Rule packs are pure data: loading a pack never executes project code. They
82/// encode project-specific policy (banned calls, banned imports) evaluated
83/// over fallow's static extraction data, reporting as `policy-violation`
84/// findings.
85///
86/// ```jsonc
87/// {
88///   "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/rule-pack-schema.json",
89///   "version": 1,
90///   "name": "team-policy",
91///   "description": "House rules for the platform team",
92///   "rules": [
93///     {
94///       "id": "no-child-process",
95///       "kind": "banned-call",
96///       "callees": ["child_process.*"],
97///       "message": "Use the sandboxed runner instead.",
98///       "severity": "error"
99///     },
100///     {
101///       "id": "no-moment",
102///       "kind": "banned-import",
103///       "specifiers": ["moment"],
104///       "message": "Use date-fns."
105///     }
106///   ]
107/// }
108/// ```
109#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
110#[serde(deny_unknown_fields, rename_all = "camelCase")]
111pub struct RulePackDef {
112    /// JSON Schema reference (ignored during deserialization).
113    #[serde(rename = "$schema", default, skip_serializing)]
114    #[schemars(skip)]
115    pub schema: Option<String>,
116    /// Pack format version. Must be `1`; the field exists so future rule
117    /// kinds can be added without breaking older fallow builds silently.
118    pub version: u32,
119    /// Pack name, unique across all loaded packs. Part of each finding's
120    /// `"<pack>/<id>"` policy identity.
121    pub name: String,
122    /// Optional human description of the pack's intent.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub description: Option<String>,
125    /// The policy rules this pack enforces. Must be non-empty: an empty pack
126    /// would silently enforce nothing.
127    pub rules: Vec<RulePackRule>,
128}
129
130impl RulePackDef {
131    /// Generate JSON Schema for the rule-pack format (consumed by
132    /// `fallow rule-pack-schema` for editor autocomplete).
133    #[must_use]
134    pub fn json_schema() -> serde_json::Value {
135        serde_json::to_value(schemars::schema_for!(RulePackDef)).unwrap_or_default()
136    }
137}
138
139/// One rule-pack load or validation failure, anchored at the offending pack
140/// file.
141#[derive(Debug, Clone)]
142pub struct RulePackError {
143    /// The pack file (as listed in `rulePacks`, root-joined).
144    pub path: PathBuf,
145    /// What went wrong, including the rule id when the error is rule-scoped.
146    pub message: String,
147}
148
149impl std::fmt::Display for RulePackError {
150    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151        write!(f, "{}: {}", self.path.display(), self.message)
152    }
153}
154
155/// Load and validate every rule pack listed in the `rulePacks` config key.
156///
157/// Paths are project-root-relative. Every failure is collected (missing file,
158/// unsupported extension, parse error, schema violation) so the user sees all
159/// problems in one run. A pack that fails any check fails the whole load:
160/// silently skipping policy would be worse than failing.
161///
162/// # Errors
163///
164/// Returns the accumulated list of [`RulePackError`] entries when any listed
165/// pack is missing, unparsable, or invalid.
166pub fn load_rule_packs(
167    root: &Path,
168    pack_paths: &[String],
169) -> Result<Vec<RulePackDef>, Vec<RulePackError>> {
170    let mut packs = Vec::new();
171    let mut errors = Vec::new();
172    let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
173
174    for path_str in pack_paths {
175        let path = root.join(path_str);
176        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
177        if !RULE_PACK_EXTENSIONS.contains(&ext) {
178            errors.push(RulePackError {
179                path: path.clone(),
180                message: format!(
181                    "unsupported rule pack extension '.{ext}'; expected .json or .jsonc"
182                ),
183            });
184            continue;
185        }
186        let content = match std::fs::read_to_string(&path) {
187            Ok(content) => content,
188            Err(e) => {
189                errors.push(RulePackError {
190                    path: path.clone(),
191                    message: format!("failed to read rule pack: {e}"),
192                });
193                continue;
194            }
195        };
196        // Checked after the read so a missing file reports as missing even on
197        // platforms where the project root itself sits behind a symlink.
198        if !crate::external_plugin::is_within_root(&path, &canonical_root) {
199            errors.push(RulePackError {
200                path: path.clone(),
201                message: "resolves outside the project root".to_owned(),
202            });
203            continue;
204        }
205        let parsed: Result<RulePackDef, String> = if ext == "jsonc" {
206            crate::jsonc::parse_to_value::<RulePackDef>(&content).map_err(|e| e.to_string())
207        } else {
208            serde_json::from_str::<RulePackDef>(&content).map_err(|e| e.to_string())
209        };
210        match parsed {
211            Ok(pack) => {
212                let before = errors.len();
213                validate_pack(&pack, &path, &mut errors);
214                if errors.len() == before {
215                    packs.push(pack);
216                }
217            }
218            Err(message) => {
219                errors.push(RulePackError {
220                    path: path.clone(),
221                    message: format!("failed to parse rule pack: {message}"),
222                });
223            }
224        }
225    }
226
227    let mut seen_names: FxHashSet<&str> = FxHashSet::default();
228    for pack in &packs {
229        if !seen_names.insert(pack.name.as_str()) {
230            errors.push(RulePackError {
231                path: root.to_path_buf(),
232                message: format!(
233                    "rule pack name '{}' is declared by more than one pack; pack names must be \
234                     unique because findings are identified as '<pack>/<rule-id>'",
235                    pack.name
236                ),
237            });
238        }
239    }
240
241    if errors.is_empty() {
242        Ok(packs)
243    } else {
244        Err(errors)
245    }
246}
247
248/// Validate a parsed pack. Pushes one error per problem so a pack with three
249/// bad rules reports all three.
250fn validate_pack(pack: &RulePackDef, path: &Path, errors: &mut Vec<RulePackError>) {
251    let err = |message: String| RulePackError {
252        path: path.to_path_buf(),
253        message,
254    };
255
256    if pack.version != SUPPORTED_PACK_VERSION {
257        errors.push(err(format!(
258            "unsupported rule pack version {}; this fallow build supports version \
259             {SUPPORTED_PACK_VERSION}",
260            pack.version
261        )));
262    }
263    if pack.name.trim().is_empty() {
264        errors.push(err("pack `name` must not be empty".to_owned()));
265    }
266    if pack.rules.is_empty() {
267        errors.push(err(
268            "pack declares no rules; an empty pack would silently enforce nothing".to_owned(),
269        ));
270    }
271
272    let mut seen_ids: FxHashSet<&str> = FxHashSet::default();
273    for rule in &pack.rules {
274        if rule.id.trim().is_empty() {
275            errors.push(err("rule `id` must not be empty".to_owned()));
276            continue;
277        }
278        if !seen_ids.insert(rule.id.as_str()) {
279            errors.push(err(format!(
280                "duplicate rule id '{}'; rule ids must be unique within a pack",
281                rule.id
282            )));
283        }
284        validate_rule(rule, path, errors);
285    }
286}
287
288/// Validate one rule's kind-specific fields and patterns.
289fn validate_rule(rule: &RulePackRule, path: &Path, errors: &mut Vec<RulePackError>) {
290    let err = |message: String| RulePackError {
291        path: path.to_path_buf(),
292        message: format!("rule '{}': {message}", rule.id),
293    };
294
295    match rule.kind {
296        RulePackRuleKind::BannedCall => {
297            if rule.callees.is_empty() {
298                errors.push(err(
299                    "banned-call rules must list at least one `callees` pattern".to_owned(),
300                ));
301            }
302            if !rule.specifiers.is_empty() {
303                errors.push(err(
304                    "`specifiers` applies only to banned-import rules".to_owned()
305                ));
306            }
307            if rule.ignore_type_only {
308                errors.push(err(
309                    "`ignoreTypeOnly` applies only to banned-import rules".to_owned()
310                ));
311            }
312            for pattern in &rule.callees {
313                if let Some(reason) = callee_pattern_error(pattern) {
314                    errors.push(err(format!("callee pattern `{pattern}` {reason}")));
315                }
316            }
317        }
318        RulePackRuleKind::BannedImport => {
319            if rule.specifiers.is_empty() {
320                errors.push(err(
321                    "banned-import rules must list at least one `specifiers` entry".to_owned(),
322                ));
323            }
324            if !rule.callees.is_empty() {
325                errors.push(err("`callees` applies only to banned-call rules".to_owned()));
326            }
327            for specifier in &rule.specifiers {
328                if specifier.trim().is_empty() {
329                    errors.push(err("specifier must not be empty".to_owned()));
330                } else if specifier.contains('*') {
331                    errors.push(err(format!(
332                        "specifier `{specifier}` contains `*`; specifier matching is \
333                         segment-aware, not glob. List the package or path prefix; subpaths are \
334                         covered automatically"
335                    )));
336                }
337            }
338        }
339    }
340
341    for (field, patterns) in [("files", &rule.files), ("exclude", &rule.exclude)] {
342        for pattern in patterns {
343            if let Err(e) = compile_user_glob(pattern, "rulePacks rules[].files/exclude") {
344                errors.push(err(format!("invalid `{field}` glob `{pattern}`: {e}")));
345            }
346        }
347    }
348}
349
350/// Reject callee patterns the segment-aware matcher cannot honor, using the
351/// same rules as `boundaries.calls.forbidden` (`validate_call_rules`).
352fn callee_pattern_error(pattern: &str) -> Option<String> {
353    let trimmed = pattern.trim();
354    if trimmed.is_empty() {
355        return Some("must not be empty".to_owned());
356    }
357    if trimmed == "*" {
358        return Some(
359            "matches nothing: a bare `*` has no callee segments. Name a specific callee such as \
360             `console.*` or `child_process.exec`"
361                .to_owned(),
362        );
363    }
364    if trimmed.split('.').any(|segment| segment.trim().is_empty()) {
365        return Some("contains an empty path segment".to_owned());
366    }
367    crate::config::wildcard_placement_error(trimmed)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    fn write_pack(dir: &Path, name: &str, content: &str) -> String {
375        std::fs::write(dir.join(name), content).unwrap();
376        name.to_owned()
377    }
378
379    fn valid_pack_json() -> &'static str {
380        r#"{
381            "version": 1,
382            "name": "team-policy",
383            "description": "House rules",
384            "rules": [
385                {
386                    "id": "no-child-process",
387                    "kind": "banned-call",
388                    "callees": ["child_process.*", "execa"],
389                    "files": ["src/**"],
390                    "exclude": ["src/tooling/**"],
391                    "message": "Use the sandboxed runner instead.",
392                    "severity": "error"
393                },
394                {
395                    "id": "no-moment",
396                    "kind": "banned-import",
397                    "specifiers": ["moment"],
398                    "ignoreTypeOnly": true,
399                    "message": "Use date-fns."
400                }
401            ]
402        }"#
403    }
404
405    #[test]
406    fn loads_valid_json_pack() {
407        let dir = tempfile::tempdir().unwrap();
408        let path = write_pack(dir.path(), "policy.json", valid_pack_json());
409        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
410        assert_eq!(packs.len(), 1);
411        assert_eq!(packs[0].name, "team-policy");
412        assert_eq!(packs[0].rules.len(), 2);
413        assert_eq!(packs[0].rules[0].kind, RulePackRuleKind::BannedCall);
414        assert_eq!(packs[0].rules[0].severity, Some(Severity::Error));
415        assert_eq!(packs[0].rules[1].kind, RulePackRuleKind::BannedImport);
416        assert!(packs[0].rules[1].ignore_type_only);
417        assert_eq!(packs[0].rules[1].severity, None);
418    }
419
420    #[test]
421    fn loads_jsonc_pack_with_comments() {
422        let dir = tempfile::tempdir().unwrap();
423        let path = write_pack(
424            dir.path(),
425            "policy.jsonc",
426            r#"{
427                // why: keep the domain layer pure
428                "version": 1,
429                "name": "jsonc-policy",
430                "rules": [
431                    { "id": "no-console", "kind": "banned-call", "callees": ["console.*"] },
432                ]
433            }"#,
434        );
435        let packs = load_rule_packs(dir.path(), &[path]).unwrap();
436        assert_eq!(packs[0].name, "jsonc-policy");
437    }
438
439    #[test]
440    fn rejects_unsupported_version() {
441        let dir = tempfile::tempdir().unwrap();
442        let path = write_pack(
443            dir.path(),
444            "policy.json",
445            r#"{ "version": 2, "name": "p", "rules": [
446                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
447            ] }"#,
448        );
449        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
450        assert!(
451            errors[0]
452                .message
453                .contains("unsupported rule pack version 2")
454        );
455    }
456
457    #[test]
458    fn rejects_unknown_kind_with_expected_list() {
459        let dir = tempfile::tempdir().unwrap();
460        let path = write_pack(
461            dir.path(),
462            "policy.json",
463            r#"{ "version": 1, "name": "p", "rules": [
464                { "id": "a", "kind": "banned-effect", "callees": ["fetch"] }
465            ] }"#,
466        );
467        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
468        assert!(errors[0].message.contains("banned-effect"));
469        assert!(errors[0].message.contains("banned-call"));
470        assert!(errors[0].message.contains("banned-import"));
471    }
472
473    #[test]
474    fn rejects_unknown_field() {
475        let dir = tempfile::tempdir().unwrap();
476        let path = write_pack(
477            dir.path(),
478            "policy.json",
479            r#"{ "version": 1, "name": "p", "rules": [
480                { "id": "a", "kind": "banned-call", "callees": ["fetch"], "file": ["src/**"] }
481            ] }"#,
482        );
483        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
484        assert!(errors[0].message.contains("file"));
485    }
486
487    #[test]
488    fn rejects_empty_rules_and_empty_pack_name() {
489        let dir = tempfile::tempdir().unwrap();
490        let path = write_pack(
491            dir.path(),
492            "policy.json",
493            r#"{ "version": 1, "name": " ", "rules": [] }"#,
494        );
495        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
496        let joined = errors
497            .iter()
498            .map(|e| e.message.clone())
499            .collect::<Vec<_>>()
500            .join("\n");
501        assert!(joined.contains("declares no rules"));
502        assert!(joined.contains("`name` must not be empty"));
503    }
504
505    #[test]
506    fn rejects_duplicate_rule_ids_within_pack() {
507        let dir = tempfile::tempdir().unwrap();
508        let path = write_pack(
509            dir.path(),
510            "policy.json",
511            r#"{ "version": 1, "name": "p", "rules": [
512                { "id": "a", "kind": "banned-call", "callees": ["fetch"] },
513                { "id": "a", "kind": "banned-import", "specifiers": ["moment"] }
514            ] }"#,
515        );
516        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
517        assert!(errors[0].message.contains("duplicate rule id 'a'"));
518    }
519
520    #[test]
521    fn rejects_duplicate_pack_names() {
522        let dir = tempfile::tempdir().unwrap();
523        let a = write_pack(
524            dir.path(),
525            "a.json",
526            r#"{ "version": 1, "name": "p", "rules": [
527                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
528            ] }"#,
529        );
530        let b = write_pack(
531            dir.path(),
532            "b.json",
533            r#"{ "version": 1, "name": "p", "rules": [
534                { "id": "b", "kind": "banned-call", "callees": ["eval"] }
535            ] }"#,
536        );
537        let errors = load_rule_packs(dir.path(), &[a, b]).unwrap_err();
538        assert!(errors[0].message.contains("rule pack name 'p'"));
539    }
540
541    #[test]
542    fn rejects_cross_kind_fields() {
543        let dir = tempfile::tempdir().unwrap();
544        let path = write_pack(
545            dir.path(),
546            "policy.json",
547            r#"{ "version": 1, "name": "p", "rules": [
548                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
549                  "specifiers": ["moment"], "ignoreTypeOnly": true },
550                { "id": "b", "kind": "banned-import", "specifiers": ["moment"],
551                  "callees": ["fetch"] }
552            ] }"#,
553        );
554        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
555        let joined = errors
556            .iter()
557            .map(|e| e.message.clone())
558            .collect::<Vec<_>>()
559            .join("\n");
560        assert!(joined.contains("`specifiers` applies only to banned-import"));
561        assert!(joined.contains("`ignoreTypeOnly` applies only to banned-import"));
562        assert!(joined.contains("`callees` applies only to banned-call"));
563    }
564
565    #[test]
566    fn rejects_missing_kind_fields() {
567        let dir = tempfile::tempdir().unwrap();
568        let path = write_pack(
569            dir.path(),
570            "policy.json",
571            r#"{ "version": 1, "name": "p", "rules": [
572                { "id": "a", "kind": "banned-call" },
573                { "id": "b", "kind": "banned-import" }
574            ] }"#,
575        );
576        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
577        let joined = errors
578            .iter()
579            .map(|e| e.message.clone())
580            .collect::<Vec<_>>()
581            .join("\n");
582        assert!(joined.contains("must list at least one `callees` pattern"));
583        assert!(joined.contains("must list at least one `specifiers` entry"));
584    }
585
586    #[test]
587    fn rejects_inert_callee_patterns() {
588        let dir = tempfile::tempdir().unwrap();
589        let path = write_pack(
590            dir.path(),
591            "policy.json",
592            r#"{ "version": 1, "name": "p", "rules": [
593                { "id": "a", "kind": "banned-call",
594                  "callees": ["*", "a..b", "child*", "a.*.b"] }
595            ] }"#,
596        );
597        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
598        assert_eq!(errors.len(), 4);
599    }
600
601    #[test]
602    fn rejects_glob_specifiers() {
603        let dir = tempfile::tempdir().unwrap();
604        let path = write_pack(
605            dir.path(),
606            "policy.json",
607            r#"{ "version": 1, "name": "p", "rules": [
608                { "id": "a", "kind": "banned-import", "specifiers": ["moment/**"] }
609            ] }"#,
610        );
611        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
612        assert!(errors[0].message.contains("segment-aware, not glob"));
613    }
614
615    #[test]
616    fn rejects_traversal_globs() {
617        let dir = tempfile::tempdir().unwrap();
618        let path = write_pack(
619            dir.path(),
620            "policy.json",
621            r#"{ "version": 1, "name": "p", "rules": [
622                { "id": "a", "kind": "banned-call", "callees": ["fetch"],
623                  "files": ["../outside/**"] }
624            ] }"#,
625        );
626        let errors = load_rule_packs(dir.path(), &[path]).unwrap_err();
627        assert!(errors[0].message.contains("invalid `files` glob"));
628    }
629
630    #[test]
631    fn rejects_missing_pack_file_and_bad_extension() {
632        let dir = tempfile::tempdir().unwrap();
633        write_pack(dir.path(), "policy.toml", "version = 1");
634        let errors = load_rule_packs(
635            dir.path(),
636            &["missing.json".to_owned(), "policy.toml".to_owned()],
637        )
638        .unwrap_err();
639        assert_eq!(errors.len(), 2);
640        assert!(errors[0].message.contains("failed to read rule pack"));
641        assert!(
642            errors[1]
643                .message
644                .contains("unsupported rule pack extension")
645        );
646    }
647
648    #[test]
649    fn rejects_paths_outside_root() {
650        let dir = tempfile::tempdir().unwrap();
651        let inner = dir.path().join("project");
652        std::fs::create_dir_all(&inner).unwrap();
653        std::fs::write(
654            dir.path().join("outside.json"),
655            r#"{ "version": 1, "name": "p", "rules": [
656                { "id": "a", "kind": "banned-call", "callees": ["fetch"] }
657            ] }"#,
658        )
659        .unwrap();
660        let errors = load_rule_packs(&inner, &["../outside.json".to_owned()]).unwrap_err();
661        assert!(errors[0].message.contains("outside the project root"));
662    }
663
664    #[test]
665    fn schema_validates_doc_example_shape() {
666        let schema = RulePackDef::json_schema();
667        let properties = schema
668            .get("properties")
669            .and_then(|p| p.as_object())
670            .expect("schema should expose properties");
671        assert!(properties.contains_key("version"));
672        assert!(properties.contains_key("name"));
673        assert!(properties.contains_key("rules"));
674
675        // The doc-comment example must parse with the same serde shape the
676        // schema is generated from.
677        let pack: RulePackDef = serde_json::from_str(valid_pack_json()).unwrap();
678        assert_eq!(pack.version, 1);
679    }
680}