1use std::collections::HashSet;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21use crate::adapters::skill_shadow::{PluginShadowReport, ShadowSource};
22
23const ISOLATION_DOC: &str = "docs/opencode-notes.md → \"Isolating from live skills\"";
24
25fn env_path(name: &str) -> Option<PathBuf> {
26 std::env::var_os(name)
27 .filter(|value| !value.is_empty())
28 .map(PathBuf::from)
29}
30
31fn user_home() -> PathBuf {
32 env_path("HOME").unwrap_or_else(|| std::env::home_dir().unwrap_or_default())
33}
34
35fn unquote(value: &str) -> &str {
36 let value = value.trim();
37 if value.len() >= 2
38 && ((value.starts_with('"') && value.ends_with('"'))
39 || (value.starts_with('\'') && value.ends_with('\'')))
40 {
41 value[1..value.len() - 1].trim()
42 } else {
43 value
44 }
45}
46
47fn frontmatter_name(skill_md: &Path) -> Option<String> {
50 let raw = fs::read_to_string(skill_md).ok()?;
51 let mut lines = raw.lines();
52 (lines.next()?.trim() == "---").then_some(())?;
53 let mut found = None;
54 for line in lines {
55 if line.trim() == "---" {
56 return found;
57 }
58 if line.starts_with(' ') || line.starts_with('\t') {
59 continue;
60 }
61 let Some((key, value)) = line.split_once(':') else {
62 continue;
63 };
64 if key.trim() == "name" {
65 let name = unquote(value);
66 found = (!name.is_empty()).then(|| name.to_string());
67 }
68 }
69 None
70}
71
72fn direct_skill_sources(dir: &Path) -> Vec<ShadowSource> {
73 let Ok(entries) = fs::read_dir(dir) else {
74 return Vec::new();
75 };
76 entries
77 .flatten()
78 .filter_map(|entry| {
79 let path = entry.path();
80 if !path.is_dir() {
81 return None;
82 }
83 let skill_name = frontmatter_name(&path.join("SKILL.md"))?;
84 Some(ShadowSource::GlobalSkill {
85 skill_name,
86 path: path.to_string_lossy().into_owned(),
87 })
88 })
89 .collect()
90}
91
92const PROJECT_SKILL_DIRS: [&str; 3] = [".opencode/skills", ".claude/skills", ".agents/skills"];
95
96fn repository_skill_dirs(scan_root: &Path) -> Vec<PathBuf> {
97 let Some(repo_root) = scan_root
98 .ancestors()
99 .find(|path| path.join(".git").exists())
100 else {
101 return Vec::new();
102 };
103 if repo_root == scan_root {
107 return Vec::new();
108 }
109 let mut dirs = Vec::new();
110 let mut cursor = scan_root.parent();
113 while let Some(path) = cursor {
114 for sub in PROJECT_SKILL_DIRS {
115 dirs.push(path.join(sub));
116 }
117 if path == repo_root {
118 break;
119 }
120 cursor = path.parent();
121 }
122 dirs
123}
124
125fn global_skill_dirs(
128 home: &Path,
129 xdg_config_home: Option<&Path>,
130 opencode_config_dir: Option<&Path>,
131) -> Vec<PathBuf> {
132 let mut dirs = vec![default_config_dir(home, xdg_config_home).join("skills")];
133 if let Some(dir) = opencode_config_dir {
134 dirs.push(dir.join("skills"));
135 }
136 dirs.push(home.join(".opencode/skills"));
137 dirs.push(home.join(".claude/skills"));
138 dirs.push(home.join(".agents/skills"));
139 dirs
140}
141
142fn default_config_dir(home: &Path, xdg_config_home: Option<&Path>) -> PathBuf {
143 xdg_config_home
144 .map(Path::to_path_buf)
145 .unwrap_or_else(|| home.join(".config"))
146 .join("opencode")
147}
148
149fn sort_and_dedup(sources: &mut Vec<ShadowSource>) {
150 sources.sort_by_key(|source| match source {
151 ShadowSource::Plugin {
152 plugin,
153 skill_name,
154 path,
155 } => format!("plugin\0{plugin}\0{skill_name}\0{path}"),
156 ShadowSource::GlobalSkill { skill_name, path } => {
157 format!("skill\0{skill_name}\0{path}")
158 }
159 });
160 sources.dedup();
161}
162
163fn detect_with_sources(
164 scan_root: &Path,
165 staged_skill_names: &[&str],
166 home: &Path,
167 xdg_config_home: Option<&Path>,
168 opencode_config_dir: Option<&Path>,
169) -> PluginShadowReport {
170 let staged: HashSet<&str> = staged_skill_names.iter().copied().collect();
171 let mut dirs = repository_skill_dirs(scan_root);
172 dirs.extend(global_skill_dirs(
173 home,
174 xdg_config_home,
175 opencode_config_dir,
176 ));
177
178 let mut seen_dirs = HashSet::new();
179 let mut shadowed = Vec::new();
180 for dir in dirs {
181 if seen_dirs.insert(dir.clone()) {
182 shadowed.extend(direct_skill_sources(&dir));
183 }
184 }
185 shadowed.retain(|source| staged.contains(source.skill_name()));
186 sort_and_dedup(&mut shadowed);
187
188 PluginShadowReport {
189 config_dir: default_config_dir(home, xdg_config_home)
190 .to_string_lossy()
191 .into_owned(),
192 shadowed,
193 }
194}
195
196pub fn shadow_preflight(
199 scan_root: &Path,
200 staged_skill_names: &[&str],
201) -> Option<PluginShadowReport> {
202 let home = user_home();
203 let report = detect_with_sources(
204 scan_root,
205 staged_skill_names,
206 &home,
207 env_path("XDG_CONFIG_HOME").as_deref(),
208 env_path("OPENCODE_CONFIG_DIR").as_deref(),
209 );
210 (!report.shadowed.is_empty()).then_some(report)
211}
212
213fn source_label(source: &ShadowSource) -> String {
214 match source {
215 ShadowSource::Plugin { plugin, .. } => format!("enabled plugin '{plugin}'"),
216 ShadowSource::GlobalSkill { path, .. } => {
217 let parent = Path::new(path).parent().unwrap_or_else(|| Path::new(path));
218 format!("skill directory '{}'", parent.display())
219 }
220 }
221}
222
223pub fn shadow_validity_warnings(report: &PluginShadowReport) -> Vec<String> {
225 report
226 .shadowed
227 .iter()
228 .map(|source| {
229 format!(
230 "staged skill '{}' is also provided by {} — each `opencode run` dispatch could \
231 discover both copies, so with/without results may be contaminated. Isolate the \
232 live skill source before dispatch (see {}).",
233 source.skill_name(),
234 source_label(source),
235 ISOLATION_DOC
236 )
237 })
238 .collect()
239}
240
241pub fn format_shadow_banner(report: &PluginShadowReport) -> String {
243 if report.shadowed.is_empty() {
244 return String::new();
245 }
246 let mut lines = vec![
247 String::new(),
248 "⚠ OpenCode skill-shadow warning: skills staged for this eval are ALSO discoverable"
249 .to_string(),
250 " from your live environment:".to_string(),
251 ];
252 for source in &report.shadowed {
253 lines.push(format!(
254 " • {} — {}",
255 source.skill_name(),
256 source_label(source)
257 ));
258 }
259 lines.extend([
260 " Each `opencode run` dispatch discovers project and global .opencode, .claude,"
261 .to_string(),
262 " and .agents skill dirs, so it can load both copies — the with/without".to_string(),
263 " comparison may be contaminated and the control arm may not be skill-absent.".to_string(),
264 " eval-magic cannot unload a live skill. Before dispatch:".to_string(),
265 " 1. Move or rename the conflicting skill directory.".to_string(),
266 " 2. For a .claude-root collision only: run the dispatch with".to_string(),
267 " OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1.".to_string(),
268 " 3. For a .claude or .agents collision: OPENCODE_DISABLE_EXTERNAL_SKILLS=1".to_string(),
269 " hides both cross-harness roots.".to_string(),
270 format!(" Full mechanics and detection limits: {ISOLATION_DOC}."),
271 ]);
272 lines.join("\n")
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::adapters::skill_shadow::ShadowSource;
279 use std::fs;
280 use std::path::{Path, PathBuf};
281 use tempfile::TempDir;
282
283 fn write_skill(path: &Path, name: &str) {
284 fs::create_dir_all(path).unwrap();
285 fs::write(
286 path.join("SKILL.md"),
287 format!("---\nname: '{name}'\ndescription: test\n---\n"),
288 )
289 .unwrap();
290 }
291
292 fn repo_with_env(tmp: &Path) -> (PathBuf, PathBuf) {
295 let repo = tmp.join("repo");
296 let scan_root = repo.join(".eval-magic/skill/iteration-1/env-g1-with_skill");
297 fs::create_dir_all(repo.join(".git")).unwrap();
298 fs::create_dir_all(&scan_root).unwrap();
299 (repo, scan_root)
300 }
301
302 fn detect(scan_root: &Path, home: &Path) -> PluginShadowReport {
303 detect_with_sources(scan_root, &["target-skill"], home, None, None)
304 }
305
306 #[test]
307 fn project_opencode_root_detects_collision() {
308 let tmp = TempDir::new().unwrap();
309 let (repo, scan_root) = repo_with_env(tmp.path());
310 write_skill(&repo.join(".opencode/skills/live-copy"), "target-skill");
311
312 let report = detect(&scan_root, &tmp.path().join("home"));
313
314 assert_eq!(report.shadowed.len(), 1);
315 assert!(
316 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
317 );
318 }
319
320 #[test]
321 fn project_claude_root_detects_collision() {
322 let tmp = TempDir::new().unwrap();
323 let (repo, scan_root) = repo_with_env(tmp.path());
324 write_skill(&repo.join(".claude/skills/live-copy"), "target-skill");
325
326 let report = detect(&scan_root, &tmp.path().join("home"));
327
328 assert_eq!(report.shadowed.len(), 1);
329 assert!(
330 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
331 );
332 }
333
334 #[test]
335 fn project_agents_root_detects_collision() {
336 let tmp = TempDir::new().unwrap();
337 let (repo, scan_root) = repo_with_env(tmp.path());
338 write_skill(&repo.join(".agents/skills/live-copy"), "target-skill");
339
340 let report = detect(&scan_root, &tmp.path().join("home"));
341
342 assert_eq!(report.shadowed.len(), 1);
343 assert!(
344 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
345 );
346 }
347
348 #[test]
349 fn global_opencode_root_detects_collision() {
350 let tmp = TempDir::new().unwrap();
351 let home = tmp.path().join("home");
352 write_skill(
353 &home.join(".config/opencode/skills/live-copy"),
354 "target-skill",
355 );
356
357 let report = detect(tmp.path(), &home);
358
359 assert_eq!(report.shadowed.len(), 1);
360 assert!(
361 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
362 );
363 }
364
365 #[test]
366 fn global_claude_root_detects_collision() {
367 let tmp = TempDir::new().unwrap();
368 let home = tmp.path().join("home");
369 write_skill(&home.join(".claude/skills/live-copy"), "target-skill");
370
371 let report = detect(tmp.path(), &home);
372
373 assert_eq!(report.shadowed.len(), 1);
374 assert!(
375 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
376 );
377 }
378
379 #[test]
380 fn global_agents_root_detects_collision() {
381 let tmp = TempDir::new().unwrap();
382 let home = tmp.path().join("home");
383 write_skill(&home.join(".agents/skills/live-copy"), "target-skill");
384
385 let report = detect(tmp.path(), &home);
386
387 assert_eq!(report.shadowed.len(), 1);
388 assert!(
389 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
390 );
391 }
392
393 #[test]
394 fn legacy_home_opencode_root_detects_collision() {
395 let tmp = TempDir::new().unwrap();
396 let home = tmp.path().join("home");
397 write_skill(&home.join(".opencode/skills/live-copy"), "target-skill");
398
399 let report = detect(tmp.path(), &home);
400
401 assert_eq!(report.shadowed.len(), 1);
402 assert!(
403 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("live-copy"))
404 );
405 }
406
407 #[test]
408 fn ancestor_walk_is_capped_at_the_git_worktree() {
409 let tmp = TempDir::new().unwrap();
410 let (repo, scan_root) = repo_with_env(tmp.path());
411 write_skill(
413 &tmp.path().join(".claude/skills/above-worktree"),
414 "target-skill",
415 );
416 write_skill(&repo.join(".claude/skills/inside-worktree"), "target-skill");
417
418 let report = detect(&scan_root, &tmp.path().join("home"));
419
420 assert_eq!(report.shadowed.len(), 1);
421 assert!(
422 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("inside-worktree"))
423 );
424 }
425
426 #[test]
427 fn staged_env_root_itself_is_not_scanned() {
428 let tmp = TempDir::new().unwrap();
429 let (_repo, scan_root) = repo_with_env(tmp.path());
430 write_skill(
432 &scan_root.join(".opencode/skills/staged-copy"),
433 "target-skill",
434 );
435
436 let report = detect(&scan_root, &tmp.path().join("home"));
437
438 assert!(report.shadowed.is_empty());
439 }
440
441 #[test]
442 fn opencode_config_dir_is_scanned_additively() {
443 let tmp = TempDir::new().unwrap();
444 let home = tmp.path().join("home");
445 let override_dir = tmp.path().join("opencode-config");
446 write_skill(
447 &home.join(".config/opencode/skills/default-copy"),
448 "target-skill",
449 );
450 write_skill(&override_dir.join("skills/override-copy"), "target-skill");
451
452 let report = detect_with_sources(
453 tmp.path(),
454 &["target-skill"],
455 &home,
456 None,
457 Some(&override_dir),
458 );
459
460 assert_eq!(report.shadowed.len(), 2);
461 }
462
463 #[test]
464 fn xdg_config_home_redirects_the_default_global_dir() {
465 let tmp = TempDir::new().unwrap();
466 let home = tmp.path().join("home");
467 let xdg = tmp.path().join("xdg");
468 write_skill(&xdg.join("opencode/skills/xdg-copy"), "target-skill");
469 write_skill(
471 &home.join(".config/opencode/skills/non-xdg-copy"),
472 "target-skill",
473 );
474
475 let report = detect_with_sources(tmp.path(), &["target-skill"], &home, Some(&xdg), None);
476
477 assert_eq!(report.shadowed.len(), 1);
478 assert!(
479 matches!(&report.shadowed[0], ShadowSource::GlobalSkill { path, .. } if path.ends_with("xdg-copy"))
480 );
481 }
482
483 #[test]
484 fn repeated_roots_are_reported_once() {
485 let tmp = TempDir::new().unwrap();
486 let home = tmp.path().join("home");
487 let default = home.join(".config/opencode");
488 write_skill(&default.join("skills/live-copy"), "target-skill");
489
490 let report =
492 detect_with_sources(tmp.path(), &["target-skill"], &home, None, Some(&default));
493
494 assert_eq!(report.shadowed.len(), 1);
495 }
496
497 #[test]
498 fn direct_scan_uses_frontmatter_name_not_folder_name() {
499 let tmp = TempDir::new().unwrap();
500 let home = tmp.path().join("home");
501 write_skill(
502 &home.join(".agents/skills/different-folder"),
503 "target-skill",
504 );
505
506 let report = detect(tmp.path(), &home);
507
508 assert_eq!(report.shadowed.len(), 1);
509 assert_eq!(report.shadowed[0].skill_name(), "target-skill");
510 }
511
512 #[test]
513 fn malformed_skills_do_not_create_false_reports() {
514 let tmp = TempDir::new().unwrap();
515 let home = tmp.path().join("home");
516 let no_frontmatter = home.join(".agents/skills/plain");
517 fs::create_dir_all(&no_frontmatter).unwrap();
518 fs::write(no_frontmatter.join("SKILL.md"), "name: target-skill\n").unwrap();
519 let unclosed = home.join(".agents/skills/unclosed");
520 fs::create_dir_all(&unclosed).unwrap();
521 fs::write(unclosed.join("SKILL.md"), "---\nname: target-skill\n").unwrap();
522
523 let report = detect(tmp.path(), &home);
524
525 assert!(report.shadowed.is_empty());
526 }
527
528 #[test]
529 fn non_staged_skill_names_are_not_reported() {
530 let tmp = TempDir::new().unwrap();
531 let home = tmp.path().join("home");
532 write_skill(&home.join(".agents/skills/live-copy"), "some-other-skill");
533
534 let report = detect(tmp.path(), &home);
535
536 assert!(report.shadowed.is_empty());
537 }
538
539 fn sample_report() -> PluginShadowReport {
540 PluginShadowReport {
541 config_dir: "/home/u/.config/opencode".into(),
542 shadowed: vec![ShadowSource::GlobalSkill {
543 skill_name: "target-skill".into(),
544 path: "/home/u/.claude/skills/target-skill".into(),
545 }],
546 }
547 }
548
549 #[test]
550 fn banner_is_empty_when_nothing_shadowed() {
551 let empty = PluginShadowReport {
552 config_dir: "/x".into(),
553 shadowed: vec![],
554 };
555 assert_eq!(format_shadow_banner(&empty), "");
556 }
557
558 #[test]
559 fn banner_lists_findings_remediation_and_isolation_doc() {
560 let banner = format_shadow_banner(&sample_report());
561 assert!(banner.contains("target-skill"), "{banner}");
562 assert!(banner.contains(".claude/skills"), "{banner}");
563 assert!(banner.contains("opencode run"), "{banner}");
564 assert!(
565 banner.contains("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
566 "banner names the .claude-root kill switch: {banner}"
567 );
568 assert!(
569 banner.contains("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
570 "banner names the external-roots kill switch: {banner}"
571 );
572 assert!(banner.contains("docs/opencode-notes.md"), "{banner}");
573 }
574
575 #[test]
576 fn validity_warnings_name_skill_source_contamination_and_doc() {
577 let warnings = shadow_validity_warnings(&sample_report());
578 assert_eq!(warnings.len(), 1);
579 assert!(warnings[0].contains("target-skill"));
580 assert!(warnings[0].contains(".claude/skills"));
581 assert!(warnings[0].contains("opencode run"));
582 assert!(warnings[0].to_lowercase().contains("contaminat"));
583 assert!(warnings[0].contains("docs/opencode-notes.md"));
584 }
585}