1pub 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 pub fn index(self) -> usize {
74 self as usize
75 }
76
77 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 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#[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 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 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 Instrumentation(String),
192 UnsupportedInput(String),
194 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
212pub enum Field {
213 Whole,
214 Subject,
215 Body,
216 Trailers,
217}
218
219#[derive(Clone, Copy, PartialEq, Eq, Debug)]
221pub enum Stance {
222 Apply,
223 Relax,
224 Off,
225}
226
227pub 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 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 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
312pub fn verify(input: &[u8], approval: &Approval, now_unix: i64) -> VerifyOutcome {
315 waiver::verify(input, approval, now_unix)
316}
317
318pub fn policy_digest() -> String {
320 policy::compute_digest()
321}
322
323pub(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}