Skip to main content

remem/rules/
compiler.rs

1//! Worker-side preference rule compiler (SP671-T3).
2//!
3//! Canonical SQLite state is compiled only by the background worker. Hooks
4//! read the derived artifact and never perform DB, network, or LLM work.
5
6use 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
42/// Rebuild artifacts for every known project on a bounded cadence. One bad
43/// project is isolated so it cannot stop unrelated memory work.
44pub 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
76/// Worker-only entry point. Failures are durably recorded and propagated;
77/// unchanged successful artifacts and diagnostics are not rewritten.
78pub 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
221/// Pure compile pass used by tests and the worker wrapper.
222pub 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    // Rows are project-before-global and newest-first. One source may emit
241    // several distinct trailer rules, but a later source cannot replace a
242    // conflict family already claimed by the authoritative earlier source.
243    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;