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