1use std::collections::{BTreeMap, BTreeSet};
22use std::path::{Path, PathBuf};
23use std::time::Duration;
24
25use lanekeep_core::{Examples, Gates, Namespace, RuleCard, RuleId, Severity};
26use lanekeep_js::{Limits, RuleRoot, RunClock, Sandbox};
27use serde::Deserialize;
28use thiserror::Error;
29
30pub type Hash = [u8; 32];
32
33#[must_use]
35pub fn hex(hash: &Hash) -> String {
36 use std::fmt::Write as _;
37 hash.iter()
38 .fold(String::with_capacity(64), |mut out, byte| {
39 let _ = write!(out, "{byte:02x}");
40 out
41 })
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct RuleSpec {
47 pub index: usize,
53 pub id: RuleId,
55 pub languages: Vec<String>,
62 pub severity: Severity,
64 pub card: RuleCard,
66 pub query: String,
68 pub gates: Gates,
70 pub timeout: Option<Duration>,
72 pub has_reduce: bool,
74}
75
76#[expect(
78 clippy::struct_field_names,
79 reason = "`ruleset_hash` and `config_hash` are the names docs/architecture.md §8.1 \
80 gives these two cache-key inputs. Renaming them to satisfy the lint would \
81 make the code and the specification disagree about the same thing, which \
82 costs more than the repetition saves."
83)]
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Config {
86 pub include: Vec<String>,
88 pub exclude: Vec<String>,
90 pub rules: Vec<RuleSpec>,
92 pub limits: Limits,
94 pub ruleset_hash: Hash,
96 pub config_hash: Hash,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Error)]
102pub enum ConfigError {
103 #[error("cannot load config `{path}`: {detail}")]
105 Unreadable {
106 path: String,
108 detail: String,
110 },
111
112 #[error("config `{path}` failed to evaluate\n{detail}")]
114 Evaluation {
115 path: String,
117 detail: String,
119 },
120
121 #[error("config `{path}` is not valid: {detail}")]
123 Shape {
124 path: String,
126 detail: String,
128 },
129
130 #[error("rule {position} in `{path}` is not valid: {detail}")]
132 Rule {
133 position: usize,
135 path: String,
137 detail: String,
139 },
140}
141
142#[derive(Debug, Deserialize)]
146struct RawConfig {
147 #[serde(default)]
148 include: Vec<String>,
149 #[serde(default)]
150 exclude: Vec<String>,
151 #[serde(default)]
152 namespaces: Vec<String>,
153 #[serde(default)]
154 severity: BTreeMap<String, String>,
155 #[serde(default)]
156 timeouts: RawTimeouts,
157 #[serde(default)]
158 rules: Vec<RawRule>,
159}
160
161#[derive(Debug, Default, Deserialize)]
162struct RawTimeouts {
163 rule: Option<u64>,
164 global: Option<u64>,
165}
166
167#[derive(Debug, Deserialize)]
168struct RawRule {
169 id: Option<String>,
170 language: Option<RawLanguages>,
171 severity: Option<String>,
172 card: Option<RawCard>,
173 query: Option<String>,
174 #[serde(default)]
175 gates: Gates,
176 timeout: Option<u64>,
177 has_check: bool,
178 has_reduce: bool,
179}
180
181#[derive(Debug, Deserialize)]
183#[serde(untagged)]
184enum RawLanguages {
185 One(String),
186 Many(Vec<String>),
187}
188
189impl RawLanguages {
190 fn into_vec(self) -> Vec<String> {
191 match self {
192 Self::One(language) => vec![language],
193 Self::Many(languages) => languages,
194 }
195 }
196}
197
198#[derive(Debug, Deserialize)]
199struct RawCard {
200 message: Option<String>,
201 remediation: Option<String>,
202 examples: Option<RawExamples>,
203}
204
205#[derive(Debug, Deserialize)]
206struct RawExamples {
207 bad: Option<String>,
208 good: Option<String>,
209}
210
211const ENTRY: &str = "__lanekeep_entry__.js";
216
217const EXTRACT: &str = r"
223 (() => {
224 const c = globalThis.__lanekeepConfig;
225 if (c === null || typeof c !== 'object') return JSON.stringify(null);
226 const rules = Array.isArray(c.rules) ? c.rules : [];
227 return JSON.stringify({
228 include: c.include ?? [],
229 namespaces: c.namespaces ?? [],
230 exclude: c.exclude ?? [],
231 severity: c.severity ?? {},
232 timeouts: c.timeouts ?? {},
233 rules: rules.map((r) => ({
234 id: r?.id ?? null,
235 language: r?.language ?? null,
236 severity: r?.severity ?? null,
237 card: r?.card ?? null,
238 query: r?.query ?? null,
239 gates: r?.gates ?? {},
240 timeout: r?.timeout ?? null,
241 has_check: typeof r?.check === 'function',
242 has_reduce: typeof r?.reduce === 'function',
243 })),
244 });
245 })()
246";
247
248pub fn evaluate_into(
259 sandbox: &Sandbox,
260 root: &RuleRoot,
261 config_path: &Path,
262) -> Result<(), ConfigError> {
263 let display = config_path.display().to_string();
264 let specifier =
265 relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
266 path: display.clone(),
267 detail: "the config file must sit inside the rules root".to_owned(),
268 })?;
269
270 let entry = root.path().join(ENTRY);
271 let source =
272 format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n");
273
274 sandbox
275 .eval_module(&entry.display().to_string(), &source)
276 .map_err(|e| ConfigError::Evaluation {
277 path: display,
278 detail: e.to_string(),
279 })
280}
281
282pub fn load(sandbox: &Sandbox, root: &RuleRoot, config_path: &Path) -> Result<Config, ConfigError> {
289 let display = config_path.display().to_string();
290
291 let specifier =
292 relative_specifier(root.path(), config_path).ok_or_else(|| ConfigError::Unreadable {
293 path: display.clone(),
294 detail: "the config file must sit inside the rules root".to_owned(),
295 })?;
296
297 let entry = root.path().join(ENTRY);
298 let source =
299 format!("import config from '{specifier}';\nglobalThis.__lanekeepConfig = config;\n");
300 sandbox
301 .eval_module(&entry.display().to_string(), &source)
302 .map_err(|e| ConfigError::Evaluation {
303 path: display.clone(),
304 detail: e.to_string(),
305 })?;
306
307 let json: String = sandbox.eval(EXTRACT).map_err(|e| ConfigError::Evaluation {
308 path: display.clone(),
309 detail: e.to_string(),
310 })?;
311
312 let raw: Option<RawConfig> = serde_json::from_str(&json).map_err(|e| ConfigError::Shape {
313 path: display.clone(),
314 detail: e.to_string(),
315 })?;
316 let raw = raw.ok_or_else(|| ConfigError::Shape {
317 path: display.clone(),
318 detail: "the default export is not an object — did you forget `export default`?".to_owned(),
319 })?;
320
321 build(sandbox, raw, &display)
322}
323
324fn build(sandbox: &Sandbox, raw: RawConfig, display: &str) -> Result<Config, ConfigError> {
325 let overrides = parse_severity_overrides(&raw.severity, display)?;
326
327 let mut declared = BTreeSet::new();
331 for namespace in &raw.namespaces {
332 RuleId::namespace_from_str(namespace).map_err(|e| ConfigError::Shape {
333 path: display.to_owned(),
334 detail: format!("`namespaces` contains an invalid entry: {e}"),
335 })?;
336 if namespace == Namespace::LANEKEEP {
337 return Err(ConfigError::Shape {
338 path: display.to_owned(),
339 detail: "`lanekeep` is reserved for rules shipped with lanekeep — a rule's \
340 origin should be readable from its ID"
341 .to_owned(),
342 });
343 }
344 declared.insert(namespace.clone());
345 }
346
347 let mut rules = Vec::with_capacity(raw.rules.len());
348 for (index, rule) in raw.rules.into_iter().enumerate() {
349 rules.push(build_rule(rule, index + 1, display, &overrides, &declared)?);
350 }
351
352 let mut limits = Limits::default();
353 if let Some(ms) = raw.timeouts.rule {
354 limits = limits.with_rule_timeout(Duration::from_millis(ms));
355 }
356 if let Some(ms) = raw.timeouts.global {
357 limits = limits.with_global_timeout(Duration::from_millis(ms));
358 }
359
360 let ruleset_hash = hash_ruleset(sandbox);
361 let config_hash = hash_config(&raw.include, &raw.exclude, &overrides, &limits);
362
363 Ok(Config {
364 include: raw.include,
365 exclude: raw.exclude,
366 rules,
367 limits,
368 ruleset_hash,
369 config_hash,
370 })
371}
372
373fn parse_severity_overrides(
374 raw: &BTreeMap<String, String>,
375 display: &str,
376) -> Result<BTreeMap<RuleId, Severity>, ConfigError> {
377 raw.iter()
378 .map(|(id, severity)| {
379 let id = id.parse::<RuleId>().map_err(|e| ConfigError::Shape {
380 path: display.to_owned(),
381 detail: format!("in `severity`: {e}"),
382 })?;
383 let severity = severity
384 .parse::<Severity>()
385 .map_err(|e| ConfigError::Shape {
386 path: display.to_owned(),
387 detail: format!("in `severity` for `{id}`: {e}"),
388 })?;
389 Ok((id, severity))
390 })
391 .collect()
392}
393
394fn build_rule(
395 raw: RawRule,
396 position: usize,
397 display: &str,
398 overrides: &BTreeMap<RuleId, Severity>,
399 declared: &BTreeSet<String>,
400) -> Result<RuleSpec, ConfigError> {
401 let fail = |detail: String| ConfigError::Rule {
402 position,
403 path: display.to_owned(),
404 detail,
405 };
406
407 let id = raw
408 .id
409 .ok_or_else(|| fail("missing `id`".to_owned()))?
410 .parse::<RuleId>()
411 .map_err(|e| fail(e.to_string()))?;
412
413 if !id.namespace().is_built_in() && !declared.contains(id.namespace().as_str()) {
417 let mut known: Vec<String> = Namespace::built_ins()
418 .iter()
419 .map(|n| format!("`{n}`"))
420 .collect();
421 known.extend(declared.iter().map(|n| format!("`{n}`")));
422 return Err(fail(format!(
423 "rule namespace `{}` is not declared — add it to `namespaces` in the config, \
424 or use one of {}",
425 id.namespace(),
426 known.join(", ")
427 )));
428 }
429
430 if !raw.has_check {
434 return Err(fail(format!(
435 "`{id}` has no `check` function — a rule without one can never report anything"
436 )));
437 }
438
439 let query = raw
440 .query
441 .ok_or_else(|| fail(format!("`{id}` has no `query`")))?;
442 if query.trim().is_empty() {
443 return Err(fail(format!("`{id}` has an empty `query`")));
444 }
445
446 let card = raw
447 .card
448 .ok_or_else(|| fail(format!("`{id}` has no `card`")))?;
449 let examples = card.examples.unwrap_or(RawExamples {
450 bad: None,
451 good: None,
452 });
453 let card = RuleCard {
454 message: card.message.unwrap_or_default(),
455 remediation: card.remediation.unwrap_or_default(),
456 examples: Examples {
457 bad: examples.bad.unwrap_or_default(),
458 good: examples.good.unwrap_or_default(),
459 },
460 };
461 card.validate()
462 .map_err(|problems| fail(format!("`{id}` has an unusable card: {problems:?}")))?;
463
464 let declared = raw
465 .severity
466 .map(|s| s.parse::<Severity>())
467 .transpose()
468 .map_err(|e| fail(format!("`{id}`: {e}")))?
469 .unwrap_or(Severity::Error);
470
471 Ok(RuleSpec {
472 index: position - 1,
473 severity: overrides.get(&id).copied().unwrap_or(declared),
475 id,
476 languages: raw.language.map_or_else(
480 || vec!["typescript".to_owned(), "tsx".to_owned()],
481 RawLanguages::into_vec,
482 ),
483 card,
484 query,
485 gates: raw.gates,
486 timeout: raw.timeout.map(Duration::from_millis),
487 has_reduce: raw.has_reduce,
488 })
489}
490
491fn hash_ruleset(sandbox: &Sandbox) -> Hash {
507 let mut hasher = blake3::Hasher::new();
508 hasher.update(b"lanekeep-ruleset-v1");
509
510 if let Some(loaded) = sandbox.loaded_modules() {
511 for (path, source) in loaded.borrow().iter() {
514 hasher.update(path.to_string_lossy().as_bytes());
515 hasher.update(&[0]);
516 hasher.update(source.as_bytes());
517 hasher.update(&[0]);
518 }
519 }
520
521 *hasher.finalize().as_bytes()
522}
523
524fn hash_config(
530 include: &[String],
531 exclude: &[String],
532 severity: &BTreeMap<RuleId, Severity>,
533 limits: &Limits,
534) -> Hash {
535 let mut hasher = blake3::Hasher::new();
536 hasher.update(b"lanekeep-config-v1");
537
538 for (label, globs) in [
539 (b"include".as_slice(), include),
540 (b"exclude".as_slice(), exclude),
541 ] {
542 hasher.update(label);
543 let mut sorted: Vec<&String> = globs.iter().collect();
546 sorted.sort();
547 for glob in sorted {
548 hasher.update(glob.as_bytes());
549 hasher.update(&[0]);
550 }
551 }
552
553 hasher.update(b"severity");
554 for (id, level) in severity {
555 hasher.update(id.to_string().as_bytes());
556 hasher.update(&[0]);
557 hasher.update(level.as_str().as_bytes());
558 hasher.update(&[0]);
559 }
560
561 hasher.update(b"limits");
562 for value in [
563 limits.rule_timeout.as_millis(),
564 limits.global_timeout.as_millis(),
565 limits.memory_bytes as u128,
566 ] {
567 hasher.update(&value.to_le_bytes());
568 }
569
570 *hasher.finalize().as_bytes()
571}
572
573fn relative_specifier(root: &Path, file: &Path) -> Option<String> {
575 let file = file.canonicalize().ok()?;
576 let relative = file.strip_prefix(root).ok()?;
577 let joined = relative
578 .components()
579 .map(|c| c.as_os_str().to_string_lossy())
580 .collect::<Vec<_>>()
581 .join("/");
582 Some(format!("./{joined}"))
583}
584
585pub fn sandbox_for(
591 root: &RuleRoot,
592 typescript: std::sync::Arc<dyn lanekeep_js::Language>,
593 javascript: std::sync::Arc<dyn lanekeep_js::Language>,
594) -> Result<Sandbox, ConfigError> {
595 let limits = Limits::default();
596 Sandbox::with_modules(
597 limits,
598 RunClock::start(limits.global_timeout),
599 root.clone(),
600 typescript,
601 javascript,
602 )
603 .map_err(|e| ConfigError::Unreadable {
604 path: root.path().display().to_string(),
605 detail: e.to_string(),
606 })
607}
608
609#[must_use]
611pub fn default_config_paths(project_root: &Path) -> Vec<PathBuf> {
612 [
613 "lanekeep.config.ts",
614 "lanekeep.config.js",
615 "lanekeep.config.mjs",
616 ]
617 .iter()
618 .map(|name| project_root.join(name))
619 .collect()
620}
621
622#[cfg(test)]
623mod tests {
624 use std::fs;
625 use std::sync::Arc;
626
627 use lanekeep_lang_js::{JavaScript, TypeScript};
628
629 use super::*;
630
631 struct Fixture {
632 dir: PathBuf,
633 }
634
635 impl Fixture {
636 fn new(name: &str, files: &[(&str, &str)]) -> Self {
637 let dir = std::env::temp_dir().join(format!("lanekeep-config-{name}"));
638 let _ = fs::remove_dir_all(&dir);
639 fs::create_dir_all(&dir).expect("creates dir");
640 let fixture = Self { dir };
641 fixture.write_all(files);
642 fixture
643 }
644
645 fn write_all(&self, files: &[(&str, &str)]) {
646 for (path, contents) in files {
647 let full = self.dir.join(path);
648 if let Some(parent) = full.parent() {
649 fs::create_dir_all(parent).expect("creates parent");
650 }
651 fs::write(&full, contents).expect("writes");
652 }
653 }
654
655 fn load_config(&self) -> Result<Config, ConfigError> {
656 let root = RuleRoot::new(&self.dir).expect("canonicalizes");
657 let sandbox =
658 sandbox_for(&root, Arc::new(TypeScript), Arc::new(JavaScript)).expect("sandbox");
659 load(&sandbox, &root, &self.dir.join("lanekeep.config.ts"))
660 }
661 }
662
663 impl Drop for Fixture {
664 fn drop(&mut self) {
665 let _ = fs::remove_dir_all(&self.dir);
666 }
667 }
668
669 fn rule(id: &str) -> String {
671 format!(
672 "import {{ defineRule }} from 'lanekeep';\n\
673 export default defineRule({{\n\
674 id: '{id}',\n\
675 query: '(identifier) @id',\n\
676 card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
677 check(ctx, m) {{ ctx.report(m.id); }},\n\
678 }});\n"
679 )
680 }
681
682 fn config_with(body: &str) -> String {
683 format!(
684 "import {{ defineConfig }} from 'lanekeep';\n\
685 import rule from './rule';\n\
686 export default defineConfig({{ {body} }});\n"
687 )
688 }
689
690 #[test]
691 fn loads_a_valid_config() {
692 let fixture = Fixture::new(
693 "valid",
694 &[
695 ("rule.ts", &rule("local/example")),
696 (
697 "lanekeep.config.ts",
698 &config_with(
699 "include: ['src/**/*.ts'], exclude: ['**/*.test.ts'], rules: [rule]",
700 ),
701 ),
702 ],
703 );
704
705 let config = fixture.load_config().expect("loads");
706 assert_eq!(config.include, ["src/**/*.ts"]);
707 assert_eq!(config.exclude, ["**/*.test.ts"]);
708 assert_eq!(config.rules.len(), 1);
709 assert_eq!(config.rules[0].id.to_string(), "local/example");
710 assert_eq!(config.rules[0].card.message, "no");
711 assert!(!config.rules[0].has_reduce);
712 }
713
714 #[test]
717 fn a_declared_namespace_is_accepted() {
718 let fixture = Fixture::new(
719 "declared-namespace",
720 &[
721 ("rule.ts", &rule("pera/no-numeric-sizes")),
722 (
723 "lanekeep.config.ts",
724 &config_with("namespaces: ['pera'], rules: [rule]"),
725 ),
726 ],
727 );
728
729 let config = fixture.load_config().expect("loads");
730 assert_eq!(config.rules[0].id.to_string(), "pera/no-numeric-sizes");
731 assert!(!config.rules[0].id.is_built_in());
732 }
733
734 #[test]
738 fn an_undeclared_namespace_is_rejected() {
739 let fixture = Fixture::new(
740 "undeclared-namespace",
741 &[
742 ("rule.ts", &rule("lanekep/no-default-export")),
743 ("lanekeep.config.ts", &config_with("rules: [rule]")),
744 ],
745 );
746
747 let error = fixture
748 .load_config()
749 .expect_err("an undeclared namespace should be refused")
750 .to_string();
751 assert!(error.contains("lanekep"), "{error}");
752 assert!(
753 error.contains("namespaces"),
754 "should say how to fix it: {error}"
755 );
756 }
757
758 #[test]
760 fn the_lanekeep_namespace_cannot_be_claimed() {
761 let fixture = Fixture::new(
762 "reserved-namespace",
763 &[
764 ("rule.ts", &rule("local/example")),
765 (
766 "lanekeep.config.ts",
767 &config_with("namespaces: ['lanekeep'], rules: [rule]"),
768 ),
769 ],
770 );
771
772 let error = fixture
773 .load_config()
774 .expect_err("claiming the reserved namespace should be refused")
775 .to_string();
776 assert!(error.contains("reserved"), "{error}");
777 }
778
779 #[test]
782 fn a_rule_defaults_to_both_typescript_dialects() {
783 let fixture = Fixture::new(
784 "default-languages",
785 &[
786 ("rule.ts", &rule("local/example")),
787 ("lanekeep.config.ts", &config_with("rules: [rule]")),
788 ],
789 );
790
791 let config = fixture.load_config().expect("loads");
792 assert_eq!(config.rules[0].languages, ["typescript", "tsx"]);
793 }
794
795 #[test]
797 fn a_rule_may_declare_one_language_or_several() {
798 for (declaration, expected) in [
799 ("language: 'tsx',", vec!["tsx"]),
800 (
801 "language: ['typescript', 'tsx'],",
802 vec!["typescript", "tsx"],
803 ),
804 ] {
805 let module = format!(
806 "import {{ defineRule }} from 'lanekeep';\n\
807 export default defineRule({{\n\
808 id: 'local/example',\n\
809 {declaration}\n\
810 query: '(identifier) @id',\n\
811 card: {{ message: 'no', remediation: 'do this', examples: {{ bad: 'a', good: 'b' }} }},\n\
812 check(ctx, m) {{ ctx.report(m.id); }},\n\
813 }});\n"
814 );
815 let fixture = Fixture::new(
816 "language-forms",
817 &[
818 ("rule.ts", &module),
819 ("lanekeep.config.ts", &config_with("rules: [rule]")),
820 ],
821 );
822
823 let config = fixture.load_config().expect("loads");
824 assert_eq!(config.rules[0].languages, expected, "{declaration}");
825 }
826 }
827
828 #[test]
829 fn a_rule_without_a_check_function_is_rejected() {
830 let fixture = Fixture::new(
838 "no-check",
839 &[
840 (
841 "rule.ts",
842 "import { defineRule } from 'lanekeep';\n\
843 export default defineRule({\n\
844 id: 'local/typo',\n\
845 query: '(identifier) @id',\n\
846 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
847 onMatch(ctx, m) {},\n\
848 });\n",
849 ),
850 ("lanekeep.config.ts", &config_with("rules: [rule]")),
851 ],
852 );
853
854 let err = fixture.load_config().expect_err("must be rejected");
855 let rendered = err.to_string();
856 assert!(rendered.contains("check"), "{rendered}");
857 assert!(rendered.contains("never report"), "{rendered}");
858 }
859
860 #[test]
861 fn a_rule_with_a_bare_id_is_rejected() {
862 let fixture = Fixture::new(
863 "bare-id",
864 &[
865 ("rule.ts", &rule("example")),
866 ("lanekeep.config.ts", &config_with("rules: [rule]")),
867 ],
868 );
869 let rendered = fixture
870 .load_config()
871 .expect_err("must be rejected")
872 .to_string();
873 assert!(rendered.contains("namespace"), "{rendered}");
874 }
875
876 #[test]
877 fn a_rule_with_an_unusable_card_is_rejected() {
878 let fixture = Fixture::new(
879 "bad-card",
880 &[
881 (
882 "rule.ts",
883 "import { defineRule } from 'lanekeep';\n\
884 export default defineRule({\n\
885 id: 'local/empty',\n\
886 query: '(identifier) @id',\n\
887 card: { message: '', remediation: '', examples: { bad: '', good: '' } },\n\
888 check() {},\n\
889 });\n",
890 ),
891 ("lanekeep.config.ts", &config_with("rules: [rule]")),
892 ],
893 );
894 assert!(fixture.load_config().is_err());
895 }
896
897 #[test]
898 fn a_missing_default_export_says_so() {
899 let fixture = Fixture::new(
902 "no-default",
903 &[
904 ("rule.ts", &rule("local/x")),
905 ("lanekeep.config.ts", "export const notDefault = 1;\n"),
906 ],
907 );
908 let rendered = fixture
909 .load_config()
910 .expect_err("must be rejected")
911 .to_string();
912 assert!(rendered.contains("default"), "{rendered}");
913 }
914
915 #[test]
916 fn a_default_export_that_is_not_an_object_says_so() {
917 let fixture = Fixture::new(
920 "default-not-object",
921 &[
922 ("rule.ts", &rule("local/x")),
923 ("lanekeep.config.ts", "export default 42;\n"),
924 ],
925 );
926 let rendered = fixture
927 .load_config()
928 .expect_err("must be rejected")
929 .to_string();
930 assert!(rendered.contains("export default"), "{rendered}");
931 }
932
933 #[test]
934 fn config_severity_overrides_what_the_rule_declares() {
935 let fixture = Fixture::new(
936 "severity",
937 &[
938 ("rule.ts", &rule("local/example")),
939 (
940 "lanekeep.config.ts",
941 &config_with("rules: [rule], severity: { 'local/example': 'warn' }"),
942 ),
943 ],
944 );
945 let config = fixture.load_config().expect("loads");
946 assert_eq!(config.rules[0].severity, Severity::Warn);
947 }
948
949 #[test]
950 fn timeouts_fall_back_to_the_defaults() {
951 let fixture = Fixture::new(
952 "timeouts-default",
953 &[
954 ("rule.ts", &rule("local/example")),
955 ("lanekeep.config.ts", &config_with("rules: [rule]")),
956 ],
957 );
958 let config = fixture.load_config().expect("loads");
959 assert_eq!(config.limits, Limits::default());
960 }
961
962 #[test]
963 fn timeouts_can_be_overridden() {
964 let fixture = Fixture::new(
965 "timeouts-set",
966 &[
967 ("rule.ts", &rule("local/example")),
968 (
969 "lanekeep.config.ts",
970 &config_with("rules: [rule], timeouts: { rule: 2000, global: 30000 }"),
971 ),
972 ],
973 );
974 let config = fixture.load_config().expect("loads");
975 assert_eq!(config.limits.rule_timeout, Duration::from_secs(2));
976 assert_eq!(config.limits.global_timeout, Duration::from_secs(30));
977 }
978
979 #[test]
982 fn the_ruleset_hash_covers_an_imported_helper() {
983 let files: &[(&str, &str)] = &[
987 ("helper.ts", "export const QUERY = '(identifier) @id';\n"),
988 (
989 "rule.ts",
990 "import { defineRule } from 'lanekeep';\n\
991 import { QUERY } from './helper';\n\
992 export default defineRule({\n\
993 id: 'local/example',\n\
994 query: QUERY,\n\
995 card: { message: 'm', remediation: 'r', examples: { bad: 'a', good: 'b' } },\n\
996 check() {},\n\
997 });\n",
998 ),
999 ("lanekeep.config.ts", ""),
1000 ];
1001 let fixture = Fixture::new("helper-hash", files);
1002 fixture.write_all(&[("lanekeep.config.ts", &config_with("rules: [rule]"))]);
1003
1004 let before = fixture.load_config().expect("loads").ruleset_hash;
1005
1006 fixture.write_all(&[("helper.ts", "export const QUERY = '(string) @s';\n")]);
1007 let after = fixture.load_config().expect("loads").ruleset_hash;
1008
1009 assert_ne!(
1010 hex(&before),
1011 hex(&after),
1012 "changing an imported helper must invalidate the ruleset hash"
1013 );
1014 }
1015
1016 #[test]
1017 fn the_ruleset_hash_is_stable_when_nothing_changed() {
1018 let fixture = Fixture::new(
1019 "stable-hash",
1020 &[
1021 ("rule.ts", &rule("local/example")),
1022 ("lanekeep.config.ts", &config_with("rules: [rule]")),
1023 ],
1024 );
1025 let first = fixture.load_config().expect("loads").ruleset_hash;
1026 let second = fixture.load_config().expect("loads").ruleset_hash;
1027 assert_eq!(hex(&first), hex(&second));
1028 }
1029
1030 #[test]
1031 fn the_config_hash_ignores_glob_order() {
1032 let make = |globs: &str| {
1035 Fixture::new(
1036 &format!("glob-order-{}", globs.len()),
1037 &[
1038 ("rule.ts", &rule("local/example")),
1039 (
1040 "lanekeep.config.ts",
1041 &config_with(&format!("rules: [rule], include: {globs}")),
1042 ),
1043 ],
1044 )
1045 .load_config()
1046 .expect("loads")
1047 .config_hash
1048 };
1049
1050 assert_eq!(
1051 hex(&make("['a/**', 'b/**']")),
1052 hex(&make("['b/**', 'a/**' ]")),
1053 "reordering globs must not change the config hash"
1054 );
1055 }
1056
1057 #[test]
1058 fn the_config_hash_changes_with_severity() {
1059 let make = |extra: &str, tag: &str| {
1060 Fixture::new(
1061 &format!("severity-hash-{tag}"),
1062 &[
1063 ("rule.ts", &rule("local/example")),
1064 (
1065 "lanekeep.config.ts",
1066 &config_with(&format!("rules: [rule]{extra}")),
1067 ),
1068 ],
1069 )
1070 .load_config()
1071 .expect("loads")
1072 .config_hash
1073 };
1074
1075 assert_ne!(
1076 hex(&make("", "none")),
1077 hex(&make(", severity: { 'local/example': 'warn' }", "warn")),
1078 "changing a severity must invalidate"
1079 );
1080 }
1081
1082 #[test]
1083 fn the_config_hash_changes_with_a_timeout() {
1084 let make = |extra: &str, tag: &str| {
1085 Fixture::new(
1086 &format!("timeout-hash-{tag}"),
1087 &[
1088 ("rule.ts", &rule("local/example")),
1089 (
1090 "lanekeep.config.ts",
1091 &config_with(&format!("rules: [rule]{extra}")),
1092 ),
1093 ],
1094 )
1095 .load_config()
1096 .expect("loads")
1097 .config_hash
1098 };
1099
1100 assert_ne!(
1101 hex(&make("", "d")),
1102 hex(&make(", timeouts: { rule: 5000 }", "t"))
1103 );
1104 }
1105
1106 #[test]
1107 fn hex_renders_a_full_hash() {
1108 assert_eq!(hex(&[0u8; 32]).len(), 64);
1109 assert_eq!(hex(&[0xab; 32]), "ab".repeat(32));
1110 }
1111}