Skip to main content

ai_slop/
lib.rs

1//! Detect defects in generated text deterministically and report coverage
2//! in outbound technical artifacts.
3//!
4//! The library performs no I/O and no clock reads. `analyze` is a pure
5//! function of `(input, config, policy)`. The embedded policy package is the
6//! single rule source.
7
8pub mod engine;
9pub mod extract;
10pub mod input;
11pub mod policy;
12pub mod render;
13pub mod report;
14pub mod rules;
15pub mod skill;
16pub mod views;
17pub mod waiver;
18
19use std::ops::Range;
20
21pub use report::{Coverage, Finding, Report};
22pub use waiver::{Approval, VerifyOutcome, Waiver};
23
24pub const SCHEMA_VERSION: &str = "1.0.0";
25pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");
26
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub enum Profile {
29    PublicBugReport,
30    CommitMessage,
31    Changelog,
32    ReleaseNotes,
33    Readme,
34    ApiDocs,
35    CargoMetadata,
36    InternalDoc,
37    PublicComment,
38}
39
40impl Profile {
41    pub const ALL: [Profile; 9] = [
42        Profile::PublicBugReport,
43        Profile::CommitMessage,
44        Profile::Changelog,
45        Profile::ReleaseNotes,
46        Profile::Readme,
47        Profile::ApiDocs,
48        Profile::CargoMetadata,
49        Profile::InternalDoc,
50        Profile::PublicComment,
51    ];
52
53    pub fn as_str(self) -> &'static str {
54        match self {
55            Profile::PublicBugReport => "public-bug-report",
56            Profile::CommitMessage => "commit-message",
57            Profile::Changelog => "changelog",
58            Profile::ReleaseNotes => "release-notes",
59            Profile::Readme => "readme",
60            Profile::ApiDocs => "api-docs",
61            Profile::CargoMetadata => "cargo-metadata",
62            Profile::InternalDoc => "internal-doc",
63            Profile::PublicComment => "public-comment",
64        }
65    }
66
67    #[allow(clippy::should_implement_trait)]
68    pub fn from_str(s: &str) -> Option<Profile> {
69        Profile::ALL.iter().copied().find(|p| p.as_str() == s)
70    }
71
72    /// Index into the policy package profile_names order.
73    pub fn index(self) -> usize {
74        self as usize
75    }
76
77    /// The input format the policy package declares for this profile.
78    pub fn default_format(self) -> InputFormat {
79        match self {
80            Profile::CommitMessage => InputFormat::Commit,
81            Profile::CargoMetadata => InputFormat::Manifest,
82            _ => InputFormat::Markdown,
83        }
84    }
85
86    /// Formats a caller may select for this profile.
87    pub fn supported_formats(self) -> &'static [InputFormat] {
88        match self {
89            Profile::CommitMessage => &[InputFormat::Commit],
90            Profile::CargoMetadata => &[InputFormat::Manifest],
91            _ => &[InputFormat::Markdown, InputFormat::Text],
92        }
93    }
94}
95
96#[derive(Clone, Copy, PartialEq, Eq, Debug)]
97pub enum InputFormat {
98    Markdown,
99    Text,
100    Commit,
101    Manifest,
102}
103
104impl InputFormat {
105    pub fn as_str(self) -> &'static str {
106        match self {
107            InputFormat::Markdown => "markdown",
108            InputFormat::Text => "text",
109            InputFormat::Commit => "commit",
110            InputFormat::Manifest => "manifest",
111        }
112    }
113
114    #[allow(clippy::should_implement_trait)]
115    pub fn from_str(s: &str) -> Option<InputFormat> {
116        match s {
117            "markdown" => Some(InputFormat::Markdown),
118            "text" => Some(InputFormat::Text),
119            "commit" => Some(InputFormat::Commit),
120            "manifest" => Some(InputFormat::Manifest),
121            _ => None,
122        }
123    }
124}
125
126#[derive(Clone, Copy, PartialEq, Eq, Debug)]
127pub enum WaiverAuthority {
128    Human,
129    OrchestratorAgent,
130}
131
132#[derive(Clone, Debug)]
133pub struct Limits {
134    pub max_bytes: usize,
135    pub max_findings: usize,
136}
137
138impl Default for Limits {
139    fn default() -> Self {
140        Limits {
141            max_bytes: 2 * 1024 * 1024,
142            max_findings: 10_000,
143        }
144    }
145}
146
147/// Deployment configuration. All fields optional.
148#[derive(Clone, Debug, Default)]
149pub struct Deployment {
150    pub waiver_authority: Option<WaiverAuthority>,
151    pub demote: Vec<String>,
152    pub expected_version: Option<String>,
153    pub expected_license_wording: Option<String>,
154    pub expected_release_body: Option<String>,
155    /// Narrows the scrub-word list. Terms outside the embedded list are
156    /// ignored, since the global automaton is compiled from the package.
157    pub scrub_overrides: Option<Vec<String>>,
158    pub exempt_comment_markers: Vec<String>,
159}
160
161#[derive(Clone, Debug)]
162pub struct Config {
163    pub profile: Profile,
164    pub input_format: InputFormat,
165    pub limits: Limits,
166    pub suggest: bool,
167    pub waivers: Vec<Waiver>,
168    pub deployment: Deployment,
169    /// Unix seconds used only for waiver expiry checks. None disables the
170    /// expiry comparison, keeping `analyze` clock-free.
171    pub now_unix: Option<i64>,
172}
173
174impl Config {
175    pub fn new(profile: Profile) -> Config {
176        Config {
177            profile,
178            input_format: profile.default_format(),
179            limits: Limits::default(),
180            suggest: false,
181            waivers: Vec::new(),
182            deployment: Deployment::default(),
183            now_unix: None,
184        }
185    }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub enum AnalysisError {
190    /// Detector could not complete. Maps to exit 30.
191    Instrumentation(String),
192    /// Input outside the supported contract. Maps to exit 40.
193    UnsupportedInput(String),
194    /// Configuration misuse. Maps to exit 2.
195    Usage(String),
196}
197
198impl std::fmt::Display for AnalysisError {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match self {
201            AnalysisError::Instrumentation(m) => write!(f, "instrumentation_error: {m}"),
202            AnalysisError::UnsupportedInput(m) => write!(f, "unsupported_input: {m}"),
203            AnalysisError::Usage(m) => write!(f, "usage error: {m}"),
204        }
205    }
206}
207
208impl std::error::Error for AnalysisError {}
209
210/// Commit-message field, used for per-field rule stances.
211#[derive(Clone, Copy, PartialEq, Eq, Debug)]
212pub enum Field {
213    Whole,
214    Subject,
215    Body,
216    Trailers,
217}
218
219/// A resolved rule stance for one profile and field.
220#[derive(Clone, Copy, PartialEq, Eq, Debug)]
221pub enum Stance {
222    Apply,
223    Relax,
224    Off,
225}
226
227/// Analyze one artifact. No input causes this function to panic.
228pub fn analyze(input: &[u8], config: &Config) -> Result<Report, AnalysisError> {
229    validate_config(config)?;
230    let compiled = engine::compiled()
231        .map_err(|e| AnalysisError::Instrumentation(format!("policy load failed: {e}")))?;
232    let prepared = input::prepare(input, config)?;
233    let doc = extract::build_doc(&prepared, config)?;
234    // Prose formats reject raw Rust source. Gating a whole `.rs` file under a
235    // prose profile draws findings from statement punctuation, not prose (the
236    // documented misfire class), so the boundary fails closed instead. The
237    // prose/code split is the extractor's own: backtick fences, tilde fences,
238    // and 4-space indented code blocks all stay outside prose.
239    if matches!(
240        prepared.format,
241        input::FormatData::Markdown | input::FormatData::Text
242    ) {
243        let code_blocks: Vec<Range<usize>> = doc
244            .regions
245            .iter()
246            .filter(|r| r.kind == extract::RegionKind::CodeBlock)
247            .map(|r| r.range.clone())
248            .collect();
249        if let Some((rust_lines, nonblank)) = input::rust_source_shape(&prepared.text, &code_blocks)
250        {
251            return Err(AnalysisError::UnsupportedInput(format!(
252                "input reads as Rust source ({rust_lines} of {nonblank} non-blank \
253                 lines outside code blocks carry Rust signatures); \
254                 extract the rustdoc and gate the extract"
255            )));
256        }
257    }
258    let norm = views::build_norm(&prepared.text, &doc);
259    let mut hits = engine::scan_all(compiled, &prepared, &doc, &norm, config)?;
260    rules::evaluate_structural(compiled, &prepared, &doc, &norm, config, &mut hits);
261    report::assemble(compiled, &prepared, &doc, &norm, config, hits)
262}
263
264fn validate_config(config: &Config) -> Result<(), AnalysisError> {
265    let compiled = engine::compiled()
266        .map_err(|e| AnalysisError::Instrumentation(format!("policy load failed: {e}")))?;
267    for id in &config.deployment.demote {
268        let rule = compiled
269            .pkg
270            .rules
271            .iter()
272            .find(|r| &r.id == id)
273            .ok_or_else(|| AnalysisError::Usage(format!("demote names unknown rule {id}")))?;
274        if rule.id == "SLOP-J001" {
275            return Err(AnalysisError::Usage(
276                "SLOP-J001 is never demotable".to_string(),
277            ));
278        }
279        if rule.tier != policy::Tier::Candidate {
280            return Err(AnalysisError::Usage(format!(
281                "demotion of non-candidate rule {id} is not allowed"
282            )));
283        }
284    }
285    // Waivers are span-bound and expiring. A span-less waiver would
286    // blanket every finding of its rule and a non-expiring one would never
287    // lapse, so both are rejected here, the single choke point covering the
288    // CLI --waivers path and approval-embedded waivers alike.
289    for w in &config.waivers {
290        if w.span.is_none() || w.expires.is_none() {
291            return Err(AnalysisError::Usage(format!(
292                "waiver for {} must be span-bound and carry an explicit expires; \
293                 a span-less or non-expiring waiver is invalid",
294                w.rule_id
295            )));
296        }
297    }
298    if !config
299        .profile
300        .supported_formats()
301        .contains(&config.input_format)
302    {
303        return Err(AnalysisError::Usage(format!(
304            "format {} is not supported by profile {}",
305            config.input_format.as_str(),
306            config.profile.as_str()
307        )));
308    }
309    Ok(())
310}
311
312/// Verify a payload against an approval record. `now_unix` is the caller's
313/// clock reading as unix seconds.
314pub fn verify(input: &[u8], approval: &Approval, now_unix: i64) -> VerifyOutcome {
315    waiver::verify(input, approval, now_unix)
316}
317
318/// The sha256 digest of the canonicalized embedded policy package.
319pub fn policy_digest() -> String {
320    policy::compute_digest()
321}
322
323/// Clamp a byte range outward to char boundaries of `s`.
324pub(crate) fn widen_to_char_boundaries(s: &str, mut r: Range<usize>) -> Range<usize> {
325    if r.start > s.len() {
326        r.start = s.len();
327    }
328    if r.end > s.len() {
329        r.end = s.len();
330    }
331    while r.start > 0 && !s.is_char_boundary(r.start) {
332        r.start -= 1;
333    }
334    while r.end < s.len() && !s.is_char_boundary(r.end) {
335        r.end += 1;
336    }
337    if r.end < r.start {
338        r.end = r.start;
339    }
340    r
341}