1use anyhow::{bail, Context, Result};
30use regex::Regex;
31use serde::{Deserialize, Serialize};
32use std::collections::BTreeSet;
33use std::path::PathBuf;
34
35use crate::analysis_pipeline::{compile_nonempty_regex, require_name};
36use crate::hashing::sha256;
37
38pub const CORRECTION_POLICY_SCHEMA_VERSION: u32 = 1;
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum CorrectionPolicySource {
49 Embedded,
51 File { path: PathBuf },
53 Inline,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct CorrectionPolicySpec {
64 pub schema_version: u32,
65 pub name: String,
66 pub version: String,
67 pub categories: Vec<CorrectionCategorySpec>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct CorrectionCategorySpec {
74 pub name: String,
75 pub patterns: Vec<String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct CorrectionPolicyIdentity {
85 pub name: String,
86 pub version: String,
87 pub sha256: String,
88 pub source: CorrectionPolicySource,
89}
90
91#[derive(Debug, Clone)]
96pub struct CorrectionPolicy {
97 identity: CorrectionPolicyIdentity,
98 rules: Vec<(String, Regex)>,
102 category_count: usize,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct CorrectionHit<'policy> {
108 pub category: &'policy str,
109 pub matched_text: String,
111 pub match_start_char: usize,
112 pub match_end_char_exclusive: usize,
113}
114
115impl CorrectionPolicySpec {
116 pub(crate) fn compile_in_memory(
126 self,
127 source: CorrectionPolicySource,
128 ) -> Result<CorrectionPolicy> {
129 let digest = sha256(&canonical_digest_input(&self));
130 self.compile(source, digest)
131 }
132
133 pub fn compile(
143 self,
144 source: CorrectionPolicySource,
145 digest: String,
146 ) -> Result<CorrectionPolicy> {
147 if self.schema_version != CORRECTION_POLICY_SCHEMA_VERSION {
148 bail!(
149 "unsupported correction policy schema_version {}; this build understands {}. \
150 Upgrade aise, or set schema_version = {}",
151 self.schema_version,
152 CORRECTION_POLICY_SCHEMA_VERSION,
153 CORRECTION_POLICY_SCHEMA_VERSION
154 );
155 }
156 require_name("correction policy name", &self.name)?;
157 require_name("correction policy version", &self.version)?;
158 if self.categories.is_empty() {
159 bail!(
160 "correction policy '{}' defines no categories; add at least one [[categories]] \
161 table with a name and one or more patterns",
162 self.name
163 );
164 }
165
166 let mut seen = BTreeSet::new();
167 let mut rules = Vec::new();
168 for category in &self.categories {
169 require_name("correction category name", &category.name)?;
170 if !seen.insert(category.name.clone()) {
171 bail!(
172 "duplicate correction category '{}'; merge its patterns into one \
173 [[categories]] table, because the first match wins and the second would \
174 never be reached",
175 category.name
176 );
177 }
178 if category.patterns.is_empty() {
179 bail!(
180 "correction category '{}' has no patterns; give it at least one, or remove \
181 the category",
182 category.name
183 );
184 }
185 for pattern in &category.patterns {
188 compile_nonempty_regex("correction", &category.name, pattern)?;
189 }
190
191 let joined = format!("(?i){}", category.patterns.join("|"));
206 let regex = Regex::new(&joined).with_context(|| {
207 format!(
208 "correction category '{}' does not compile once its {} patterns are combined; \
209 each is valid alone, so check for an unbalanced group spanning them",
210 category.name,
211 category.patterns.len()
212 )
213 })?;
214 if regex.is_match("") {
215 bail!(
216 "correction category '{}' matches empty text after its patterns are combined; \
217 every category must require at least one input character",
218 category.name
219 );
220 }
221 rules.push((category.name.clone(), regex));
222 }
223
224 Ok(CorrectionPolicy {
225 identity: CorrectionPolicyIdentity {
226 name: self.name,
227 version: self.version,
228 sha256: digest,
229 source,
230 },
231 rules,
232 category_count: self.categories.len(),
233 })
234 }
235}
236
237impl CorrectionPolicy {
238 #[cfg(test)]
250 pub fn parse_toml(source_text: &str, source: CorrectionPolicySource) -> Result<Self> {
251 let spec: CorrectionPolicySpec = toml::from_str(source_text).with_context(|| {
252 format!(
253 "failed to parse correction policy from {}",
254 describe_source(&source)
255 )
256 })?;
257 let digest = sha256(source_text.as_bytes());
258 spec.compile(source, digest)
259 }
260
261 pub fn classify(&self, text: &str) -> Option<CorrectionHit<'_>> {
268 self.classify_fragments(std::iter::once((0, text)))
269 }
270
271 fn classify_fragments<'policy, 'text, I>(
277 &'policy self,
278 fragments: I,
279 ) -> Option<CorrectionHit<'policy>>
280 where
281 I: Iterator<Item = (usize, &'text str)> + Clone,
282 {
283 self.rules.iter().find_map(|(category, regex)| {
284 fragments.clone().find_map(|(fragment_start_char, text)| {
285 regex.find(text).map(|matched| {
286 let match_start_char =
287 fragment_start_char.saturating_add(text[..matched.start()].chars().count());
288 let matched_text = matched.as_str().to_string();
289 let match_end_char_exclusive =
290 match_start_char.saturating_add(matched_text.chars().count());
291 CorrectionHit {
292 category: category.as_str(),
293 matched_text,
294 match_start_char,
295 match_end_char_exclusive,
296 }
297 })
298 })
299 })
300 }
301
302 pub fn identity(&self) -> &CorrectionPolicyIdentity {
304 &self.identity
305 }
306
307 pub fn category_count(&self) -> usize {
309 self.category_count
310 }
311
312 pub fn rules(&self) -> &[(String, Regex)] {
314 &self.rules
315 }
316}
317
318pub const EMBEDDED_POLICY_NAME: &str = "corrections";
323
324#[derive(Debug, Clone)]
326pub struct ResolvedCorrectionPolicySet {
327 policies: Vec<CorrectionPolicy>,
328}
329
330impl ResolvedCorrectionPolicySet {
331 pub fn from_policies(policies: Vec<CorrectionPolicy>) -> Self {
336 Self { policies }
337 }
338
339 pub fn classify(&self, text: &str) -> Option<(&CorrectionPolicyIdentity, CorrectionHit<'_>)> {
344 self.policies
345 .iter()
346 .find_map(|policy| policy.classify(text).map(|hit| (policy.identity(), hit)))
347 }
348
349 pub(crate) fn classify_fragments<'policy, 'text, I>(
351 &'policy self,
352 fragments: I,
353 ) -> Option<(&'policy CorrectionPolicyIdentity, CorrectionHit<'policy>)>
354 where
355 I: Iterator<Item = (usize, &'text str)> + Clone,
356 {
357 self.policies.iter().find_map(|policy| {
358 policy
359 .classify_fragments(fragments.clone())
360 .map(|hit| (policy.identity(), hit))
361 })
362 }
363
364 pub fn receipts(&self) -> Vec<CapabilityReceipt> {
369 self.policies
370 .iter()
371 .map(|policy| {
372 let identity = policy.identity();
373 CapabilityReceipt {
374 name: identity.name.clone(),
375 version: identity.version.clone(),
376 sha256: identity.sha256.clone(),
377 }
378 })
379 .collect()
380 }
381
382 pub fn policies(&self) -> &[CorrectionPolicy] {
384 &self.policies
385 }
386
387 pub fn is_empty(&self) -> bool {
389 self.policies.is_empty()
390 }
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct CapabilityReceipt {
396 pub name: String,
397 pub version: String,
398 pub sha256: String,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct MessageClassificationReport {
408 pub policies: Vec<CapabilityReceipt>,
413 pub matches: Vec<crate::models::MessageClassificationMatch>,
415}
416
417pub(crate) fn embedded_policy() -> Result<CorrectionPolicy> {
432 crate::message_classification::MessageClassificationPolicySpec::parse_toml(
433 EMBEDDED_POLICY_TOML,
434 )?
435 .compile(
436 EMBEDDED_POLICY_NAME.to_string(),
437 env!("CARGO_PKG_VERSION").to_string(),
438 CorrectionPolicySource::Embedded,
439 EMBEDDED_POLICY_TOML.as_bytes(),
440 )
441}
442
443pub(crate) const EMBEDDED_POLICY_TOML: &str =
445 include_str!("../skills/ai-session-search/aise-capability.toml");
446
447pub(crate) fn canonical_digest_input_len(spec: &CorrectionPolicySpec) -> usize {
452 let mut bytes = 0_usize;
453 let mut add = |value: &str| {
454 bytes = bytes.saturating_add(std::mem::size_of::<u64>() + value.len());
455 };
456 add(&spec.schema_version.to_string());
457 add(&spec.name);
458 add(&spec.version);
459 for category in &spec.categories {
460 add(&category.name);
461 for pattern in &category.patterns {
462 add(pattern);
463 }
464 }
465 bytes
466}
467
468fn canonical_digest_input(spec: &CorrectionPolicySpec) -> Vec<u8> {
469 let mut out = Vec::new();
470 let mut push = |value: &str| {
471 out.extend_from_slice(&(value.len() as u64).to_le_bytes());
472 out.extend_from_slice(value.as_bytes());
473 };
474 push(&spec.schema_version.to_string());
475 push(&spec.name);
476 push(&spec.version);
477 for category in &spec.categories {
478 push(&category.name);
479 for pattern in &category.patterns {
480 push(pattern);
481 }
482 }
483 out
484}
485
486#[cfg(test)]
487fn describe_source(source: &CorrectionPolicySource) -> String {
488 match source {
489 CorrectionPolicySource::Embedded => "the policy embedded in this executable".to_string(),
490 CorrectionPolicySource::File { path } => path.display().to_string(),
491 CorrectionPolicySource::Inline => "typed inline capability parameters".to_string(),
492 }
493}
494
495#[cfg(test)]
496mod tests {
497
498 #[test]
500 fn a_policy_may_have_one_category_or_many_but_never_zero() {
501 let spec = |count: usize| CorrectionPolicySpec {
502 schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
503 name: "boundary".to_string(),
504 version: "1.0.0".to_string(),
505 categories: (0..count)
506 .map(|index| CorrectionCategorySpec {
507 name: format!("c{index}"),
508 patterns: vec![format!(r"\bmarker{index}\b")],
509 })
510 .collect(),
511 };
512
513 let error = spec(0)
514 .compile_in_memory(CorrectionPolicySource::Embedded)
515 .expect_err("a policy with no categories matches nothing, silently");
516 assert!(format!("{error:#}").contains("[[categories]]"), "{error:#}");
517
518 for count in [1_usize, 64] {
519 let policy = spec(count)
520 .compile_in_memory(CorrectionPolicySource::Embedded)
521 .unwrap_or_else(|error| panic!("{count} categories must compile: {error:#}"));
522 assert_eq!(policy.category_count(), count);
523 let hit = policy
525 .classify(&format!("marker{}", count - 1))
526 .expect("the final category still matches");
527 assert_eq!(hit.category, format!("c{}", count - 1));
528 }
529 }
530
531 #[test]
534 fn a_pattern_matching_empty_text_is_refused() {
535 let spec = CorrectionPolicySpec {
536 schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
537 name: "greedy".to_string(),
538 version: "1.0.0".to_string(),
539 categories: vec![CorrectionCategorySpec {
540 name: "everything".to_string(),
541 patterns: vec![".*".to_string()],
542 }],
543 };
544 let error = spec
545 .compile_in_memory(CorrectionPolicySource::Embedded)
546 .expect_err("a rule that matches the empty string matches every message");
547 let message = format!("{error:#}");
548 assert!(
549 message.contains(".*"),
550 "name the offending pattern: {message}"
551 );
552 }
553
554 #[test]
557 fn blank_names_and_versions_are_refused_by_field() {
558 for (name, version, category, expected) in [
559 ("", "1.0.0", "c", "correction policy name"),
560 (" ", "1.0.0", "c", "correction policy name"),
561 ("ok", "", "c", "correction policy version"),
562 ("ok", " ", "c", "correction policy version"),
563 ("ok", "1.0.0", " ", "correction category name"),
564 ] {
565 let spec = CorrectionPolicySpec {
566 schema_version: CORRECTION_POLICY_SCHEMA_VERSION,
567 name: name.to_string(),
568 version: version.to_string(),
569 categories: vec![CorrectionCategorySpec {
570 name: category.to_string(),
571 patterns: vec![r"\bx\b".to_string()],
572 }],
573 };
574 let error = spec
575 .compile_in_memory(CorrectionPolicySource::Embedded)
576 .expect_err(&format!(
577 "{name:?}/{version:?}/{category:?} must be refused"
578 ));
579 let message = format!("{error:#}");
580 assert!(
581 message.contains(expected),
582 "the message must name WHICH field is blank: {message}"
583 );
584 }
585 }
586
587 use super::*;
588
589 fn policy(body: &str) -> String {
590 format!(
591 "schema_version = 1\nname = \"fixture\"\nversion = \"1.0.0\"\n\n{body}",
592 body = body
593 )
594 }
595
596 fn compile(body: &str) -> Result<CorrectionPolicy> {
597 CorrectionPolicy::parse_toml(&policy(body), CorrectionPolicySource::Embedded)
598 }
599
600 fn message(result: Result<CorrectionPolicy>) -> String {
605 format!(
606 "{:#}",
607 result.expect_err("policy should have been rejected")
608 )
609 }
610
611 #[test]
615 fn declaration_order_is_evaluation_order() {
616 let compiled = compile(
617 r#"
618[[categories]]
619name = "regression"
620patterns = ['''\byou broke\b''']
621
622[[categories]]
623name = "incomplete"
624patterns = ['''\bstill need\b''']
625
626[[categories]]
627name = "other"
628patterns = ['''\bstop\b''']
629"#,
630 )
631 .unwrap();
632
633 let order: Vec<&str> = compiled
634 .rules()
635 .iter()
636 .map(|(category, _)| category.as_str())
637 .collect();
638 assert_eq!(
639 order,
640 ["regression", "incomplete", "other"],
641 "categories must evaluate in declaration order, not sorted order"
642 );
643 }
644
645 #[test]
646 fn first_matching_category_wins_and_later_ones_are_not_reported() {
647 let compiled = compile(
648 r#"
649[[categories]]
650name = "specific"
651patterns = ['''\byou broke the build\b''']
652
653[[categories]]
654name = "catch_all"
655patterns = ['''\bbroke\b''']
656"#,
657 )
658 .unwrap();
659
660 let hit = compiled.classify("you broke the build again").unwrap();
661 assert_eq!(hit.category, "specific");
662 assert_eq!(hit.matched_text, "you broke the build");
663
664 let hit = compiled.classify("the deploy broke overnight").unwrap();
666 assert_eq!(hit.category, "catch_all");
667 }
668
669 #[test]
670 fn patterns_within_a_category_are_ored() {
671 let compiled = compile(
672 r#"
673[[categories]]
674name = "skip_step"
675patterns = ['''\byou forgot\b''', '''\byou missed\b''', '''\byou skipped\b''']
676"#,
677 )
678 .unwrap();
679
680 for (text, expected) in [
681 ("you forgot the tests", "you forgot"),
682 ("you missed a step", "you missed"),
683 ("you skipped the lint", "you skipped"),
684 ] {
685 let hit = compiled.classify(text).unwrap();
686 assert_eq!(hit.category, "skip_step");
687 assert_eq!(hit.matched_text, expected);
688 }
689 assert!(compiled.classify("everything looks fine").is_none());
690 }
691
692 #[test]
697 fn categories_are_case_insensitive_and_report_the_leftmost_match() {
698 let compiled = compile(
699 r#"
700[[categories]]
701name = "skip_step"
702patterns = ['''\byou forgot\b''', '''\byou missed\b''']
703"#,
704 )
705 .unwrap();
706
707 assert_eq!(
709 compiled
710 .classify("You Forgot the tests")
711 .unwrap()
712 .matched_text,
713 "You Forgot",
714 "matching is case-insensitive, and matched_text preserves the ORIGINAL casing"
715 );
716
717 assert_eq!(
720 compiled
721 .classify("you missed X, and you forgot Y")
722 .unwrap()
723 .matched_text,
724 "you missed",
725 "within a category the leftmost match in the message wins, not the first pattern"
726 );
727 }
728
729 #[test]
732 fn matched_text_is_the_substring_not_the_rule() {
733 let compiled = compile(
734 r#"
735[[categories]]
736name = "regression"
737patterns = ['''\byou (deleted|removed|reverted)\b''']
738"#,
739 )
740 .unwrap();
741
742 let hit = compiled.classify("é wait, you reverted my fix").unwrap();
743 assert_eq!(hit.matched_text, "you reverted");
744 assert_eq!(hit.match_start_char, 8);
745 assert_eq!(hit.match_end_char_exclusive, 20);
746 assert_ne!(
747 hit.matched_text, r"\byou (deleted|removed|reverted)\b",
748 "the field must not carry the regex source"
749 );
750 }
751
752 #[test]
753 fn identity_digests_the_exact_source_bytes() {
754 let body = r#"
755[[categories]]
756name = "regression"
757patterns = ['''\byou broke\b''']
758"#;
759 let first = compile(body).unwrap();
760 let second = compile(body).unwrap();
761 assert_eq!(
762 first.identity().sha256,
763 second.identity().sha256,
764 "identical bytes must digest identically"
765 );
766
767 let commented = compile(&format!("# a note\n{body}")).unwrap();
770 assert_ne!(
771 first.identity().sha256,
772 commented.identity().sha256,
773 "a byte-level edit must change the digest even when the parsed values match"
774 );
775 assert_eq!(first.identity().name, "fixture");
776 assert_eq!(first.identity().version, "1.0.0");
777 assert_eq!(first.identity().source, CorrectionPolicySource::Embedded);
778 }
779
780 #[test]
781 fn unknown_schema_version_is_rejected_by_name() {
782 let source = "schema_version = 2\nname = \"f\"\nversion = \"1\"\n\n[[categories]]\nname = \"c\"\npatterns = ['''x''']\n";
783 let err = CorrectionPolicy::parse_toml(source, CorrectionPolicySource::Embedded);
784 let err = message(err);
785 assert!(err.contains("schema_version 2"), "{err}");
786 assert!(
787 err.contains("Upgrade aise"),
788 "must say what to do, not only what is wrong: {err}"
789 );
790 }
791
792 #[test]
793 fn unknown_field_is_a_hard_error_not_a_silent_drop() {
794 let err = compile(
795 r#"
796[[categories]]
797name = "regression"
798pattern = ['''\byou broke\b''']
799"#,
800 );
801 let err = message(err);
802 assert!(
803 err.contains("pattern"),
804 "a misspelled key must be named, not ignored: {err}"
805 );
806 }
807
808 #[test]
809 fn duplicate_category_names_are_rejected_because_the_second_is_unreachable() {
810 let err = compile(
811 r#"
812[[categories]]
813name = "regression"
814patterns = ['''\byou broke\b''']
815
816[[categories]]
817name = "regression"
818patterns = ['''\byou deleted\b''']
819"#,
820 );
821 let err = message(err);
822 assert!(
823 err.contains("duplicate correction category 'regression'"),
824 "{err}"
825 );
826 assert!(err.contains("never be reached"), "must explain why: {err}");
827 }
828
829 #[test]
830 fn structural_emptiness_is_rejected_at_every_level() {
831 let no_categories = CorrectionPolicy::parse_toml(
832 "schema_version = 1\nname = \"f\"\nversion = \"1\"\ncategories = []\n",
833 CorrectionPolicySource::Embedded,
834 );
835 let no_categories = message(no_categories);
836 assert!(
837 no_categories.contains("defines no categories"),
838 "{no_categories}"
839 );
840
841 let no_patterns = message(compile(
842 "[[categories]]\nname = \"regression\"\npatterns = []\n",
843 ));
844 assert!(no_patterns.contains("has no patterns"), "{no_patterns}");
845
846 let blank_name = message(compile(
847 "[[categories]]\nname = \" \"\npatterns = ['''x''']\n",
848 ));
849 assert!(blank_name.contains("must not be empty"), "{blank_name}");
850
851 let blank_policy_name = message(CorrectionPolicy::parse_toml(
852 "schema_version = 1\nname = \"\"\nversion = \"1\"\n\n[[categories]]\nname = \"c\"\npatterns = ['''x''']\n",
853 CorrectionPolicySource::Embedded,
854 ));
855 assert!(
856 blank_policy_name.contains("must not be empty"),
857 "{blank_policy_name}"
858 );
859 }
860
861 #[test]
862 fn invalid_and_empty_matching_regexes_are_rejected() {
863 let invalid = message(compile(
864 "[[categories]]\nname = \"c\"\npatterns = ['''(unclosed''']\n",
865 ));
866 assert!(invalid.contains("invalid correction regex"), "{invalid}");
867
868 let empty_matching = message(compile(
871 "[[categories]]\nname = \"c\"\npatterns = ['''a*''']\n",
872 ));
873 assert!(
874 empty_matching.contains("matches empty text"),
875 "{empty_matching}"
876 );
877 assert!(
878 empty_matching.contains("a*"),
879 "and it names WHICH pattern, since a category may hold several: {empty_matching}"
880 );
881
882 let empty_pattern = message(compile(
883 "[[categories]]\nname = \"c\"\npatterns = ['''''']\n",
884 ));
885 assert!(
886 empty_pattern.contains("must not be empty"),
887 "{empty_pattern}"
888 );
889 }
890
891 #[test]
897 fn the_default_resolution_compiles_to_todays_built_in_rules() {
898 let config = crate::config::Config::default();
899 let resolved = ResolvedCorrectionPolicySet::from_policies(vec![embedded_policy().unwrap()]);
900
901 let new_rules: Vec<(String, String)> = resolved.policies()[0]
902 .rules()
903 .iter()
904 .map(|(category, regex)| (category.clone(), regex.as_str().to_string()))
905 .collect();
906 let old_rules: Vec<(String, String)> = crate::analytics::compile_patterns(&config)
907 .unwrap()
908 .iter()
909 .map(|(category, regex)| (category.clone(), regex.as_str().to_string()))
910 .collect();
911
912 assert_eq!(
913 new_rules, old_rules,
914 "the embedded policy must be indistinguishable from the built-ins it replaces"
915 );
916 assert_eq!(resolved.receipts()[0].name, EMBEDDED_POLICY_NAME);
917 }
918
919 #[test]
920 fn malformed_toml_names_where_it_came_from() {
921 let err = CorrectionPolicy::parse_toml(
922 "schema_version = ",
923 CorrectionPolicySource::File {
924 path: PathBuf::from("/skills/mine/corrections/policy.toml"),
925 },
926 );
927 let err = message(err);
928 assert!(
929 err.contains("/skills/mine/corrections/policy.toml"),
930 "a parse failure must name the file: {err}"
931 );
932 }
933}