1pub mod candidate;
14pub(crate) mod gate;
15mod provenance;
16pub(crate) mod rule_file;
17
18use std::path::{Path, PathBuf};
19
20use gate::Verdict;
21use rule_file::WriteOutcome;
22
23#[derive(Debug, Default)]
25pub struct MineReport {
26 pub created: Vec<String>,
27 pub merged: Vec<String>,
28 pub unchanged: Vec<String>,
29 pub skipped: Vec<(String, String)>,
31 pub candidates_seen: usize,
32 pub output_dir: String,
33 pub notices: Vec<RuleNotice>,
35}
36
37#[derive(Debug, Clone)]
39pub struct RuleSummary {
40 pub slug: String,
41 pub version: u32,
42 pub title: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct RuleNotice {
49 pub path: String,
50 pub code: &'static str,
51 pub reason: &'static str,
52}
53
54#[derive(Debug, Default)]
57pub struct RuleListing {
58 pub rules: Vec<RuleSummary>,
59 pub notices: Vec<RuleNotice>,
60}
61
62fn reviewed(output_root: &Path, project_root: &str) -> Vec<RuleNotice> {
64 provenance::review(output_root, project_root)
65 .into_iter()
66 .map(|(path, notice)| RuleNotice {
67 path: path.display().to_string(),
68 code: notice.code(),
69 reason: notice.sentence(),
70 })
71 .collect()
72}
73
74fn config() -> crate::core::config::SkillifyConfig {
75 crate::core::config::Config::load().skillify
76}
77
78fn output_root(project_root: &str, scope: &str) -> PathBuf {
80 if scope.eq_ignore_ascii_case("global")
81 && let Some(home) = dirs::home_dir()
82 {
83 return home;
84 }
85 PathBuf::from(project_root)
86}
87
88pub fn mine(project_root: &str) -> Result<MineReport, String> {
91 let cfg = config();
92 if !cfg.enabled {
93 return Err("skillify is disabled — set `[skillify] enabled = true`".to_string());
94 }
95 let root = output_root(project_root, &cfg.scope);
96 let now = chrono::Utc::now().to_rfc3339();
97 let candidates = candidate::mine_candidates(project_root);
98
99 let mut report = MineReport {
100 candidates_seen: candidates.len(),
101 output_dir: rule_file::rules_dir(&root).display().to_string(),
102 notices: reviewed(&root, project_root),
106 ..Default::default()
107 };
108
109 for c in &candidates {
110 match gate::judge(c, cfg.min_confidence, cfg.min_recurrence) {
111 Verdict::Skip(reason) => report.skipped.push((c.title.clone(), reason)),
112 Verdict::Keep => {
113 let full = rule_file::full_slug(&c.slug);
114 match write_rule(&root, project_root, c, &now) {
115 Ok(WriteOutcome::Created) => report.created.push(full),
116 Ok(WriteOutcome::Merged) => report.merged.push(full),
117 Ok(WriteOutcome::Unchanged) => report.unchanged.push(full),
118 Err(Refused::Withheld(reason)) => report.skipped.push((full, reason)),
119 Err(Refused::Failed(error)) => return Err(error),
120 }
121 }
122 }
123 }
124 Ok(report)
125}
126
127enum Refused {
129 Withheld(String),
131 Failed(String),
133}
134
135fn write_rule(
144 output_root: &Path,
145 project_root: &str,
146 candidate: &candidate::SkillCandidate,
147 now: &str,
148) -> Result<WriteOutcome, Refused> {
149 let admitted = admitted_candidate(candidate).map_err(Refused::Withheld)?;
150 let planned = rule_file::plan(output_root, &admitted, now);
151 let binding = provenance::prepare(&planned.text, &candidate.derivation, project_root)
152 .map_err(|withheld| Refused::Withheld(withheld.message("rule")))?;
153 planned.write().map_err(Refused::Failed)?;
154 if let Some(binding) = &binding {
155 provenance::record(&planned.path, binding).map_err(Refused::Failed)?;
156 }
157 provenance::release(&planned.path);
158 Ok(planned.outcome)
159}
160
161pub fn list_rules(project_root: &str) -> RuleListing {
163 let cfg = config();
164 let root = output_root(project_root, &cfg.scope);
165 let dir = rule_file::rules_dir(&root);
166 let mut listing = RuleListing {
170 rules: Vec::new(),
171 notices: reviewed(&root, project_root),
172 };
173 let out = &mut listing.rules;
174 let Ok(entries) = std::fs::read_dir(&dir) else {
175 return listing;
176 };
177 for entry in entries.flatten() {
178 let path = entry.path();
179 if path.extension().and_then(|e| e.to_str()) != Some("mdc") {
180 continue;
181 }
182 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
183 continue;
184 };
185 if !stem.starts_with(rule_file::SLUG_PREFIX) {
186 continue;
187 }
188 let held = std::fs::read_to_string(&path)
192 .is_ok_and(|raw| provenance::served(&path, &raw, project_root).is_ok());
193 if !held {
194 continue;
195 }
196 if let Ok(content) = crate::core::rooted_read::read_file_for_tool_rooted(
200 &path.to_string_lossy(),
201 &root.to_string_lossy(),
202 "ctx_skillify",
203 ) {
204 let version = rule_file::parse_existing(&content).map_or(1, |r| r.version);
205 let title =
206 rule_file::extract_description(&content).unwrap_or_else(|| stem.to_string());
207 out.push(RuleSummary {
208 slug: stem.to_string(),
209 version,
210 title,
211 });
212 }
213 }
214 out.sort_by(|a, b| a.slug.cmp(&b.slug));
215 listing
216}
217
218pub fn promote(project_root: &str, slug: &str) -> Result<String, String> {
221 let home = dirs::home_dir().ok_or("cannot resolve home directory")?;
222 promote_into(project_root, slug, &home)
223}
224
225fn promote_into(project_root: &str, slug: &str, home: &Path) -> Result<String, String> {
228 let full = if slug.starts_with(rule_file::SLUG_PREFIX) {
229 slug.to_string()
230 } else {
231 rule_file::full_slug(slug)
232 };
233 provenance::review(Path::new(project_root), project_root);
236 let src = rule_file::rule_path(&PathBuf::from(project_root), &full);
237 if !src.exists() {
238 return Err(format!("no generated rule `{full}` in this project"));
239 }
240 let content = promotable(&src, project_root)?;
244 provenance::served(&src, &content, project_root)
245 .map_err(|withheld| withheld.message("rule"))?;
246 let dst = rule_file::rule_path(home, &full);
247 if let Some(parent) = dst.parent() {
248 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
249 }
250 crate::config_io::write_atomic_with_backup(&dst, &content)?;
251 provenance::carry_over(&src, &dst, &content)?;
253 Ok(dst.display().to_string())
254}
255
256fn promotable(src: &std::path::Path, project_root: &str) -> Result<String, String> {
262 let mut remaining = crate::core::limits::max_read_bytes();
263 let read = crate::core::rooted_read::read_file_for_tool_rooted_admitted(
264 &src.to_string_lossy(),
265 project_root,
266 "ctx_skillify",
267 &mut remaining,
268 )
269 .map_err(|error| error.to_string())?;
270 let original = std::fs::read_to_string(src).map_err(|e| e.to_string())?;
271 if !read.storable || !read.complete || read.content != original {
272 return Err(
273 "rule withheld by the context gateway: its content may not be copied out of the \
274 project unchanged"
275 .to_string(),
276 );
277 }
278 Ok(original)
279}
280
281fn admitted_candidate(
286 candidate: &candidate::SkillCandidate,
287) -> Result<candidate::SkillCandidate, String> {
288 use crate::core::context_admission::carrier::admit_for_store;
289 let admit = |text: &str| admit_for_store(text).map_err(|withheld| withheld.message("rule"));
290 Ok(candidate::SkillCandidate {
291 title: admit(&candidate.title)?,
292 body: admit(&candidate.body)?,
293 ..candidate.clone()
294 })
295}
296
297pub fn current_config() -> crate::core::config::SkillifyConfig {
299 config()
300}
301
302#[cfg(test)]
303mod admission_tests {
304 use super::*;
305
306 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
307
308 fn candidate(body: &str) -> candidate::SkillCandidate {
309 candidate::SkillCandidate {
310 slug: "rotate-keys".into(),
311 title: "Rotate keys".into(),
312 body: body.into(),
313 category: "decision".into(),
314 recurrence: 3,
315 confidence: 0.8,
316 sources: vec!["sess1".into()],
317 derivation: candidate::Derivation::default(),
318 }
319 }
320
321 #[test]
327 fn a_rule_is_used_only_while_the_sources_of_its_facts_admit_it() {
328 use crate::core::context_admission::stored::lineage_support::{
329 governed, project, scoped, two_facts, withdraw_source,
330 };
331 let _data = governed();
332 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
333 let (_directory, root) = project();
334 let output = Path::new(&root);
335 two_facts(&root, "decision");
336 scoped(&root, || {
340 for mined in candidate::mine_candidates(&root) {
341 match write_rule(output, &root, &mined, "2026-01-01T00:00:00Z") {
342 Ok(outcome) => {
343 assert_eq!(mined.slug, "token-check");
344 assert_eq!(outcome, WriteOutcome::Created);
345 }
346 Err(Refused::Withheld(reason)) => {
347 assert_eq!(mined.slug, "release-duty");
348 assert!(reason.contains("store.no_source_authority"), "{reason}");
349 }
350 Err(Refused::Failed(error)) => panic!("{error}"),
351 }
352 }
353 });
354 assert!(!rule_file::rule_path(output, "skillify-release-duty").exists());
355 let created = rule_file::rule_path(output, "skillify-token-check");
356 let text = std::fs::read_to_string(&created).expect("written rule");
357 let sibling = rule_file::rule_path(output, "skillify-edited");
360 std::fs::write(&sibling, &text).expect("sibling");
361 provenance::carry_over(&created, &sibling, &text).expect("binding of the sibling");
362 let edited = format!("{text}\nA line added by hand.\n");
363 std::fs::write(&sibling, &edited).expect("hand edit");
364 let legacy = rule_file::rule_path(output, "skillify-legacy");
365 std::fs::write(&legacy, "Run the linter before a push.\n").expect("legacy rule");
366
367 let listed = || scoped(&root, || list_rules(&root));
368 let slugs = |listing: &RuleListing| -> Vec<String> {
369 listing.rules.iter().map(|rule| rule.slug.clone()).collect()
370 };
371 let notice = |path: &Path, code: &'static str| -> (String, &'static str) {
372 (path.display().to_string(), code)
373 };
374 let codes = |listing: &RuleListing| -> Vec<(String, &'static str)> {
375 listing
376 .notices
377 .iter()
378 .map(|notice| (notice.path.clone(), notice.code))
379 .collect()
380 };
381 let before = listed();
383 assert_eq!(slugs(&before), ["skillify-token-check"]);
384 assert_eq!(
385 codes(&before),
386 [notice(&legacy, "rule.no_source_on_record")]
387 );
388
389 let (copy, origin) = provenance::quarantine_paths(&created).expect("quarantine");
396 withdraw_source(&root);
397 let after = listed();
398 assert!(slugs(&after).is_empty());
399 assert!(!created.exists());
400 assert_eq!(
401 std::fs::read_to_string(copy).ok().as_deref(),
402 Some(text.as_str())
403 );
404 assert!(origin.is_file());
405 assert_eq!(
406 std::fs::read_to_string(&sibling).ok().as_deref(),
407 Some(edited.as_str())
408 );
409 assert_eq!(
410 codes(&after),
411 [
412 notice(&sibling, "rule.edited_source_withdrawn"),
413 notice(&legacy, "rule.no_source_on_record"),
414 ]
415 );
416 assert!(
417 after
418 .notices
419 .iter()
420 .all(|notice| !notice.reason.contains("RS256"))
421 );
422
423 let home = tempfile::tempdir().expect("home");
425 let refused = scoped(&root, || promote_into(&root, "token-check", home.path()))
426 .expect_err("a moved rule is not promoted");
427 assert!(refused.contains("no generated rule"), "{refused}");
428 assert!(!home.path().join(".cursor").exists());
429 }
430
431 #[test]
434 fn rule_files_receive_admitted_text_and_only_unmasked_rules_are_promoted() {
435 use crate::core::context_admission::stored::lineage_support::{
436 bound_fact, project as canonical_project, scoped, withdraw_source,
437 };
438 let _data = crate::core::data_dir::isolated_data_dir();
439 let project = tempfile::tempdir().unwrap();
440 let root = project.path().to_string_lossy().into_owned();
441 let rule = |name: &str, content: &str| {
442 let path = rule_file::rule_path(project.path(), name);
443 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
444 std::fs::write(&path, content).unwrap();
445 path
446 };
447 {
448 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
449 let clean = admitted_candidate(&candidate("Run the linter before a push.")).unwrap();
451 assert_eq!(clean.body, "Run the linter before a push.");
452 let path = rule("skillify-clean", "Run the linter before a push.\n");
453 assert_eq!(
454 promotable(&path, &root).as_deref(),
455 Ok("Run the linter before a push.\n")
456 );
457
458 let keyed = admitted_candidate(&candidate(&format!("Deploy with {AWS_KEY}.")));
459 assert!(keyed.is_ok_and(|rule| !rule.body.contains(AWS_KEY)));
460 let path = rule("skillify-keyed", &format!("Deploy with {AWS_KEY}.\n"));
461 let refused = promotable(&path, &root).expect_err("a masked rule is not copied");
462 assert!(!refused.contains(AWS_KEY), "{refused}");
463
464 let (_bound, bound_root) = canonical_project();
469 let output = Path::new(&bound_root);
470 bound_fact(&bound_root, "decision");
471 scoped(&bound_root, || {
472 for mined in candidate::mine_candidates(&bound_root) {
473 let written = write_rule(output, &bound_root, &mined, "2026-01-01T00:00:00Z");
474 assert!(
475 matches!(written, Ok(WriteOutcome::Created)),
476 "{}",
477 mined.slug
478 );
479 }
480 });
481 let kept = rule_file::rule_path(output, "skillify-token-check");
482 assert!(kept.is_file());
483 withdraw_source(&bound_root);
484 let home = tempfile::tempdir().unwrap();
485 let refused = scoped(&bound_root, || {
486 promote_into(&bound_root, "token-check", home.path())
487 })
488 .expect_err("a rule whose source is withdrawn is not copied");
489 assert!(refused.contains("store.source_unavailable"), "{refused}");
490 assert!(!refused.contains("RS256"), "{refused}");
491 assert!(kept.is_file());
492 assert!(!home.path().join(".cursor").exists());
493 }
494 let policy = crate::core::policy::load(
495 "name = \"skillify-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
496 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
497 )
498 .unwrap();
499 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(Some(policy));
500 let refused = admitted_candidate(&candidate("CONFIDENTIAL\nroadmap-marker"))
501 .expect_err("a blocked candidate is not written");
502 assert!(!refused.contains("roadmap-marker"), "{refused}");
503 let path = rule("skillify-plan", "CONFIDENTIAL\nroadmap-marker\n");
504 let refused = promotable(&path, &root).expect_err("a blocked rule is not copied");
505 assert!(!refused.contains("roadmap-marker"), "{refused}");
506 }
507}