1use anyhow::{bail, Context, Result};
7use rusqlite::{params, Connection, OptionalExtension};
8use std::io::ErrorKind;
9
10use crate::rules::artifact::{CompiledRule, CompiledRulesArtifact, RuleAction, RulePredicate};
11use crate::rules::store::{
12 artifact_path_for_project, load_artifact_fail_open, write_artifact_atomic, ArtifactLoad,
13};
14use crate::runtime_config::{rule_compilation_config, RuleCompilationConfig};
15
16mod classify;
17
18pub use classify::{
19 classify_preference_predicate, classify_preference_predicates, PreferenceClassification,
20 PreferencePredicate,
21};
22
23const PACKAGE_MANAGER_MESSAGE: &str = "Command violates a compiled package-manager preference";
24const FORBIDDEN_COMMAND_MESSAGE: &str = "Command violates a compiled forbidden-command preference";
25const COMMIT_TRAILER_MESSAGE: &str = "Commit message violates a compiled trailer preference";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct CompileOutcome {
29 pub project: String,
30 pub rule_count: usize,
31 pub artifact_path: std::path::PathBuf,
32 pub artifact_changed: bool,
33}
34
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36pub struct CompileSweepOutcome {
37 pub projects_seen: usize,
38 pub artifacts_changed: usize,
39 pub failures: usize,
40}
41
42pub fn run_compile_rules_sweep() -> Result<CompileSweepOutcome> {
45 let config = rule_compilation_config()?;
46 if !config.enabled {
47 return Ok(CompileSweepOutcome::default());
48 }
49
50 let conn = crate::db::open_db()?;
51 let projects = load_rule_compilation_projects(&conn)?;
52 drop(conn);
53
54 let mut outcome = CompileSweepOutcome {
55 projects_seen: projects.len(),
56 ..Default::default()
57 };
58 for project in &projects {
59 match run_compile_rules_job(project) {
60 Ok(Some(project_outcome)) => {
61 outcome.artifacts_changed += usize::from(project_outcome.artifact_changed);
62 }
63 Ok(None) => {}
64 Err(error) => {
65 outcome.failures += 1;
66 crate::log::error(
67 "rules",
68 &format!("rule compilation sweep failed for project {project}: {error}"),
69 );
70 }
71 }
72 }
73 Ok(outcome)
74}
75
76pub fn run_compile_rules_job(project: &str) -> Result<Option<CompileOutcome>> {
79 let config = rule_compilation_config()?;
80 if !config.enabled {
81 return Ok(None);
82 }
83 let conn = crate::db::open_db()?;
84 let data_dir = crate::db::absolute_data_dir()?;
85 let artifact_path = artifact_path_for_project(&data_dir, project);
86
87 let mut conflict_messages = Vec::new();
88 let artifact = match compile_project_rules_with_conflicts(
89 &conn,
90 project,
91 config,
92 &mut conflict_messages,
93 ) {
94 Ok(artifact) => artifact,
95 Err(error) => {
96 if let Err(diagnostic_error) =
97 record_diagnostic(&conn, project, "error", &error.to_string(), None, None)
98 {
99 crate::log::error(
100 "rules",
101 &format!(
102 "compile and diagnostic persistence failed for project {project}: {diagnostic_error}"
103 ),
104 );
105 return Err(error.context(format!(
106 "failed to persist compile diagnostic: {diagnostic_error}"
107 )));
108 }
109 crate::log::error(
110 "rules",
111 &format!("compile failed for project {project}: {error}"),
112 );
113 return Err(error);
114 }
115 };
116
117 let rule_count = artifact.rules.len();
118 if matches!(
119 load_artifact_fail_open(&artifact_path),
120 ArtifactLoad::Loaded(existing) if existing.rules == artifact.rules
121 ) {
122 record_compile_success(
123 &conn,
124 project,
125 rule_count,
126 &artifact_path,
127 false,
128 &conflict_messages,
129 )?;
130 return Ok(Some(CompileOutcome {
131 project: project.to_string(),
132 rule_count,
133 artifact_path,
134 artifact_changed: false,
135 }));
136 }
137
138 let previous_artifact = match snapshot_artifact(&artifact_path) {
139 Ok(previous) => previous,
140 Err(error) => {
141 let message = format!("artifact snapshot failed: {error}");
142 if let Err(diagnostic_error) =
143 record_diagnostic(&conn, project, "error", &message, None, None)
144 {
145 return Err(error.context(format!(
146 "failed to persist artifact snapshot diagnostic: {diagnostic_error}"
147 )));
148 }
149 return Err(error);
150 }
151 };
152
153 if let Err(error) = write_artifact_atomic(&artifact_path, &artifact) {
154 let message = format!("artifact write failed: {error}");
155 crate::log::error(
156 "rules",
157 &format!("compile artifact write failed for project {project}: {error}"),
158 );
159 if let Err(diagnostic_error) =
160 record_diagnostic(&conn, project, "error", &message, None, None)
161 {
162 return Err(error.context(format!(
163 "failed to persist artifact write diagnostic: {diagnostic_error}"
164 )));
165 }
166 return Err(error);
167 }
168
169 if let Err(diagnostic_error) = record_compile_success(
170 &conn,
171 project,
172 rule_count,
173 &artifact_path,
174 true,
175 &conflict_messages,
176 ) {
177 crate::log::error(
178 "rules",
179 &format!(
180 "compile success diagnostic failed for project {project}; restoring previous artifact: {diagnostic_error}"
181 ),
182 );
183 return match restore_artifact(&artifact_path, previous_artifact) {
184 Ok(()) => Err(diagnostic_error
185 .context("compile success diagnostic failed; previous artifact restored")),
186 Err(restore_error) => Err(diagnostic_error.context(format!(
187 "compile success diagnostic failed and previous artifact restoration failed: {restore_error}"
188 ))),
189 };
190 }
191 Ok(Some(CompileOutcome {
192 project: project.to_string(),
193 rule_count,
194 artifact_path,
195 artifact_changed: true,
196 }))
197}
198
199fn snapshot_artifact(path: &std::path::Path) -> Result<Option<Vec<u8>>> {
200 match std::fs::read(path) {
201 Ok(contents) => Ok(Some(contents)),
202 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
203 Err(error) => Err(error)
204 .with_context(|| format!("read previous compiled rules artifact {}", path.display())),
205 }
206}
207
208fn restore_artifact(path: &std::path::Path, previous: Option<Vec<u8>>) -> Result<()> {
209 match previous {
210 Some(contents) => crate::atomic_file::write_atomic(path, contents)
211 .with_context(|| format!("restore compiled rules artifact {}", path.display())),
212 None => match std::fs::remove_file(path) {
213 Ok(()) => Ok(()),
214 Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
215 Err(error) => Err(error)
216 .with_context(|| format!("remove unpublished artifact {}", path.display())),
217 },
218 }
219}
220
221pub fn compile_project_rules(
223 conn: &Connection,
224 project: &str,
225 config: RuleCompilationConfig,
226) -> Result<CompiledRulesArtifact> {
227 compile_project_rules_with_conflicts(conn, project, config, &mut Vec::new())
228}
229
230fn compile_project_rules_with_conflicts(
231 conn: &Connection,
232 project: &str,
233 config: RuleCompilationConfig,
234 conflict_messages: &mut Vec<String>,
235) -> Result<CompiledRulesArtifact> {
236 let now = chrono::Utc::now().timestamp();
237 let eligible = select_eligible_preferences(conn, project, config.min_reinforcement, now)?;
238 let overrides = load_overrides(conn, project)?;
239
240 let mut conflict_sources = std::collections::HashMap::<String, i64>::new();
244 let mut rules = Vec::new();
245 for pref in eligible {
246 let classifications = classify_preference_predicates(&pref.content);
247 if classifications.is_empty() {
248 bail!(
249 "preference memory {} is marked machine_checkable but no safe v1 predicate can be derived",
250 pref.memory_id
251 );
252 }
253
254 for (index, classification) in classifications.into_iter().enumerate() {
255 let conflict_key = classification.predicate.conflict_key();
256 match conflict_sources.get(&conflict_key) {
257 Some(source_memory_id) if *source_memory_id != pref.memory_id => {
258 conflict_messages.push(format!(
259 "dropped preference #{} behind authoritative conflicting rule ({conflict_key})",
260 pref.memory_id
261 ));
262 continue;
263 }
264 Some(_) => {}
265 None => {
266 conflict_sources.insert(conflict_key, pref.memory_id);
267 }
268 }
269
270 let rule_id = format!("pref-{}-{}", pref.memory_id, index + 1);
271 let predicate = match classification.predicate {
272 PreferencePredicate::CommandRegex { pattern, .. } => RulePredicate::CommandRegex {
273 pattern,
274 message: PACKAGE_MANAGER_MESSAGE.to_string(),
275 },
276 PreferencePredicate::CommitTrailerForbidden { trailer, .. } => {
277 RulePredicate::CommitTrailerForbidden {
278 trailer,
279 message: COMMIT_TRAILER_MESSAGE.to_string(),
280 }
281 }
282 PreferencePredicate::GitPushForceForbidden { .. } => {
283 RulePredicate::GitPushForceForbidden {
284 message: FORBIDDEN_COMMAND_MESSAGE.to_string(),
285 }
286 }
287 };
288 let override_state =
289 overrides
290 .get(&rule_id)
291 .cloned()
292 .unwrap_or(crate::rules::RuleOverrideState {
293 disabled: false,
294 action_override: None,
295 });
296 rules.push(CompiledRule {
297 rule_id,
298 source_memory_id: pref.memory_id,
299 reinforcement_count: pref.reinforcement_count,
300 action: RuleAction::Warn,
301 override_state,
302 predicate,
303 });
304 }
305 }
306
307 rules.sort_by(|a, b| a.rule_id.cmp(&b.rule_id));
308 Ok(CompiledRulesArtifact::new(now, rules))
309}
310
311struct EligiblePreference {
312 memory_id: i64,
313 content: String,
314 reinforcement_count: i64,
315}
316
317fn select_eligible_preferences(
318 conn: &Connection,
319 project: &str,
320 min_reinforcement: i64,
321 now: i64,
322) -> Result<Vec<EligiblePreference>> {
323 let policy_filter = crate::memory::suppression::memory_policy_filter_sql("m");
324 let sql = format!(
325 "SELECT m.id, m.content, r.reinforcement_count
326 FROM memories m
327 JOIN memory_preference_reinforcements r ON r.memory_id = m.id
328 JOIN memory_candidates c ON c.id = m.source_candidate_id
329 WHERE m.memory_type = 'preference'
330 AND m.status = 'active'
331 AND (m.expires_at_epoch IS NULL OR m.expires_at_epoch > ?1)
332 AND (
333 (COALESCE(m.scope, 'project') = 'project'
334 AND m.owner_scope = 'repo'
335 AND COALESCE(
336 NULLIF(m.target_project, ''),
337 NULLIF(m.owner_key, ''),
338 m.project
339 ) = ?3)
340 OR
341 (COALESCE(m.scope, 'project') = 'global'
342 AND m.owner_scope IS NOT NULL)
343 )
344 AND m.source_trust_class IN ('local_tool_output', 'repo_file', 'user_prompt')
345 AND r.machine_checkable = 1
346 AND r.risk_class = 'low'
347 AND r.reinforcement_count >= ?2
348 AND c.risk_class = 'low'
349 AND c.review_status IN ('approved', 'edited', 'auto_promoted')
350 AND {policy_filter}
351 ORDER BY CASE
352 WHEN COALESCE(m.scope, 'project') = 'project' THEN 0
353 ELSE 1
354 END,
355 m.updated_at_epoch DESC,
356 m.id DESC"
357 );
358 let mut stmt = conn.prepare(&sql)?;
359 let rows = stmt.query_map(params![now, min_reinforcement, project], |row| {
360 Ok(EligiblePreference {
361 memory_id: row.get(0)?,
362 content: row.get(1)?,
363 reinforcement_count: row.get(2)?,
364 })
365 })?;
366 crate::db::query::collect_rows(rows).context("load eligible preferences for rule compilation")
367}
368
369fn load_rule_compilation_projects(conn: &Connection) -> Result<Vec<String>> {
370 let mut stmt = conn.prepare(
371 "SELECT project
372 FROM (
373 SELECT DISTINCT project FROM memories
374 UNION
375 SELECT DISTINCT CASE
376 WHEN COALESCE(m.scope, 'project') = 'global' THEN m.project
377 ELSE COALESCE(
378 NULLIF(m.target_project, ''),
379 CASE WHEN m.owner_scope = 'repo' THEN NULLIF(m.owner_key, '') END,
380 m.project
381 )
382 END AS project
383 FROM memories m
384 JOIN memory_preference_reinforcements r ON r.memory_id = m.id
385 UNION
386 SELECT DISTINCT project FROM preference_rule_overrides
387 UNION
388 SELECT DISTINCT project FROM preference_rule_diagnostics
389 UNION
390 SELECT DISTINCT project FROM jobs WHERE job_type = 'compile_rules'
391 UNION
392 SELECT DISTINCT project_path AS project FROM projects
393 )
394 WHERE project IS NOT NULL AND TRIM(project) <> ''
395 ORDER BY project",
396 )?;
397 let rows = stmt.query_map([], |row| row.get(0))?;
398 crate::db::query::collect_rows(rows).context("load projects for rule compilation sweep")
399}
400
401fn load_overrides(
402 conn: &Connection,
403 project: &str,
404) -> Result<std::collections::HashMap<String, crate::rules::RuleOverrideState>> {
405 let mut stmt = conn.prepare(
406 "SELECT rule_id, disabled, action_override
407 FROM preference_rule_overrides
408 WHERE project = ?1",
409 )?;
410 let rows = stmt.query_map(params![project], |row| {
411 let rule_id: String = row.get(0)?;
412 let disabled: i64 = row.get(1)?;
413 let action_override: Option<String> = row.get(2)?;
414 Ok((rule_id, disabled != 0, action_override))
415 })?;
416 let mut map = std::collections::HashMap::new();
417 for row in rows {
418 let (rule_id, disabled, action_override) = row?;
419 let action_override = match action_override.as_deref() {
420 Some("warn") => Some(RuleAction::Warn),
421 Some("block") => Some(RuleAction::Block),
422 Some(other) => bail!("invalid action_override '{other}' for rule {rule_id}"),
423 None => None,
424 };
425 map.insert(
426 rule_id,
427 crate::rules::RuleOverrideState {
428 disabled,
429 action_override,
430 },
431 );
432 }
433 Ok(map)
434}
435
436fn record_diagnostic(
437 conn: &Connection,
438 project: &str,
439 status: &str,
440 message: &str,
441 rule_count: Option<usize>,
442 artifact_path: Option<&str>,
443) -> Result<()> {
444 if status != "ok"
445 && latest_compile_diagnostic(conn, project)?.is_some_and(
446 |(latest_status, latest_message)| latest_status == status && latest_message == message,
447 )
448 {
449 return Ok(());
450 }
451 let now = chrono::Utc::now().timestamp();
452 conn.execute(
453 "INSERT INTO preference_rule_diagnostics
454 (project, event_kind, status, message, rule_id, artifact_path, rule_count, occurred_at_epoch)
455 VALUES (?1, 'compile', ?2, ?3, NULL, ?4, ?5, ?6)",
456 params![
457 project,
458 status,
459 message,
460 artifact_path,
461 rule_count.map(|count| count as i64),
462 now
463 ],
464 )
465 .with_context(|| format!("persist compile diagnostic for {project}"))?;
466 Ok(())
467}
468
469fn record_compile_success(
470 conn: &Connection,
471 project: &str,
472 rule_count: usize,
473 artifact_path: &std::path::Path,
474 artifact_changed: bool,
475 conflict_messages: &[String],
476) -> Result<()> {
477 if !conflict_messages.is_empty() {
478 let mut conflicts = conflict_messages.to_vec();
479 conflicts.sort();
480 conflicts.dedup();
481 return record_diagnostic(
482 conn,
483 project,
484 "warn",
485 &format!(
486 "compiled {rule_count} rule(s) with conflicts: {}",
487 conflicts.join("; ")
488 ),
489 Some(rule_count),
490 Some(&artifact_path.display().to_string()),
491 );
492 }
493
494 let latest = latest_compile_diagnostic(conn, project)
495 .with_context(|| format!("load latest compile diagnostic for {project}"))?;
496 if artifact_changed
497 || latest
498 .as_ref()
499 .is_none_or(|(latest_status, _)| latest_status != "ok")
500 {
501 record_diagnostic(
502 conn,
503 project,
504 "ok",
505 &format!("compiled {rule_count} rule(s)"),
506 Some(rule_count),
507 Some(&artifact_path.display().to_string()),
508 )?;
509 }
510 Ok(())
511}
512
513fn latest_compile_diagnostic(
514 conn: &Connection,
515 project: &str,
516) -> rusqlite::Result<Option<(String, String)>> {
517 conn.query_row(
518 "SELECT status, COALESCE(message, '')
519 FROM preference_rule_diagnostics
520 WHERE project = ?1
521 AND event_kind = 'compile'
522 ORDER BY id DESC
523 LIMIT 1",
524 params![project],
525 |row| Ok((row.get(0)?, row.get(1)?)),
526 )
527 .optional()
528}
529
530#[cfg(test)]
531mod fixture_tests;
532#[cfg(test)]
533mod sweep_tests;
534#[cfg(test)]
535mod tests;