pytest-skill-engineering 1.0.1__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.1 → pytest_skill_engineering-1.0.2}/PKG-INFO +1 -1
  2. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/pyproject.toml +1 -1
  3. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/plugin.py +1 -1
  4. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill.py +53 -18
  5. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/.gitignore +0 -0
  6. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/LICENSE +0 -0
  7. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/README.md +0 -0
  8. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/__init__.py +0 -0
  9. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/__init__.py +0 -0
  10. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/api.py +0 -0
  11. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/client.py +0 -0
  12. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/config.py +0 -0
  13. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/contracts.py +0 -0
  14. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/controls.py +0 -0
  15. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/eval.py +0 -0
  16. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/events.py +0 -0
  17. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/fixtures.py +0 -0
  18. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/personas.py +0 -0
  19. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/requests.py +0 -0
  20. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/result.py +0 -0
  21. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/runner.py +0 -0
  22. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/copilot/skills.py +0 -0
  23. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/__init__.py +0 -0
  24. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/errors.py +0 -0
  25. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/evals.py +0 -0
  26. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/prompt.py +0 -0
  27. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/result.py +0 -0
  28. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/serialization.py +0 -0
  29. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_evals.py +0 -0
  30. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/core/skill_grading.py +0 -0
  31. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/__init__.py +0 -0
  32. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/cost.py +0 -0
  33. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/rate_limiter.py +0 -0
  34. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/servers.py +0 -0
  35. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/execution/skill_tools.py +0 -0
  36. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/__init__.py +0 -0
  37. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/factories.py +0 -0
  38. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/fixtures/iteration.py +0 -0
  39. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/onboarding.py +0 -0
  40. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin.py +0 -0
  41. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_options.py +0 -0
  42. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/plugin_report.py +0 -0
  43. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/__init__.py +0 -0
  44. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/collector.py +0 -0
  45. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/generator.py +0 -0
  46. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/reporting/schema.py +0 -0
  47. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/__init__.py +0 -0
  48. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking.py +0 -0
  49. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/banking_mcp.py +0 -0
  50. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo.py +0 -0
  51. {pytest_skill_engineering-1.0.1 → pytest_skill_engineering-1.0.2}/src/pytest_skill_engineering/testing/todo_mcp.py +0 -0
  52. {pytest_skill_engineering-1.0.1 → 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.1
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.1"
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" }
@@ -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:
@@ -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