pytest-skill-engineering 1.0.0__tar.gz → 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/PKG-INFO +1 -1
  2. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/pyproject.toml +1 -1
  3. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/__init__.py +3 -0
  4. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/eval.py +17 -1
  5. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/fixtures.py +1 -0
  6. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/result.py +8 -1
  7. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/runner.py +29 -2
  8. pytest_skill_engineering-1.0.2/src/pytest_skill_engineering/copilot/skills.py +104 -0
  9. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/__init__.py +4 -0
  10. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/plugin.py +1 -1
  11. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/result.py +26 -0
  12. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/serialization.py +23 -1
  13. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill.py +53 -18
  14. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/.gitignore +0 -0
  15. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/LICENSE +0 -0
  16. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/README.md +0 -0
  17. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/__init__.py +0 -0
  18. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/api.py +0 -0
  19. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/client.py +0 -0
  20. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/config.py +0 -0
  21. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/contracts.py +0 -0
  22. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/controls.py +0 -0
  23. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/events.py +0 -0
  24. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/personas.py +0 -0
  25. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/requests.py +0 -0
  26. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/errors.py +0 -0
  27. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/evals.py +0 -0
  28. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/prompt.py +0 -0
  29. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_evals.py +0 -0
  30. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_grading.py +0 -0
  31. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/__init__.py +0 -0
  32. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/cost.py +0 -0
  33. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/rate_limiter.py +0 -0
  34. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/servers.py +0 -0
  35. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/skill_tools.py +0 -0
  36. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/__init__.py +0 -0
  37. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/factories.py +0 -0
  38. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/iteration.py +0 -0
  39. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/onboarding.py +0 -0
  40. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin.py +0 -0
  41. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_options.py +0 -0
  42. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_report.py +0 -0
  43. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/__init__.py +0 -0
  44. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/collector.py +0 -0
  45. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/generator.py +0 -0
  46. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/schema.py +0 -0
  47. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/__init__.py +0 -0
  48. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking.py +0 -0
  49. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking_mcp.py +0 -0
  50. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo.py +0 -0
  51. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo_mcp.py +0 -0
  52. {pytest_skill_engineering-1.0.0 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pytest-skill-engineering
3
- Version: 1.0.0
3
+ Version: 1.0.2
4
4
  Summary: Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents.
5
5
  Project-URL: Homepage, https://github.com/sbroenne/pytest-skill-engineering
6
6
  Project-URL: Repository, https://github.com/sbroenne/pytest-skill-engineering
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pytest-skill-engineering"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "Reliable Copilot execution and evidence for coding-agent-led testing of MCP servers, CLI tools, skills, and custom agents."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -22,11 +22,14 @@ from pytest_skill_engineering.copilot.personas import (
22
22
  from pytest_skill_engineering.copilot.requests import RequestAudit
23
23
  from pytest_skill_engineering.copilot.result import CopilotResult, UsageInfo
24
24
  from pytest_skill_engineering.core.evals import load_custom_agent, load_custom_agents
25
+ from pytest_skill_engineering.core.result import DiscoveredSkill, SkillDiscovery
25
26
 
26
27
  __all__ = [
27
28
  "CopilotEval",
28
29
  "CopilotResult",
30
+ "DiscoveredSkill",
29
31
  "RequestAudit",
32
+ "SkillDiscovery",
30
33
  "UsageInfo",
31
34
  "ClaudeCodePersona",
32
35
  "CopilotCLIPersona",
@@ -139,7 +139,7 @@ class CopilotEval:
139
139
  # display_name, tools, mcp_servers, infer)
140
140
  custom_agents: list[CopilotCustomAgentConfig] = field(default_factory=list)
141
141
 
142
- # Skill directories
142
+ # Explicit skills are enabled and checked before model execution.
143
143
  skill_directories: list[str] = field(default_factory=list)
144
144
  disabled_skills: list[str] = field(default_factory=list)
145
145
 
@@ -235,8 +235,24 @@ class CopilotEval:
235
235
  config["hooks"] = self.hooks
236
236
 
237
237
  # Apply extra_config passthrough
238
+ if (
239
+ self.skill_directories
240
+ and "skill_directories" in self.extra_config
241
+ and self.extra_config["skill_directories"] != self.skill_directories
242
+ ):
243
+ raise ValueError("extra_config must not override explicitly supplied skill_directories")
238
244
  config.update(self.extra_config)
239
245
 
246
+ if config.get("skill_directories"):
247
+ if config.get("enable_skills") is False:
248
+ raise ValueError(
249
+ "Explicit skill_directories conflict with enable_skills=False; "
250
+ "remove the directories for a no-skill baseline, or enable skills"
251
+ )
252
+ if config.get("enable_skills") is not None and config["enable_skills"] is not True:
253
+ raise ValueError("enable_skills must be a boolean or None")
254
+ config["enable_skills"] = True
255
+
240
256
  if self.client_mode == "empty":
241
257
  isolated = {
242
258
  "enable_config_discovery": False,
@@ -116,6 +116,7 @@ def _convert_to_aitest(
116
116
  stop_reason=result.stop_reason,
117
117
  usage=list(result.usage),
118
118
  tool_calls_admitted=result.tool_calls_admitted,
119
+ skill_discovery=deepcopy(result.skill_discovery),
119
120
  configuration=deepcopy(
120
121
  {
121
122
  "name": agent.name,
@@ -12,7 +12,13 @@ from typing import Any
12
12
 
13
13
  from pytest_skill_engineering.copilot.contracts import CopilotResultAgent, SubagentInvocation
14
14
  from pytest_skill_engineering.copilot.requests import RequestAudit
15
- from pytest_skill_engineering.core.result import StopReason, ToolCall, Turn, UsageInfo
15
+ from pytest_skill_engineering.core.result import (
16
+ SkillDiscovery,
17
+ StopReason,
18
+ ToolCall,
19
+ Turn,
20
+ UsageInfo,
21
+ )
16
22
 
17
23
  __all__ = [
18
24
  "CopilotResult",
@@ -68,6 +74,7 @@ class CopilotResult:
68
74
  request_audit: list[RequestAudit] = field(default_factory=list)
69
75
  stop_reason: StopReason | None = None
70
76
  tool_calls_admitted: int = 0
77
+ skill_discovery: SkillDiscovery | None = None
71
78
 
72
79
  @property
73
80
  def evidence_complete(self) -> bool:
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import asyncio
16
16
  import logging
17
+ from pathlib import Path
17
18
  from tempfile import TemporaryDirectory
18
19
  from typing import TYPE_CHECKING
19
20
 
@@ -27,6 +28,8 @@ from pytest_skill_engineering.copilot.controls import RunControls
27
28
  from pytest_skill_engineering.copilot.events import EventMapper
28
29
  from pytest_skill_engineering.copilot.requests import RequestAuditHandler
29
30
  from pytest_skill_engineering.copilot.result import CopilotResult, StopReason
31
+ from pytest_skill_engineering.copilot.skills import discover_requested_skills, requested_skill_files
32
+ from pytest_skill_engineering.core.result import SkillDiscovery
30
33
 
31
34
  if TYPE_CHECKING:
32
35
  from copilot.client import CopilotClient
@@ -80,8 +83,20 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
80
83
  reason: StopReason = "completed"
81
84
  error: str | None = None
82
85
  cleanup_errors: list[str] = []
86
+ skill_discovery: SkillDiscovery | None = None
87
+ send_started = False
83
88
  try:
84
89
  session_config = agent.build_session_config()
90
+ skill_directories = session_config.get("skill_directories") or []
91
+ requested_skills: dict[Path, str] = {}
92
+ if skill_directories:
93
+ skill_discovery = SkillDiscovery()
94
+ if not isinstance(skill_directories, list):
95
+ raise ValueError("skill_directories must be a list of directory paths")
96
+ requested_skills = requested_skill_files(skill_directories)
97
+ session_config["skill_directories"] = [
98
+ str(Path(directory).resolve()) for directory in skill_directories
99
+ ]
85
100
  caller_hooks = dict(session_config.get("hooks") or {})
86
101
  session_config["hooks"] = dict(caller_hooks)
87
102
  # Empty mode must not read persona instruction files or inject tools.
@@ -123,12 +138,20 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
123
138
  )
124
139
 
125
140
  async def execute() -> None:
126
- nonlocal session
141
+ nonlocal session, send_started
127
142
  assert client is not None
128
143
  async with asyncio.timeout(min(60, agent.timeout_s)):
129
144
  await client.start()
130
145
  async with asyncio.timeout(min(30, agent.timeout_s)):
131
146
  session = await client.create_session(**session_config)
147
+ if skill_discovery is not None:
148
+ await discover_requested_skills(
149
+ session,
150
+ requested_skills,
151
+ session_config.get("disabled_skills") or [],
152
+ skill_discovery,
153
+ )
154
+ send_started = True
132
155
  await session.send_and_wait(prompt, timeout=agent.timeout_s)
133
156
 
134
157
  execution = asyncio.create_task(execute())
@@ -158,6 +181,9 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
158
181
  except Exception as exc:
159
182
  logger.error("Copilot execution failed: %s", exc)
160
183
  reason, error = "execution_error", str(exc)
184
+ if skill_discovery is not None and not skill_discovery.complete:
185
+ error = f"Skill setup failed before model execution: {exc}"
186
+ skill_discovery.errors.append(error)
161
187
  finally:
162
188
  controls.closed = True
163
189
  for watcher in watchers:
@@ -210,12 +236,13 @@ async def run_copilot(agent: CopilotEvalConfig, prompt: str) -> CopilotResult:
210
236
 
211
237
  result = mapper.build()
212
238
  result.agent = agent
239
+ result.skill_discovery = skill_discovery
213
240
  result.tool_calls_admitted = controls.admitted
214
241
  if result.capture_errors:
215
242
  result.capture_errors.extend(controls.incomplete_diagnostics(abort_phase=reason))
216
243
  if audit is not None and capture_requests:
217
244
  result.request_audit = list(audit.records)
218
- if audit.error or (session is not None and not audit.observed_requests):
245
+ if audit.error or (send_started and not audit.observed_requests):
219
246
  audit_error = audit.error or "No outbound model requests captured; audit unsupported"
220
247
  result.capture_errors.append(audit_error)
221
248
  if reason == "completed" or audit.error:
@@ -0,0 +1,104 @@
1
+ """Validate explicit skill inputs and verify native SDK availability before sending."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING
8
+
9
+ from pytest_skill_engineering.core.result import DiscoveredSkill, SkillDiscovery
10
+ from pytest_skill_engineering.core.skill import Skill, SkillError
11
+
12
+ if TYPE_CHECKING:
13
+ from copilot.session import CopilotSession
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def requested_skill_files(directories: list[str]) -> dict[Path, str]:
19
+ """Validate individual skill directories or parents of immediate skill directories.
20
+
21
+ Relative directories are resolved from the Python caller's current directory,
22
+ then passed to the SDK as absolute paths.
23
+ """
24
+ requested: dict[Path, str] = {}
25
+ names: dict[str, Path] = {}
26
+ for raw_directory in directories:
27
+ if not isinstance(raw_directory, str) or not raw_directory.strip():
28
+ raise SkillError("skill_directories entries must be non-empty directory paths")
29
+ directory = Path(raw_directory).resolve()
30
+ if not directory.is_dir():
31
+ raise SkillError(
32
+ f"Requested skill directory does not exist or is not a directory: {directory}"
33
+ )
34
+ try:
35
+ candidates = (
36
+ [directory]
37
+ if (directory / "SKILL.md").exists()
38
+ else [
39
+ child
40
+ for child in sorted(directory.iterdir())
41
+ if child.is_dir() and (child / "SKILL.md").exists()
42
+ ]
43
+ )
44
+ if not candidates:
45
+ raise SkillError(f"No SKILL.md found in requested skill directory: {directory}")
46
+ for candidate in candidates:
47
+ skill = Skill.from_path(candidate)
48
+ path = (skill.path / "SKILL.md").resolve()
49
+ if skill.name in names and names[skill.name] != path:
50
+ raise SkillError(
51
+ f"Duplicate requested skill name '{skill.name}': "
52
+ f"{names[skill.name]} and {path}"
53
+ )
54
+ names[skill.name] = path
55
+ requested[path] = skill.name
56
+ except OSError as exc:
57
+ raise SkillError(f"Cannot read requested skill directory {directory}: {exc}") from exc
58
+ return requested
59
+
60
+
61
+ async def discover_requested_skills(
62
+ session: CopilotSession,
63
+ requested: dict[Path, str],
64
+ disabled: list[str],
65
+ evidence: SkillDiscovery,
66
+ ) -> None:
67
+ """Record SDK discovery and reject missing or unexpectedly disabled requested skills."""
68
+ await session.rpc.skills.ensure_loaded()
69
+ diagnostics = await session.rpc.skills.reload()
70
+ evidence.warnings = list(diagnostics.warnings)
71
+ evidence.errors = list(diagnostics.errors)
72
+ for warning in evidence.warnings:
73
+ logger.warning("SDK skill discovery: %s", warning)
74
+ listed = await session.rpc.skills.list()
75
+ evidence.skills = [
76
+ DiscoveredSkill(
77
+ name=skill.name,
78
+ description=skill.description,
79
+ source=skill.source.value,
80
+ enabled=skill.enabled,
81
+ user_invocable=skill.user_invocable,
82
+ path=skill.path,
83
+ plugin_name=skill.plugin_name,
84
+ command_name=skill.command_name,
85
+ argument_hint=skill.argument_hint,
86
+ )
87
+ for skill in listed.skills
88
+ ]
89
+ evidence.complete = True
90
+ available = {
91
+ (Path(skill.path).resolve(), skill.name): skill
92
+ for skill in evidence.skills
93
+ if skill.path is not None
94
+ }
95
+ for path, name in requested.items():
96
+ skill = available.get((path, name))
97
+ if skill is None:
98
+ evidence.errors.append(
99
+ f"Requested skill '{name}' was not discovered by the SDK: {path}"
100
+ )
101
+ elif not skill.enabled and name not in disabled:
102
+ evidence.errors.append(f"Requested skill '{name}' is unexpectedly disabled: {path}")
103
+ if evidence.errors:
104
+ raise SkillError("Skill setup failed before model execution: " + "; ".join(evidence.errors))
@@ -17,10 +17,12 @@ from pytest_skill_engineering.core.prompt import (
17
17
  load_system_prompts,
18
18
  )
19
19
  from pytest_skill_engineering.core.result import (
20
+ DiscoveredSkill,
20
21
  EvalResult,
21
22
  ImageContent,
22
23
  MCPPrompt,
23
24
  MCPPromptArgument,
25
+ SkillDiscovery,
24
26
  SkillInfo,
25
27
  SubagentInvocation,
26
28
  ToolCall,
@@ -38,6 +40,7 @@ from pytest_skill_engineering.core.skill_grading import export_grading
38
40
  __all__ = [
39
41
  "AITestError",
40
42
  "EvalResult",
43
+ "DiscoveredSkill",
41
44
  "EngineTimeoutError",
42
45
  "HookDefinition",
43
46
  "ImageContent",
@@ -48,6 +51,7 @@ __all__ = [
48
51
  "Prompt",
49
52
  "ServerStartError",
50
53
  "Skill",
54
+ "SkillDiscovery",
51
55
  "SkillError",
52
56
  "SkillInfo",
53
57
  "SkillMetadata",
@@ -341,7 +341,7 @@ def _discover_skills(plugin_dir: Path) -> list[Skill]:
341
341
 
342
342
 
343
343
  def _validate_skill_reference_names(skills: list[Skill]) -> None:
344
- """Reject duplicate reference basenames across loaded skills."""
344
+ """Reject duplicate relative reference paths across loaded skills."""
345
345
  owners: dict[str, str] = {}
346
346
  for skill in skills:
347
347
  for reference_name in skill.references:
@@ -181,6 +181,31 @@ class SkillInfo:
181
181
  return f"SkillInfo({self.name}{refs})"
182
182
 
183
183
 
184
+ @dataclass(slots=True, frozen=True)
185
+ class DiscoveredSkill:
186
+ """Skill metadata actually returned by the public SDK, not observed use."""
187
+
188
+ name: str
189
+ description: str
190
+ source: str
191
+ enabled: bool
192
+ user_invocable: bool
193
+ path: str | None = None
194
+ plugin_name: str | None = None
195
+ command_name: str | None = None
196
+ argument_hint: str | None = None
197
+
198
+
199
+ @dataclass(slots=True)
200
+ class SkillDiscovery:
201
+ """Pre-execution discovery evidence, including incomplete or failed checks."""
202
+
203
+ skills: list[DiscoveredSkill] = field(default_factory=list)
204
+ warnings: list[str] = field(default_factory=list)
205
+ errors: list[str] = field(default_factory=list)
206
+ complete: bool = False
207
+
208
+
184
209
  @dataclass(slots=True)
185
210
  class ClarificationStats:
186
211
  """Statistics about clarification requests detected during execution.
@@ -280,6 +305,7 @@ class EvalResult:
280
305
  # Recorded interface metadata.
281
306
  available_tools: list[ToolInfo] = field(default_factory=list)
282
307
  skill_info: SkillInfo | None = None
308
+ skill_discovery: SkillDiscovery | None = None
283
309
  effective_system_prompt: str = ""
284
310
  mcp_prompts: list[MCPPrompt] = field(default_factory=list)
285
311
  prompt_name: str | None = None
@@ -56,7 +56,14 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
56
56
  Reconstructs the full dataclass hierarchy from the serialized format.
57
57
  """
58
58
  from pytest_skill_engineering.copilot.requests import RequestAudit
59
- from pytest_skill_engineering.core.result import EvalResult, ToolCall, Turn, UsageInfo
59
+ from pytest_skill_engineering.core.result import (
60
+ DiscoveredSkill,
61
+ EvalResult,
62
+ SkillDiscovery,
63
+ ToolCall,
64
+ Turn,
65
+ UsageInfo,
66
+ )
60
67
  from pytest_skill_engineering.reporting.collector import SuiteReport, TestReport
61
68
 
62
69
  # Reconstruct tests
@@ -214,6 +221,20 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
214
221
  )
215
222
 
216
223
  # Reconstruct agent result
224
+ discovery_data = _require_key(ar_data, "skill_discovery", context="EvalResult")
225
+ skill_discovery = None
226
+ if discovery_data is not None:
227
+ skill_discovery = SkillDiscovery(
228
+ skills=[
229
+ DiscoveredSkill(**skill)
230
+ for skill in _require_key(
231
+ discovery_data, "skills", context="SkillDiscovery"
232
+ )
233
+ ],
234
+ warnings=_require_key(discovery_data, "warnings", context="SkillDiscovery"),
235
+ errors=_require_key(discovery_data, "errors", context="SkillDiscovery"),
236
+ complete=_require_key(discovery_data, "complete", context="SkillDiscovery"),
237
+ )
217
238
  eval_result = EvalResult(
218
239
  turns=turns,
219
240
  success=_require_key(ar_data, "success", context="EvalResult"),
@@ -228,6 +249,7 @@ def deserialize_suite_report(data: dict[str, Any]) -> SuiteReport:
228
249
  assertions=assertions,
229
250
  available_tools=available_tools,
230
251
  skill_info=skill_info,
252
+ skill_discovery=skill_discovery,
231
253
  effective_system_prompt=_require_key(
232
254
  ar_data, "effective_system_prompt", context="EvalResult"
233
255
  ),
@@ -28,6 +28,7 @@ Example SKILL.md:
28
28
  from __future__ import annotations
29
29
 
30
30
  import re
31
+ import stat
31
32
  from dataclasses import dataclass, field
32
33
  from pathlib import Path
33
34
 
@@ -163,7 +164,8 @@ class Skill:
163
164
  path: Path to skill directory or SKILL.md file
164
165
 
165
166
  Returns:
166
- Loaded Skill instance
167
+ Loaded Skill instance. Reference keys are slash-separated paths
168
+ relative to references/, including any nested subdirectories.
167
169
 
168
170
  Raises:
169
171
  SkillError: If skill cannot be loaded or is invalid
@@ -197,7 +199,7 @@ class Skill:
197
199
 
198
200
  for name in ("references", "scripts", "assets"):
199
201
  component = skill_dir / name
200
- if component.exists() and not component.is_dir():
202
+ if (component.exists() or component.is_symlink()) and not component.is_dir():
201
203
  raise SkillError(f"{component}: skill component must be a directory")
202
204
 
203
205
  # Load references if directory exists
@@ -320,27 +322,59 @@ def _parse_skill_md(content: str) -> tuple[SkillMetadata, str]:
320
322
 
321
323
 
322
324
  def _load_references(refs_dir: Path) -> dict[str, str]:
323
- """Load all files from references/ directory.
325
+ """Load Markdown files recursively, without escaping references/ or following cycles.
324
326
 
325
327
  Returns:
326
- Dict mapping filename to content
328
+ Dict mapping slash-separated paths relative to references/ to content
327
329
  """
330
+ root = _resolve_reference_path(refs_dir, refs_dir.parent.resolve())
328
331
  references: dict[str, str] = {}
329
-
330
- for file_path in refs_dir.iterdir():
331
- if not file_path.is_file():
332
- raise SkillError(f"Invalid references entry (must be a file): {file_path}")
333
- if file_path.suffix.lower() != ".md":
334
- raise SkillError(f"Invalid reference file '{file_path}': only .md files are allowed")
332
+ pending = [(refs_dir, frozenset({root}))]
333
+ while pending:
334
+ directory, ancestors = pending.pop()
335
335
  try:
336
- content = file_path.read_text(encoding="utf-8")
337
- except UnicodeDecodeError as exc:
338
- raise SkillError(f"Reference file must be valid UTF-8 text: {file_path}") from exc
339
- if not content.strip():
340
- raise SkillError(f"Reference file must not be empty: {file_path}")
341
- references[file_path.name] = content
336
+ entries = sorted(directory.iterdir())
337
+ except OSError as exc:
338
+ raise SkillError(f"Cannot read references directory {directory}: {exc}") from exc
339
+ for file_path in entries:
340
+ resolved = _resolve_reference_path(file_path, root)
341
+ try:
342
+ mode = resolved.stat().st_mode
343
+ except OSError as exc:
344
+ raise SkillError(f"Cannot inspect reference entry {file_path}: {exc}") from exc
345
+ if stat.S_ISDIR(mode):
346
+ if resolved in ancestors:
347
+ raise SkillError(f"Reference directory cycle: {file_path}")
348
+ pending.append((file_path, ancestors | {resolved}))
349
+ continue
350
+ if not stat.S_ISREG(mode):
351
+ raise SkillError(f"Invalid references entry (must be a file): {file_path}")
352
+ if file_path.suffix.lower() != ".md":
353
+ raise SkillError(
354
+ f"Invalid reference file '{file_path}': only .md files are allowed"
355
+ )
356
+ try:
357
+ content = resolved.read_text(encoding="utf-8")
358
+ except UnicodeDecodeError as exc:
359
+ raise SkillError(f"Reference file must be valid UTF-8 text: {file_path}") from exc
360
+ except OSError as exc:
361
+ raise SkillError(f"Cannot read reference file {file_path}: {exc}") from exc
362
+ if not content.strip():
363
+ raise SkillError(f"Reference file must not be empty: {file_path}")
364
+ references[file_path.relative_to(refs_dir).as_posix()] = content
365
+
366
+ return dict(sorted(references.items()))
342
367
 
343
- return references
368
+
369
+ def _resolve_reference_path(path: Path, root: Path) -> Path:
370
+ """Resolve links before traversal or reads, keeping their targets within the root."""
371
+ try:
372
+ resolved = path.resolve(strict=True)
373
+ except (OSError, RuntimeError) as exc:
374
+ raise SkillError(f"Cannot resolve reference path {path}: {exc}") from exc
375
+ if not resolved.is_relative_to(root):
376
+ raise SkillError(f"Reference path escapes its root {root}: {path}")
377
+ return resolved
344
378
 
345
379
 
346
380
  _SCRIPT_EXTENSIONS = frozenset({".py", ".sh", ".js", ".bash"})
@@ -375,7 +409,8 @@ def _load_scripts(scripts_dir: Path) -> dict[str, str]:
375
409
  def load_skill(path: Path | str) -> Skill:
376
410
  """Load a skill from a path.
377
411
 
378
- Convenience function wrapping Skill.from_path().
412
+ Convenience function wrapping Skill.from_path(). This is the runner's local
413
+ skill validation, without SDK startup or model execution.
379
414
 
380
415
  Args:
381
416
  path: Path to skill directory or SKILL.md file