Skip to main content

alint_rules/
lib.rs

1//! Built-in rule implementations for alint.
2//!
3//! Rules are registered into an [`alint_core::RuleRegistry`] via
4//! [`register_builtin`]. Each kind has its own submodule.
5
6use alint_core::RuleRegistry;
7
8pub mod case;
9pub mod command;
10pub mod command_idempotent;
11pub mod commented_out_code;
12pub mod cross_file_value_equals;
13pub mod dir_absent;
14pub mod dir_contains;
15pub mod dir_exists;
16pub mod dir_only_contains;
17pub mod every_matching_has;
18pub mod executable_bit;
19pub mod executable_has_shebang;
20mod extract;
21pub mod file_absent;
22pub mod file_content_forbidden;
23pub mod file_content_matches;
24pub mod file_ends_with;
25pub mod file_exists;
26pub mod file_footer;
27pub mod file_hash;
28pub mod file_header;
29pub mod file_is_ascii;
30pub mod file_is_text;
31pub mod file_max_lines;
32pub mod file_max_size;
33pub mod file_min_lines;
34pub mod file_min_size;
35pub mod file_shebang;
36pub mod file_starts_with;
37pub mod filename_case;
38pub mod filename_regex;
39pub mod final_newline;
40pub mod fixers;
41pub mod for_each_dir;
42pub mod for_each_file;
43pub mod generated_file_fresh;
44pub mod git_blame_age;
45pub mod git_commit_message;
46pub mod git_no_denied_paths;
47pub mod import_gate;
48pub mod indent_style;
49pub mod io;
50pub mod json_schema_passes;
51pub mod line_endings;
52pub mod line_max_width;
53pub mod markdown_paths_resolve;
54pub mod max_consecutive_blank_lines;
55pub mod max_directory_depth;
56pub mod max_files_per_directory;
57pub mod no_bidi_controls;
58pub mod no_bom;
59pub mod no_case_conflicts;
60pub mod no_empty_files;
61pub mod no_illegal_windows_names;
62pub mod no_merge_conflict_markers;
63pub mod no_submodules;
64pub mod no_symlinks;
65pub mod no_trailing_whitespace;
66pub mod no_zero_width_chars;
67pub mod ordered_block;
68pub mod pair;
69pub mod pair_hash;
70pub mod registry_paths_resolve;
71pub mod shebang_has_executable;
72mod spawn;
73pub mod structured_path;
74#[cfg(test)]
75mod test_support;
76pub mod unique_by;
77
78/// Register every built-in rule kind into the given registry.
79///
80/// Naming convention: rules that have a `dir_*` sibling keep
81/// their `file_*` prefix (`file_exists` vs `dir_exists`); rules
82/// with no such parallel also register a short alias without the
83/// prefix — `content_matches`, `content_forbidden`, `header`,
84/// `is_text`, `max_size`. Both forms resolve to the same
85/// builder; new rules land under short names only.
86pub fn register_builtin(registry: &mut RuleRegistry) {
87    registry.register("file_exists", file_exists::build);
88    registry.register("file_absent", file_absent::build);
89    registry.register("dir_exists", dir_exists::build);
90    registry.register("dir_absent", dir_absent::build);
91
92    registry.register("file_content_matches", file_content_matches::build);
93    registry.register("content_matches", file_content_matches::build);
94    registry.register("file_content_forbidden", file_content_forbidden::build);
95    registry.register("content_forbidden", file_content_forbidden::build);
96    registry.register("file_header", file_header::build);
97    registry.register("header", file_header::build);
98    registry.register("file_max_size", file_max_size::build);
99    registry.register("max_size", file_max_size::build);
100    registry.register("file_min_size", file_min_size::build);
101    registry.register("min_size", file_min_size::build);
102    registry.register("file_min_lines", file_min_lines::build);
103    registry.register("min_lines", file_min_lines::build);
104    registry.register("file_max_lines", file_max_lines::build);
105    registry.register("max_lines", file_max_lines::build);
106    registry.register("file_footer", file_footer::build);
107    registry.register("footer", file_footer::build);
108    registry.register("file_shebang", file_shebang::build);
109    registry.register("shebang", file_shebang::build);
110
111    // Structured-query family — JSONPath queries over
112    // JSON / YAML / TOML documents.
113    registry.register("json_path_equals", structured_path::json_path_equals_build);
114    registry.register(
115        "json_path_matches",
116        structured_path::json_path_matches_build,
117    );
118    registry.register("yaml_path_equals", structured_path::yaml_path_equals_build);
119    registry.register(
120        "yaml_path_matches",
121        structured_path::yaml_path_matches_build,
122    );
123    registry.register("toml_path_equals", structured_path::toml_path_equals_build);
124    registry.register(
125        "toml_path_matches",
126        structured_path::toml_path_matches_build,
127    );
128    registry.register("xml_path_equals", structured_path::xml_path_equals_build);
129    registry.register("xml_path_matches", structured_path::xml_path_matches_build);
130    registry.register("json_schema_passes", json_schema_passes::build);
131    registry.register("markdown_paths_resolve", markdown_paths_resolve::build);
132    registry.register("commented_out_code", commented_out_code::build);
133    registry.register("git_no_denied_paths", git_no_denied_paths::build);
134    registry.register("git_commit_message", git_commit_message::build);
135    registry.register("git_blame_age", git_blame_age::build);
136    registry.register("file_is_text", file_is_text::build);
137    registry.register("is_text", file_is_text::build);
138
139    registry.register("filename_case", filename_case::build);
140    registry.register("filename_regex", filename_regex::build);
141    registry.register("pair", pair::build);
142    registry.register("pair_hash", pair_hash::build);
143    registry.register("for_each_dir", for_each_dir::build);
144    registry.register("for_each_file", for_each_file::build);
145    registry.register("dir_only_contains", dir_only_contains::build);
146    registry.register("unique_by", unique_by::build);
147    registry.register("dir_contains", dir_contains::build);
148    registry.register("every_matching_has", every_matching_has::build);
149    registry.register("registry_paths_resolve", registry_paths_resolve::build);
150    registry.register("cross_file_value_equals", cross_file_value_equals::build);
151    registry.register("ordered_block", ordered_block::build);
152    registry.register("generated_file_fresh", generated_file_fresh::build);
153    registry.register("import_gate", import_gate::build);
154    registry.register("command_idempotent", command_idempotent::build);
155
156    // Text-hygiene family (short names — no `file_` prefix).
157    registry.register("no_trailing_whitespace", no_trailing_whitespace::build);
158    registry.register("final_newline", final_newline::build);
159    registry.register("line_endings", line_endings::build);
160    registry.register("line_max_width", line_max_width::build);
161
162    // Security / Unicode sanity.
163    registry.register(
164        "no_merge_conflict_markers",
165        no_merge_conflict_markers::build,
166    );
167    registry.register("no_bidi_controls", no_bidi_controls::build);
168    registry.register("no_zero_width_chars", no_zero_width_chars::build);
169
170    // Encoding + content fingerprint.
171    registry.register("file_is_ascii", file_is_ascii::build);
172    registry.register("no_bom", no_bom::build);
173    registry.register("file_hash", file_hash::build);
174
175    // Structure / layout.
176    registry.register("max_directory_depth", max_directory_depth::build);
177    registry.register("max_files_per_directory", max_files_per_directory::build);
178    registry.register("no_empty_files", no_empty_files::build);
179
180    // Cross-platform / portable metadata.
181    registry.register("no_case_conflicts", no_case_conflicts::build);
182    registry.register("no_illegal_windows_names", no_illegal_windows_names::build);
183
184    // Unix metadata + git.
185    registry.register("no_symlinks", no_symlinks::build);
186    registry.register("executable_bit", executable_bit::build);
187    registry.register("executable_has_shebang", executable_has_shebang::build);
188    registry.register("shebang_has_executable", shebang_has_executable::build);
189    registry.register("no_submodules", no_submodules::build);
190
191    // Hygiene + byte fingerprint.
192    registry.register("indent_style", indent_style::build);
193    registry.register(
194        "max_consecutive_blank_lines",
195        max_consecutive_blank_lines::build,
196    );
197    registry.register("file_starts_with", file_starts_with::build);
198    registry.register("file_ends_with", file_ends_with::build);
199
200    // Plugin tier 1 — shell out to an external CLI per matched
201    // file. Trust-gated at config-load: only the user's own
202    // top-level config can declare these.
203    registry.register("command", command::build);
204}
205
206/// Convenience constructor that returns a fresh registry pre-populated with
207/// every built-in rule.
208pub fn builtin_registry() -> RuleRegistry {
209    let mut r = RuleRegistry::new();
210    register_builtin(&mut r);
211    r
212}
213
214#[cfg(test)]
215mod registry_tests {
216    use super::*;
217
218    #[test]
219    fn every_documented_kind_is_registered() {
220        let r = builtin_registry();
221        let known: Vec<&str> = r.known_kinds().collect();
222        for kind in [
223            // Prefixed kinds (parallel with dir_*).
224            "file_exists",
225            "file_absent",
226            "dir_exists",
227            "dir_absent",
228            // Prefixed + short alias pairs.
229            "file_content_matches",
230            "content_matches",
231            "file_content_forbidden",
232            "content_forbidden",
233            "file_header",
234            "header",
235            "file_max_size",
236            "max_size",
237            "file_min_size",
238            "min_size",
239            "file_min_lines",
240            "min_lines",
241            "file_max_lines",
242            "max_lines",
243            "file_footer",
244            "footer",
245            "file_shebang",
246            "shebang",
247            // Structured-query family.
248            "json_path_equals",
249            "json_path_matches",
250            "yaml_path_equals",
251            "yaml_path_matches",
252            "toml_path_equals",
253            "toml_path_matches",
254            "xml_path_equals",
255            "xml_path_matches",
256            "json_schema_passes",
257            "git_no_denied_paths",
258            "git_commit_message",
259            "git_blame_age",
260            "file_is_text",
261            "is_text",
262            // Short-only.
263            "filename_case",
264            "filename_regex",
265            "pair",
266            "pair_hash",
267            "for_each_dir",
268            "for_each_file",
269            "dir_only_contains",
270            "unique_by",
271            "dir_contains",
272            "every_matching_has",
273            "registry_paths_resolve",
274            "cross_file_value_equals",
275            "ordered_block",
276            "generated_file_fresh",
277            "import_gate",
278            "command_idempotent",
279            // Text-hygiene family.
280            "no_trailing_whitespace",
281            "final_newline",
282            "line_endings",
283            "line_max_width",
284            // Security / Unicode sanity.
285            "no_merge_conflict_markers",
286            "no_bidi_controls",
287            "no_zero_width_chars",
288            // Encoding + fingerprint.
289            "file_is_ascii",
290            "no_bom",
291            "file_hash",
292            // Structure / layout.
293            "max_directory_depth",
294            "max_files_per_directory",
295            "no_empty_files",
296            // Portable metadata.
297            "no_case_conflicts",
298            "no_illegal_windows_names",
299            // Unix metadata + git.
300            "no_symlinks",
301            "executable_bit",
302            "executable_has_shebang",
303            "shebang_has_executable",
304            "no_submodules",
305            // Hygiene + byte fingerprint.
306            "indent_style",
307            "max_consecutive_blank_lines",
308            "file_starts_with",
309            "file_ends_with",
310            // Plugin (tier 1).
311            "command",
312        ] {
313            assert!(
314                known.contains(&kind),
315                "{kind} missing from builtin registry"
316            );
317        }
318    }
319
320    /// The v0.10 cross-file rule kinds must opt out of
321    /// `--changed` filtering (`requires_full_index() == true`)
322    /// and declare no `path_scope` (so the engine never
323    /// skip-by-intersects them). A refactor breaking this would
324    /// silently make them miss violations in PR mode — there was
325    /// no test guarding it before the v0.10 post-audit pass.
326    #[test]
327    fn v010_cross_file_kinds_require_full_index_and_no_path_scope() {
328        use crate::test_support::spec_yaml;
329        use alint_core::Rule;
330
331        let cases: &[(&str, &str)] = &[
332            (
333                "registry_paths_resolve",
334                "id: t\nkind: registry_paths_resolve\nsource: Cargo.toml\n\
335                 extract:\n  toml: \"$.x\"\nlevel: error\n",
336            ),
337            (
338                "cross_file_value_equals",
339                "id: t\nkind: cross_file_value_equals\nsource:\n  file: a.toml\n  \
340                 extract:\n    toml: \"$.x\"\ntargets:\n  files: \"b/*.toml\"\n  \
341                 extract:\n    toml: \"$.y\"\nlevel: error\n",
342            ),
343            (
344                "generated_file_fresh",
345                "id: t\nkind: generated_file_fresh\nfile: x\ncommand: [\"true\"]\n\
346                 level: error\n",
347            ),
348            (
349                "command_idempotent",
350                "id: t\nkind: command_idempotent\ncommand: [\"true\"]\nlevel: error\n",
351            ),
352            (
353                "pair_hash",
354                "id: t\nkind: pair_hash\nsource: a\ntarget: b\nlevel: error\n",
355            ),
356        ];
357
358        for (kind, yaml) in cases {
359            let spec = spec_yaml(yaml);
360            let built: alint_core::Result<Box<dyn Rule>> = match *kind {
361                "registry_paths_resolve" => crate::registry_paths_resolve::build(&spec),
362                "cross_file_value_equals" => crate::cross_file_value_equals::build(&spec),
363                "generated_file_fresh" => crate::generated_file_fresh::build(&spec),
364                "command_idempotent" => crate::command_idempotent::build(&spec),
365                "pair_hash" => crate::pair_hash::build(&spec),
366                _ => unreachable!(),
367            };
368            let rule = built.unwrap_or_else(|e| panic!("{kind} build failed: {e}"));
369            assert!(
370                rule.requires_full_index(),
371                "{kind}: requires_full_index() must be true (cross-file; \
372                 must not be --changed-filtered)"
373            );
374            assert!(
375                rule.path_scope().is_none(),
376                "{kind}: must declare no path_scope (cross-file dispatch)"
377            );
378        }
379    }
380}