feat: Grand Unification — one command, one interface, direct converters (#346)

* fix: resolve 8 pipeline bugs found during skill quality review

- Fix 0 APIs extracted from documentation by enriching summary.json
  with individual page file content before conflict detection
- Fix all "Unknown" entries in merged_api.md by injecting dict keys
  as API names and falling back to AI merger field names
- Fix frontmatter using raw slugs instead of config name by
  normalizing frontmatter after SKILL.md generation
- Fix leaked absolute filesystem paths in patterns/index.md by
  stripping .skillseeker-cache repo clone prefixes
- Fix ARCHITECTURE.md file count always showing "1 files" by
  counting files per language from code_analysis data
- Fix YAML parse errors on GitHub Actions workflows by converting
  boolean keys (on: true) to strings
- Fix false React/Vue.js framework detection in C# projects by
  filtering web frameworks based on primary language
- Improve how-to guide generation by broadening workflow example
  filter to include setup/config examples with sufficient complexity
- Fix test_git_sources_e2e failures caused by git init default
  branch being 'main' instead of 'master'

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address 6 review issues in ExecutionContext implementation

Fixes from code review:

1. Mode resolution (#3 critical): _args_to_data no longer unconditionally
   overwrites mode. Only writes mode="api" when --api-key explicitly passed.
   Env-var-based mode detection moved to _default_data() as lowest priority.

2. Re-initialization warning (#4): initialize() now logs debug message
   when called a second time instead of silently returning stale instance.

3. _raw_args preserved in override (#5): temp context now copies _raw_args
   from parent so get_raw() works correctly inside override blocks.

4. test_local_mode_detection env cleanup (#7): test now saves/restores
   API key env vars to prevent failures when ANTHROPIC_API_KEY is set.

5. _load_config_file error handling (#8): wraps FileNotFoundError and
   JSONDecodeError with user-friendly ValueError messages.

6. Lint fixes: added logging import, fixed Generator import from
   collections.abc, fixed AgentClient return type annotation.

Remaining P2/P3 items (documented, not blocking):
- Lock TOCTOU in override() — safe on CPython, needs fix for no-GIL
- get() reads _instance without lock — same CPython caveat
- config_path not stored on instance
- AnalysisSettings.depth not Literal constrained

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address all remaining P2/P3 review issues in ExecutionContext

1. Thread safety: get() now acquires _lock before reading _instance (#2)
2. Thread safety: override() saves/restores _initialized flag to prevent
   re-init during override blocks (#10)
3. Config path stored: _config_path PrivateAttr + config_path property (#6)
4. Literal validation: AnalysisSettings.depth now uses
   Literal["surface", "deep", "full"] — rejects invalid values (#9)
5. Test updated: test_analysis_depth_choices now expects ValidationError
   for invalid depth, added test_analysis_depth_valid_choices
6. Lint cleanup: removed unused imports, fixed whitespace in tests

All 10 previously reported issues now resolved.
26 tests pass, lint clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore 5 truncated scrapers, migrate unified_scraper, fix context init

5 scrapers had main() truncated with "# Original main continues here..."
after Kimi's migration — business logic was never connected:
- html_scraper.py — restored HtmlToSkillConverter extraction + build
- pptx_scraper.py — restored PptxToSkillConverter extraction + build
- confluence_scraper.py — restored ConfluenceToSkillConverter with 3 modes
- notion_scraper.py — restored NotionToSkillConverter with 4 sources
- chat_scraper.py — restored ChatToSkillConverter extraction + build

unified_scraper.py — migrated main() to context-first pattern with argv fallback

Fixed context initialization chain:
- main.py no longer initializes ExecutionContext (was stealing init from commands)
- create_command.py now passes config_path from source_info.parsed
- execution_context.py handles SourceInfo.raw_input (not raw_source)

All 18 scrapers now genuinely migrated. 26 tests pass, lint clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve 7 data flow conflicts between ExecutionContext and legacy paths

Critical fixes (CLI args silently lost):
- unified_scraper Phase 6: reads ctx.enhancement.level instead of raw JSON
  when args=None (#3, #4)
- unified_scraper Phase 6 agent: reads ctx.enhancement.agent instead of
  3 independent env var lookups (#5)
- doc_scraper._run_enhancement: uses agent_client.api_key instead of raw
  os.environ.get() — respects config file api_key (#1)

Important fixes:
- main._handle_analyze_command: populates _fake_args from ExecutionContext
  so --agent and --api-key aren't lost in analyze→enhance path (#6)
- doc_scraper type annotations: replaced forward refs with Any to avoid
  F821 undefined name errors

All changes include RuntimeError fallback for backward compatibility when
ExecutionContext isn't initialized.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 3 crashes + 1 stub in migrated scrapers found by deep scan

1. github_scraper.py: args.scrape_only and args.enhance_level crash when
   args=None (context path). Guarded with if args and getattr(). Also
   fixed agent fallback to read ctx.enhancement.agent.

2. codebase_scraper.py: args.output and args.skip_api_reference crash in
   summary block when args=None. Replaced with output_dir local var and
   ctx.analysis.skip_api_reference.

3. epub_scraper.py: main() was still a stub ending with "# Rest of main()
   continues..." — restored full extraction + build + enhancement logic
   using ctx values exclusively.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: complete ExecutionContext migration for remaining scrapers

Kimi's Phase 4 scraper migrations + Claude's review fixes.
All 18 scrapers now use context-first pattern with argv fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Phase 1 — ExecutionContext.get() always returns context (no RuntimeError)

get() now returns a default context instead of raising RuntimeError when
not explicitly initialized. This eliminates the need for try/except
RuntimeError blocks in all 18 scrapers.

Components can always call ExecutionContext.get() safely — it returns
defaults if not initialized, or the explicitly initialized instance.

Updated tests: test_get_returns_defaults_when_not_initialized,
test_reset_clears_instance (no longer expects RuntimeError).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Phase 2a-c — remove 16 individual scraper CLI commands

Removed individual scraper commands from:
- COMMAND_MODULES in main.py (16 entries: scrape, github, pdf, word,
  epub, video, jupyter, html, openapi, asciidoc, pptx, rss, manpage,
  confluence, notion, chat)
- pyproject.toml entry points (16 skill-seekers-<type> binaries)
- parsers/__init__.py (16 parser registrations)

All source types now accessed via: skill-seekers create <source>
Kept: create, unified, analyze, enhance, package, upload, install,
      install-agent, config, doctor, and utility commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: create SkillConverter base class + converter registry

New base interface that all 17 converters will inherit:
- SkillConverter.run() — extract + build (same call for all types)
- SkillConverter.extract() — override in subclass
- SkillConverter.build_skill() — override in subclass
- get_converter(source_type, config) — factory from registry
- CONVERTER_REGISTRY — maps source type → (module, class)

create_command will use get_converter() instead of _call_module().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Grand Unification — one command, one interface, direct converters

Complete the Grand Unification refactor: `skill-seekers create` is now
the single entry point for all 18 source types. Individual scraper CLI
commands (scrape, github, pdf, analyze, unified, etc.) are removed.

## Architecture changes

- **18 SkillConverter subclasses**: Every scraper now inherits SkillConverter
  with extract() + build_skill() + SOURCE_TYPE. Factory via get_converter().
- **create_command.py rewritten**: _build_config() constructs config dicts
  from ExecutionContext for each source type. Direct converter.run() calls
  replace the old _build_argv() + sys.argv swap + _call_module() machinery.
- **main.py simplified**: create command bypasses _reconstruct_argv entirely,
  calls CreateCommand(args).execute() directly. analyze/unified commands
  removed (create handles both via auto-detection).
- **CreateParser mode="all"**: Top-level parser now accepts all 120+ flags
  (--browser, --max-pages, --depth, etc.) since create is the only entry.
- **Centralized enhancement**: Runs once in create_command after converter,
  not duplicated in each scraper.
- **MCP tools use converters**: 5 scraping tools call get_converter()
  directly instead of subprocess. Config type auto-detected from keys.
- **ConfigValidator → UniSkillConfigValidator**: Renamed with backward-
  compat alias.
- **Data flow**: AgentClient + LocalSkillEnhancer read ExecutionContext
  first, env vars as fallback.

## What was removed

- main() from all 18 scraper files (~3400 lines)
- 18 CLI commands from COMMAND_MODULES + pyproject.toml entry points
- analyze + unified parsers from parser registry
- _build_argv, _call_module, _SKIP_ARGS, _DEST_TO_FLAG, all _route_*()
- setup_argument_parser, get_configuration, _check_deprecated_flags
- Tests referencing removed commands/functions

## Net impact

51 files changed, ~6000 lines removed. 2996 tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review fixes for Grand Unification PR

- Add autouse conftest fixture to reset ExecutionContext singleton between tests
- Replace hardcoded defaults in _is_explicitly_set() with parser-derived defaults
- Upgrade ExecutionContext double-init log from debug to info
- Use logger.exception() in SkillConverter.run() to preserve tracebacks
- Fix docstring "17 types" → "18 types" in skill_converter.py
- DRY up 10 copy-paste help handlers into dict + loop (~100 lines removed)
- Fix 2 CI workflows still referencing removed `skill-seekers scrape` command
- Remove broken pyproject.toml entry point for codebase_scraper:main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve 12 logic/flow issues found in deep review

Critical fixes:
- UnifiedScraper.run(): replace sys.exit(1) with return 1, add return 0
- doc_scraper: use ExecutionContext.get() when already initialized instead
  of re-calling initialize() which silently discards new config
- unified_scraper: define enhancement_config before try/except to prevent
  UnboundLocalError in LOCAL enhancement timeout read

Important fixes:
- override(): cleaner tuple save/restore for singleton swap
- --agent without --api-key now sets mode="local" so env API key doesn't
  override explicit agent choice
- Remove DeprecationWarning from _reconstruct_argv (fires on every
  non-create command in production)
- Rewrite scrape_generic_tool to use get_converter() instead of subprocess
  calls to removed main() functions
- SkillConverter.run() checks build_skill() return value, returns 1 if False
- estimate_pages_tool uses -m module invocation instead of .py file path

Low-priority fixes:
- get_converter() raises descriptive ValueError on class name typo
- test_default_values: save/clear API key env vars before asserting mode
- test_get_converter_pdf: fix config key "path" → "pdf_path"

3056 passed, 4 failed (pre-existing dep version issues), 32 skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update MCP server tests to mock converter instead of subprocess

scrape_docs_tool now uses get_converter() + _run_converter() in-process
instead of run_subprocess_with_streaming. Update 4 TestScrapeDocsTool
tests to mock the converter layer instead of the removed subprocess path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: YusufKaraaslanSpyke <yusuf@spykegames.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yusyus
2026-04-05 23:00:52 +03:00
committed by GitHub
parent 2a14309342
commit 6d37e43b83
62 changed files with 2841 additions and 6840 deletions

View File

@@ -29,3 +29,17 @@ def pytest_configure(config): # noqa: ARG001
def anyio_backend():
"""Override anyio backend to only use asyncio (not trio)."""
return "asyncio"
@pytest.fixture(autouse=True)
def _reset_execution_context():
"""Reset the ExecutionContext singleton before and after every test.
Without this, a test that calls ExecutionContext.initialize() poisons
all subsequent tests in the same process.
"""
from skill_seekers.cli.execution_context import ExecutionContext
ExecutionContext.reset()
yield
ExecutionContext.reset()

View File

@@ -1,263 +0,0 @@
#!/usr/bin/env python3
"""Tests for analyze subcommand integration in main CLI."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from skill_seekers.cli.main import create_parser
class TestAnalyzeSubcommand(unittest.TestCase):
"""Test analyze subcommand registration and argument parsing."""
def setUp(self):
"""Create parser for testing."""
self.parser = create_parser()
def test_analyze_subcommand_exists(self):
"""Test that analyze subcommand is registered."""
args = self.parser.parse_args(["analyze", "--directory", "."])
self.assertEqual(args.command, "analyze")
self.assertEqual(args.directory, ".")
def test_analyze_with_output_directory(self):
"""Test analyze with custom output directory."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--output", "custom/"])
self.assertEqual(args.output, "custom/")
def test_quick_preset_flag(self):
"""Test --quick preset flag parsing."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--quick"])
self.assertTrue(args.quick)
self.assertFalse(args.comprehensive)
def test_comprehensive_preset_flag(self):
"""Test --comprehensive preset flag parsing."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--comprehensive"])
self.assertTrue(args.comprehensive)
self.assertFalse(args.quick)
def test_quick_and_comprehensive_mutually_exclusive(self):
"""Test that both flags can be parsed (mutual exclusion enforced at runtime)."""
# The parser allows both flags; runtime logic prevents simultaneous use
args = self.parser.parse_args(["analyze", "--directory", ".", "--quick", "--comprehensive"])
self.assertTrue(args.quick)
self.assertTrue(args.comprehensive)
# Note: Runtime will catch this and return error code 1
def test_enhance_level_flag(self):
"""Test --enhance-level flag parsing."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--enhance-level", "2"])
self.assertEqual(args.enhance_level, 2)
def test_skip_flags_passed_through(self):
"""Test that skip flags are recognized."""
args = self.parser.parse_args(
["analyze", "--directory", ".", "--skip-patterns", "--skip-test-examples"]
)
self.assertTrue(args.skip_patterns)
self.assertTrue(args.skip_test_examples)
def test_all_skip_flags(self):
"""Test all skip flags are properly parsed."""
args = self.parser.parse_args(
[
"analyze",
"--directory",
".",
"--skip-api-reference",
"--skip-dependency-graph",
"--skip-patterns",
"--skip-test-examples",
"--skip-how-to-guides",
"--skip-config-patterns",
"--skip-docs",
]
)
self.assertTrue(args.skip_api_reference)
self.assertTrue(args.skip_dependency_graph)
self.assertTrue(args.skip_patterns)
self.assertTrue(args.skip_test_examples)
self.assertTrue(args.skip_how_to_guides)
self.assertTrue(args.skip_config_patterns)
self.assertTrue(args.skip_docs)
def test_backward_compatible_depth_flag(self):
"""Test that deprecated --depth flag still works."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--depth", "full"])
self.assertEqual(args.depth, "full")
def test_depth_flag_choices(self):
"""Test that depth flag accepts correct values."""
for depth in ["surface", "deep", "full"]:
args = self.parser.parse_args(["analyze", "--directory", ".", "--depth", depth])
self.assertEqual(args.depth, depth)
def test_languages_flag(self):
"""Test languages flag parsing."""
args = self.parser.parse_args(
["analyze", "--directory", ".", "--languages", "Python,JavaScript"]
)
self.assertEqual(args.languages, "Python,JavaScript")
def test_file_patterns_flag(self):
"""Test file patterns flag parsing."""
args = self.parser.parse_args(
["analyze", "--directory", ".", "--file-patterns", "*.py,src/**/*.js"]
)
self.assertEqual(args.file_patterns, "*.py,src/**/*.js")
def test_no_comments_flag(self):
"""Test no-comments flag parsing."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--no-comments"])
self.assertTrue(args.no_comments)
def test_verbose_flag(self):
"""Test verbose flag parsing."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--verbose"])
self.assertTrue(args.verbose)
def test_complex_command_combination(self):
"""Test complex command with multiple flags."""
args = self.parser.parse_args(
[
"analyze",
"--directory",
"./src",
"--output",
"analysis/",
"--quick",
"--languages",
"Python",
"--skip-patterns",
"--verbose",
]
)
self.assertEqual(args.directory, "./src")
self.assertEqual(args.output, "analysis/")
self.assertTrue(args.quick)
self.assertEqual(args.languages, "Python")
self.assertTrue(args.skip_patterns)
self.assertTrue(args.verbose)
def test_directory_is_required(self):
"""Test that directory argument is required."""
with self.assertRaises(SystemExit):
self.parser.parse_args(["analyze"])
def test_default_output_directory(self):
"""Test default output directory value."""
args = self.parser.parse_args(["analyze", "--directory", "."])
self.assertEqual(args.output, "output/codebase/")
class TestAnalyzePresetBehavior(unittest.TestCase):
"""Test preset flag behavior and argument transformation."""
def setUp(self):
"""Create parser for testing."""
self.parser = create_parser()
def test_quick_preset_implies_surface_depth(self):
"""Test that --quick preset should trigger surface depth."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--quick"])
self.assertTrue(args.quick)
# Note: Depth transformation happens in dispatch handler
def test_comprehensive_preset_implies_full_depth(self):
"""Test that --comprehensive preset should trigger full depth."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--comprehensive"])
self.assertTrue(args.comprehensive)
# Note: Depth transformation happens in dispatch handler
def test_enhance_level_standalone(self):
"""Test --enhance-level can be used without presets."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--enhance-level", "3"])
self.assertEqual(args.enhance_level, 3)
self.assertFalse(args.quick)
self.assertFalse(args.comprehensive)
class TestAnalyzeWorkflowFlags(unittest.TestCase):
"""Test workflow and parity flags added to the analyze subcommand."""
def setUp(self):
"""Create parser for testing."""
self.parser = create_parser()
def test_enhance_workflow_accepted_as_list(self):
"""Test --enhance-workflow is accepted and stored as a list."""
args = self.parser.parse_args(
["analyze", "--directory", ".", "--enhance-workflow", "security-focus"]
)
self.assertEqual(args.enhance_workflow, ["security-focus"])
def test_enhance_workflow_chained_twice(self):
"""Test --enhance-workflow can be chained to produce a two-item list."""
args = self.parser.parse_args(
[
"analyze",
"--directory",
".",
"--enhance-workflow",
"security-focus",
"--enhance-workflow",
"minimal",
]
)
self.assertEqual(args.enhance_workflow, ["security-focus", "minimal"])
def test_enhance_stage_accepted_as_list(self):
"""Test --enhance-stage is accepted with action=append."""
args = self.parser.parse_args(
["analyze", "--directory", ".", "--enhance-stage", "sec:Analyze security"]
)
self.assertEqual(args.enhance_stage, ["sec:Analyze security"])
def test_var_accepted_as_list(self):
"""Test --var is accepted with action=append (dest is 'var')."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--var", "focus=performance"])
self.assertEqual(args.var, ["focus=performance"])
def test_workflow_dry_run_flag(self):
"""Test --workflow-dry-run sets the flag."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--workflow-dry-run"])
self.assertTrue(args.workflow_dry_run)
def test_api_key_stored_correctly(self):
"""Test --api-key is stored in args."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--api-key", "sk-ant-test"])
self.assertEqual(args.api_key, "sk-ant-test")
def test_dry_run_stored_correctly(self):
"""Test --dry-run is stored in args."""
args = self.parser.parse_args(["analyze", "--directory", ".", "--dry-run"])
self.assertTrue(args.dry_run)
def test_workflow_flags_combined(self):
"""Test workflow flags can be combined with other analyze flags."""
args = self.parser.parse_args(
[
"analyze",
"--directory",
".",
"--enhance-workflow",
"security-focus",
"--api-key",
"sk-ant-test",
"--dry-run",
"--enhance-level",
"1",
]
)
self.assertEqual(args.enhance_workflow, ["security-focus"])
self.assertEqual(args.api_key, "sk-ant-test")
self.assertTrue(args.dry_run)
self.assertEqual(args.enhance_level, 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,344 +0,0 @@
#!/usr/bin/env python3
"""
End-to-End tests for the new 'analyze' command.
Tests real-world usage scenarios with actual command execution.
"""
import json
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
class TestAnalyzeCommandE2E(unittest.TestCase):
"""End-to-end tests for skill-seekers analyze command."""
@classmethod
def setUpClass(cls):
"""Set up test fixtures once for all tests."""
cls.test_dir = Path(tempfile.mkdtemp(prefix="analyze_e2e_"))
cls.create_sample_codebase()
@classmethod
def tearDownClass(cls):
"""Clean up test directory."""
if cls.test_dir.exists():
shutil.rmtree(cls.test_dir)
@classmethod
def create_sample_codebase(cls):
"""Create a sample Python codebase for testing."""
# Create directory structure
(cls.test_dir / "src").mkdir()
(cls.test_dir / "tests").mkdir()
# Create sample Python files
(cls.test_dir / "src" / "__init__.py").write_text("")
(cls.test_dir / "src" / "main.py").write_text('''
"""Main application module."""
class Application:
"""Main application class."""
def __init__(self, name: str):
"""Initialize application.
Args:
name: Application name
"""
self.name = name
def run(self):
"""Run the application."""
print(f"Running {self.name}")
return True
''')
(cls.test_dir / "tests" / "test_main.py").write_text('''
"""Tests for main module."""
import unittest
from src.main import Application
class TestApplication(unittest.TestCase):
"""Test Application class."""
def test_init(self):
"""Test application initialization."""
app = Application("Test")
self.assertEqual(app.name, "Test")
def test_run(self):
"""Test application run."""
app = Application("Test")
self.assertTrue(app.run())
''')
def run_command(self, *args, timeout=120):
"""Run skill-seekers command and return result."""
cmd = ["skill-seekers"] + list(args)
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout, cwd=str(self.test_dir)
)
return result
def test_analyze_help_shows_command(self):
"""Test that analyze command appears in main help."""
result = self.run_command("--help", timeout=5)
self.assertEqual(result.returncode, 0, f"Help failed: {result.stderr}")
self.assertIn("analyze", result.stdout)
self.assertIn("Analyze local codebase", result.stdout)
def test_analyze_subcommand_help(self):
"""Test that analyze subcommand has proper help."""
result = self.run_command("analyze", "--help", timeout=5)
self.assertEqual(result.returncode, 0, f"Analyze help failed: {result.stderr}")
self.assertIn("--quick", result.stdout)
self.assertIn("--comprehensive", result.stdout)
self.assertIn("--enhance", result.stdout)
self.assertIn("--directory", result.stdout)
def test_analyze_quick_preset(self):
"""Test quick analysis preset (real execution)."""
output_dir = self.test_dir / "output_quick"
result = self.run_command(
"analyze", "--directory", str(self.test_dir), "--output", str(output_dir), "--quick"
)
# Check command succeeded
self.assertEqual(
result.returncode,
0,
f"Quick analysis failed:\nSTDOUT: {result.stdout}\nSTDERR: {result.stderr}",
)
# Verify output directory was created
self.assertTrue(output_dir.exists(), "Output directory not created")
# Verify SKILL.md was generated
skill_file = output_dir / "SKILL.md"
self.assertTrue(skill_file.exists(), "SKILL.md not generated")
# Verify SKILL.md has content and valid structure
skill_content = skill_file.read_text()
self.assertGreater(len(skill_content), 100, "SKILL.md is too short")
# Check for expected structure (works even with 0 files analyzed)
self.assertIn("Codebase", skill_content, "Missing codebase header")
self.assertIn("Analysis", skill_content, "Missing analysis section")
# Verify it's valid markdown with frontmatter
self.assertTrue(skill_content.startswith("---"), "Missing YAML frontmatter")
self.assertIn("name:", skill_content, "Missing name in frontmatter")
def test_analyze_with_custom_output(self):
"""Test analysis with custom output directory."""
output_dir = self.test_dir / "custom_output"
result = self.run_command(
"analyze", "--directory", str(self.test_dir), "--output", str(output_dir), "--quick"
)
self.assertEqual(result.returncode, 0, f"Analysis failed: {result.stderr}")
self.assertTrue(output_dir.exists(), "Custom output directory not created")
self.assertTrue((output_dir / "SKILL.md").exists(), "SKILL.md not in custom directory")
def test_analyze_skip_flags_work(self):
"""Test that skip flags are properly handled."""
output_dir = self.test_dir / "output_skip"
result = self.run_command(
"analyze",
"--directory",
str(self.test_dir),
"--output",
str(output_dir),
"--quick",
"--skip-patterns",
"--skip-test-examples",
)
self.assertEqual(result.returncode, 0, f"Analysis with skip flags failed: {result.stderr}")
self.assertTrue(
(output_dir / "SKILL.md").exists(), "SKILL.md not generated with skip flags"
)
def test_analyze_invalid_directory(self):
"""Test analysis with non-existent directory."""
result = self.run_command(
"analyze", "--directory", "/nonexistent/directory/path", "--quick", timeout=10
)
# Should fail with error
self.assertNotEqual(result.returncode, 0, "Should fail with invalid directory")
self.assertTrue(
"not found" in result.stderr.lower() or "does not exist" in result.stderr.lower(),
f"Expected directory error, got: {result.stderr}",
)
def test_analyze_missing_directory_arg(self):
"""Test that --directory is required."""
result = self.run_command("analyze", "--quick", timeout=5)
# Should fail without --directory
self.assertNotEqual(result.returncode, 0, "Should fail without --directory")
self.assertTrue(
"required" in result.stderr.lower() or "directory" in result.stderr.lower(),
f"Expected missing argument error, got: {result.stderr}",
)
def test_backward_compatibility_depth_flag(self):
"""Test that old --depth flag still works."""
output_dir = self.test_dir / "output_depth"
result = self.run_command(
"analyze",
"--directory",
str(self.test_dir),
"--output",
str(output_dir),
"--depth",
"surface",
)
self.assertEqual(result.returncode, 0, f"Depth flag failed: {result.stderr}")
self.assertTrue((output_dir / "SKILL.md").exists(), "SKILL.md not generated with --depth")
def test_analyze_generates_references(self):
"""Test that references directory is created."""
output_dir = self.test_dir / "output_refs"
result = self.run_command(
"analyze", "--directory", str(self.test_dir), "--output", str(output_dir), "--quick"
)
self.assertEqual(result.returncode, 0, f"Analysis failed: {result.stderr}")
# Check for references directory
refs_dir = output_dir / "references"
if refs_dir.exists(): # Optional, depends on content
self.assertTrue(refs_dir.is_dir(), "References is not a directory")
def test_analyze_output_structure(self):
"""Test that output has expected structure."""
output_dir = self.test_dir / "output_structure"
result = self.run_command(
"analyze", "--directory", str(self.test_dir), "--output", str(output_dir), "--quick"
)
self.assertEqual(result.returncode, 0, f"Analysis failed: {result.stderr}")
# Verify expected files/directories
self.assertTrue((output_dir / "SKILL.md").exists(), "SKILL.md missing")
# Check for code_analysis.json if it exists
analysis_file = output_dir / "code_analysis.json"
if analysis_file.exists():
# Verify it's valid JSON
with open(analysis_file) as f:
data = json.load(f)
self.assertIsInstance(data, (dict, list), "code_analysis.json is not valid JSON")
class TestAnalyzeOldCommand(unittest.TestCase):
"""Test that old skill-seekers-codebase command still works."""
def test_old_command_still_exists(self):
"""Test that skill-seekers-codebase still exists."""
result = subprocess.run(
["skill-seekers-codebase", "--help"], capture_output=True, text=True, timeout=5
)
# Command should exist and show help
self.assertEqual(result.returncode, 0, f"Old command doesn't work: {result.stderr}")
self.assertIn("--directory", result.stdout)
class TestAnalyzeIntegration(unittest.TestCase):
"""Integration tests for analyze command with other features."""
def setUp(self):
"""Set up test directory."""
self.test_dir = Path(tempfile.mkdtemp(prefix="analyze_int_"))
# Create minimal Python project
(self.test_dir / "main.py").write_text('''
def hello():
"""Say hello."""
return "Hello, World!"
''')
def tearDown(self):
"""Clean up test directory."""
if self.test_dir.exists():
shutil.rmtree(self.test_dir)
def test_analyze_then_check_output(self):
"""Test analyzing and verifying output can be read."""
output_dir = self.test_dir / "output"
# Run analysis
result = subprocess.run(
[
"skill-seekers",
"analyze",
"--directory",
str(self.test_dir),
"--output",
str(output_dir),
"--quick",
],
capture_output=True,
text=True,
timeout=120,
)
self.assertEqual(result.returncode, 0, f"Analysis failed: {result.stderr}")
# Read and verify SKILL.md
skill_file = output_dir / "SKILL.md"
self.assertTrue(skill_file.exists(), "SKILL.md not created")
content = skill_file.read_text()
# Check for valid structure instead of specific content
# (file detection may vary in temp directories)
self.assertGreater(len(content), 50, "Output too short")
self.assertIn("Codebase", content, "Missing codebase header")
self.assertTrue(content.startswith("---"), "Missing YAML frontmatter")
def test_analyze_verbose_flag(self):
"""Test that verbose flag works."""
output_dir = self.test_dir / "output"
result = subprocess.run(
[
"skill-seekers",
"analyze",
"--directory",
str(self.test_dir),
"--output",
str(output_dir),
"--quick",
"--verbose",
],
capture_output=True,
text=True,
timeout=120,
)
self.assertEqual(result.returncode, 0, f"Verbose analysis failed: {result.stderr}")
# Verbose should produce more output
combined_output = result.stdout + result.stderr
self.assertGreater(len(combined_output), 100, "Verbose mode didn't produce extra output")
if __name__ == "__main__":
unittest.main()

View File

@@ -37,9 +37,7 @@ class TestBootstrapSkillScript:
# Must have commands table
assert "## Commands" in content, "Header must have Commands section"
assert "skill-seekers analyze" in content, "Header must mention analyze command"
assert "skill-seekers scrape" in content, "Header must mention scrape command"
assert "skill-seekers github" in content, "Header must mention github command"
assert "skill-seekers create" in content, "Header must mention create command"
def test_header_has_yaml_frontmatter(self, project_root):
"""Test that header has valid YAML frontmatter."""

View File

@@ -147,18 +147,31 @@ class TestDocScraperBrowserIntegration:
class TestBrowserArgument:
"""Test --browser argument is registered in CLI."""
"""Test --browser argument is accepted by DocToSkillConverter config."""
def test_scrape_parser_accepts_browser_flag(self):
from skill_seekers.cli.doc_scraper import setup_argument_parser
def test_browser_config_true(self):
"""Test that DocToSkillConverter accepts browser=True in config."""
from skill_seekers.cli.doc_scraper import DocToSkillConverter
parser = setup_argument_parser()
args = parser.parse_args(["--name", "test", "--url", "https://example.com", "--browser"])
assert args.browser is True
config = {
"name": "test",
"base_url": "https://example.com",
"browser": True,
"selectors": {},
"url_patterns": {"include": [], "exclude": []},
}
scraper = DocToSkillConverter(config)
assert scraper.browser_mode is True
def test_scrape_parser_browser_default_false(self):
from skill_seekers.cli.doc_scraper import setup_argument_parser
def test_browser_config_default_false(self):
"""Test that DocToSkillConverter defaults browser to False."""
from skill_seekers.cli.doc_scraper import DocToSkillConverter
parser = setup_argument_parser()
args = parser.parse_args(["--name", "test", "--url", "https://example.com"])
assert args.browser is False
config = {
"name": "test",
"base_url": "https://example.com",
"selectors": {},
"url_patterns": {"include": [], "exclude": []},
}
scraper = DocToSkillConverter(config)
assert scraper.browser_mode is False

View File

@@ -14,8 +14,6 @@ from skill_seekers.cli.parsers import (
get_parser_names,
register_parsers,
)
from skill_seekers.cli.parsers.scrape_parser import ScrapeParser
from skill_seekers.cli.parsers.github_parser import GitHubParser
from skill_seekers.cli.parsers.package_parser import PackageParser
@@ -24,20 +22,17 @@ class TestParserRegistry:
def test_all_parsers_registered(self):
"""Test that all parsers are registered."""
assert len(PARSERS) == 36, f"Expected 36 parsers, got {len(PARSERS)}"
assert len(PARSERS) == 18, f"Expected 18 parsers, got {len(PARSERS)}"
def test_get_parser_names(self):
"""Test getting list of parser names."""
names = get_parser_names()
assert len(names) == 36
assert "scrape" in names
assert "github" in names
assert len(names) == 18
assert "create" in names
assert "package" in names
assert "upload" in names
assert "analyze" in names
assert "config" in names
assert "workflows" in names
assert "video" in names
def test_all_parsers_are_subcommand_parsers(self):
"""Test that all parsers inherit from SubcommandParser."""
@@ -71,29 +66,6 @@ class TestParserRegistry:
class TestParserCreation:
"""Test parser creation functionality."""
def test_scrape_parser_creates_subparser(self):
"""Test that ScrapeParser creates valid subparser."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers()
scrape_parser = ScrapeParser()
subparser = scrape_parser.create_parser(subparsers)
assert subparser is not None
assert scrape_parser.name == "scrape"
assert scrape_parser.help == "Scrape documentation website"
def test_github_parser_creates_subparser(self):
"""Test that GitHubParser creates valid subparser."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers()
github_parser = GitHubParser()
subparser = github_parser.create_parser(subparsers)
assert subparser is not None
assert github_parser.name == "github"
def test_package_parser_creates_subparser(self):
"""Test that PackageParser creates valid subparser."""
main_parser = argparse.ArgumentParser()
@@ -106,21 +78,18 @@ class TestParserCreation:
assert package_parser.name == "package"
def test_register_parsers_creates_all_subcommands(self):
"""Test that register_parsers creates all 19 subcommands."""
"""Test that register_parsers creates all subcommands."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers(dest="command")
# Register all parsers
register_parsers(subparsers)
# Test that all commands can be parsed
# Test that existing commands can be parsed
test_commands = [
"config --show",
"scrape --config test.json",
"github --repo owner/repo",
"package output/test/",
"upload test.zip",
"analyze --directory .",
"enhance output/test/",
"estimate test.json",
]
@@ -133,40 +102,6 @@ class TestParserCreation:
class TestSpecificParsers:
"""Test specific parser implementations."""
def test_scrape_parser_arguments(self):
"""Test ScrapeParser has correct arguments."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers(dest="command")
scrape_parser = ScrapeParser()
scrape_parser.create_parser(subparsers)
# Test various argument combinations
args = main_parser.parse_args(["scrape", "--config", "test.json"])
assert args.command == "scrape"
assert args.config == "test.json"
args = main_parser.parse_args(["scrape", "--config", "test.json", "--max-pages", "100"])
assert args.max_pages == 100
args = main_parser.parse_args(["scrape", "--enhance-level", "2"])
assert args.enhance_level == 2
def test_github_parser_arguments(self):
"""Test GitHubParser has correct arguments."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers(dest="command")
github_parser = GitHubParser()
github_parser.create_parser(subparsers)
args = main_parser.parse_args(["github", "--repo", "owner/repo"])
assert args.command == "github"
assert args.repo == "owner/repo"
args = main_parser.parse_args(["github", "--repo", "owner/repo", "--non-interactive"])
assert args.non_interactive is True
def test_package_parser_arguments(self):
"""Test PackageParser has correct arguments."""
main_parser = argparse.ArgumentParser()
@@ -185,44 +120,19 @@ class TestSpecificParsers:
args = main_parser.parse_args(["package", "output/test/", "--no-open"])
assert args.no_open is True
def test_analyze_parser_arguments(self):
"""Test AnalyzeParser has correct arguments."""
main_parser = argparse.ArgumentParser()
subparsers = main_parser.add_subparsers(dest="command")
from skill_seekers.cli.parsers.analyze_parser import AnalyzeParser
class TestCurrentCommands:
"""Test current CLI commands after Grand Unification."""
analyze_parser = AnalyzeParser()
analyze_parser.create_parser(subparsers)
args = main_parser.parse_args(["analyze", "--directory", "."])
assert args.command == "analyze"
assert args.directory == "."
args = main_parser.parse_args(["analyze", "--directory", ".", "--quick"])
assert args.quick is True
args = main_parser.parse_args(["analyze", "--directory", ".", "--comprehensive"])
assert args.comprehensive is True
args = main_parser.parse_args(["analyze", "--directory", ".", "--skip-patterns"])
assert args.skip_patterns is True
class TestBackwardCompatibility:
"""Test backward compatibility with old CLI."""
def test_all_original_commands_still_work(self):
"""Test that all original commands are still registered."""
def test_all_current_commands_registered(self):
"""Test that all current commands are registered."""
names = get_parser_names()
# Original commands from old main.py
original_commands = [
# Commands that survived the Grand Unification
# (individual scraper commands removed; use 'create' instead)
current_commands = [
"config",
"scrape",
"github",
"pdf",
"unified",
"create",
"enhance",
"enhance-status",
"package",
@@ -230,22 +140,50 @@ class TestBackwardCompatibility:
"estimate",
"extract-test-examples",
"install-agent",
"analyze",
"install",
"resume",
"stream",
"update",
"multilang",
"quality",
"doctor",
"workflows",
"sync-config",
]
for cmd in original_commands:
for cmd in current_commands:
assert cmd in names, f"Command '{cmd}' not found in parser registry!"
def test_removed_scraper_commands_not_present(self):
"""Test that individual scraper commands were removed."""
names = get_parser_names()
removed_commands = [
"scrape",
"github",
"pdf",
"video",
"word",
"epub",
"jupyter",
"html",
"openapi",
"asciidoc",
"pptx",
"rss",
"manpage",
"confluence",
"notion",
"chat",
]
for cmd in removed_commands:
assert cmd not in names, f"Removed command '{cmd}' still in parser registry!"
def test_command_count_matches(self):
"""Test that we have exactly 35 commands (25 original + 10 new source types)."""
assert len(PARSERS) == 36
assert len(get_parser_names()) == 36
"""Test that we have exactly 18 commands."""
assert len(PARSERS) == 18
assert len(get_parser_names()) == 18
if __name__ == "__main__":

View File

@@ -14,152 +14,6 @@ import subprocess
import argparse
class TestParserSync:
"""E2E tests for parser synchronization (Issue #285)."""
def test_scrape_interactive_flag_works(self):
"""Test that --interactive flag (previously missing) now works."""
result = subprocess.run(
["skill-seekers", "scrape", "--interactive", "--help"], capture_output=True, text=True
)
assert result.returncode == 0, "Command should execute successfully"
assert "--interactive" in result.stdout, "Help should show --interactive flag"
assert "-i" in result.stdout, "Help should show short form -i"
def test_scrape_chunk_for_rag_flag_works(self):
"""Test that --chunk-for-rag flag (previously missing) now works."""
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True
)
assert "--chunk-for-rag" in result.stdout, "Help should show --chunk-for-rag flag"
assert "--chunk-tokens" in result.stdout, "Help should show --chunk-tokens flag"
assert "--chunk-overlap-tokens" in result.stdout, (
"Help should show --chunk-overlap-tokens flag"
)
def test_scrape_verbose_flag_works(self):
"""Test that --verbose flag (previously missing) now works."""
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True
)
assert "--verbose" in result.stdout, "Help should show --verbose flag"
assert "-v" in result.stdout, "Help should show short form -v"
def test_scrape_url_flag_works(self):
"""Test that --url flag (previously missing) now works."""
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True
)
assert "--url URL" in result.stdout, "Help should show --url flag"
def test_github_all_flags_present(self):
"""Test that github command has all expected flags."""
result = subprocess.run(
["skill-seekers", "github", "--help"], capture_output=True, text=True
)
# Key github flags that should be present
expected_flags = [
"--repo",
"--api-key",
"--profile",
"--non-interactive",
]
for flag in expected_flags:
assert flag in result.stdout, f"Help should show {flag} flag"
class TestPresetSystem:
"""E2E tests for preset system (Issue #268)."""
def test_analyze_preset_flag_exists(self):
"""Test that analyze command has --preset flag."""
result = subprocess.run(
["skill-seekers", "analyze", "--help"], capture_output=True, text=True
)
assert "--preset" in result.stdout, "Help should show --preset flag"
assert "quick" in result.stdout, "Help should mention 'quick' preset"
assert "standard" in result.stdout, "Help should mention 'standard' preset"
assert "comprehensive" in result.stdout, "Help should mention 'comprehensive' preset"
def test_analyze_preset_list_flag_exists(self):
"""Test that analyze command has --preset-list flag."""
result = subprocess.run(
["skill-seekers", "analyze", "--help"], capture_output=True, text=True
)
assert "--preset-list" in result.stdout, "Help should show --preset-list flag"
def test_preset_list_shows_presets(self):
"""Test that --preset-list shows all available presets."""
result = subprocess.run(
["skill-seekers", "analyze", "--preset-list"], capture_output=True, text=True
)
assert result.returncode == 0, "Command should execute successfully"
assert "Available presets" in result.stdout, "Should show preset list header"
assert "quick" in result.stdout, "Should show quick preset"
assert "standard" in result.stdout, "Should show standard preset"
assert "comprehensive" in result.stdout, "Should show comprehensive preset"
assert "1-2 minutes" in result.stdout, "Should show time estimates"
def test_deprecated_quick_flag_shows_warning(self, tmp_path):
"""Test that --quick flag shows deprecation warning."""
result = subprocess.run(
["skill-seekers", "analyze", "--directory", str(tmp_path), "--quick"],
capture_output=True,
text=True,
)
# Note: Deprecation warnings go to stderr or stdout
output = result.stdout + result.stderr
assert "DEPRECATED" in output, "Should show deprecation warning"
assert "--preset quick" in output, "Should suggest alternative"
def test_deprecated_comprehensive_flag_shows_warning(self, tmp_path):
"""Test that --comprehensive flag shows deprecation warning."""
result = subprocess.run(
["skill-seekers", "analyze", "--directory", str(tmp_path), "--comprehensive"],
capture_output=True,
text=True,
)
output = result.stdout + result.stderr
assert "DEPRECATED" in output, "Should show deprecation warning"
assert "--preset comprehensive" in output, "Should suggest alternative"
class TestBackwardCompatibility:
"""E2E tests for backward compatibility."""
def test_old_scrape_command_still_works(self):
"""Test that old scrape command invocations still work."""
result = subprocess.run(["skill-seekers-scrape", "--help"], capture_output=True, text=True)
assert result.returncode == 0, "Old command should still work"
assert "documentation" in result.stdout.lower(), "Help should mention documentation"
def test_unified_cli_and_standalone_have_same_args(self):
"""Test that unified CLI and standalone have identical arguments."""
# Get help from unified CLI
unified_result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True
)
# Get help from standalone
standalone_result = subprocess.run(
["skill-seekers-scrape", "--help"], capture_output=True, text=True
)
# Both should have the same key flags
key_flags = [
"--interactive",
"--url",
"--verbose",
"--chunk-for-rag",
"--config",
"--max-pages",
]
for flag in key_flags:
assert flag in unified_result.stdout, f"Unified should have {flag}"
assert flag in standalone_result.stdout, f"Standalone should have {flag}"
class TestProgrammaticAPI:
"""Test that the shared argument functions work programmatically."""
@@ -211,11 +65,7 @@ class TestIntegration:
# All major commands should be listed
expected_commands = [
"scrape",
"github",
"pdf",
"unified",
"analyze",
"create",
"enhance",
"package",
"upload",
@@ -224,75 +74,6 @@ class TestIntegration:
for cmd in expected_commands:
assert cmd in result.stdout, f"Should list {cmd} command"
def test_scrape_help_detailed(self):
"""Test that scrape help shows all argument details."""
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True
)
# Check for argument categories
assert "url" in result.stdout.lower(), "Should show url argument"
assert "scraping options" in result.stdout.lower() or "options" in result.stdout.lower()
assert "enhancement" in result.stdout.lower(), "Should mention enhancement options"
def test_analyze_help_shows_presets(self):
"""Test that analyze help prominently shows preset information."""
result = subprocess.run(
["skill-seekers", "analyze", "--help"], capture_output=True, text=True
)
assert "--preset" in result.stdout, "Should show --preset flag"
assert "DEFAULT" in result.stdout or "default" in result.stdout, (
"Should indicate default preset"
)
class TestE2EWorkflow:
"""End-to-end workflow tests."""
@pytest.mark.slow
def test_dry_run_scrape_with_new_args(self, tmp_path):
"""Test scraping with previously missing arguments (dry run)."""
result = subprocess.run(
[
"skill-seekers",
"scrape",
"--url",
"https://example.com",
"--interactive",
"false", # Would fail if arg didn't exist
"--verbose", # Would fail if arg didn't exist
"--dry-run",
],
capture_output=True,
text=True,
timeout=10,
)
# Dry run should complete without errors
# (it may return non-zero if --interactive false isn't valid,
# but it shouldn't crash with "unrecognized arguments")
assert "unrecognized arguments" not in result.stderr.lower()
@pytest.mark.slow
def test_analyze_with_preset_flag(self, tmp_path):
"""Test analyze with preset flag (no dry-run available)."""
# Create a dummy directory to analyze
test_dir = tmp_path / "test_code"
test_dir.mkdir()
(test_dir / "test.py").write_text("def hello(): pass")
# Just verify the flag is recognized (no execution)
result = subprocess.run(
["skill-seekers", "analyze", "--help"],
capture_output=True,
text=True,
)
# Verify preset flag exists
assert "--preset" in result.stdout, "Should have --preset flag"
assert "unrecognized arguments" not in result.stderr.lower()
class TestVarFlagRouting:
"""Test that --var flag is correctly routed through create command."""
@@ -306,15 +87,6 @@ class TestVarFlagRouting:
)
assert "--var" in result.stdout, "create --help should show --var flag"
def test_var_flag_accepted_by_analyze(self):
"""Test that --var flag is accepted by analyze command."""
result = subprocess.run(
["skill-seekers", "analyze", "--help"],
capture_output=True,
text=True,
)
assert "--var" in result.stdout, "analyze --help should show --var flag"
@pytest.mark.slow
def test_var_flag_not_rejected_in_create_local(self, tmp_path):
"""Test --var KEY=VALUE doesn't cause 'unrecognized arguments' in create."""
@@ -354,15 +126,6 @@ class TestBackwardCompatibleFlags:
# but should not cause an error if used
assert result.returncode == 0
def test_no_preserve_code_alias_accepted_by_scrape(self):
"""Test --no-preserve-code (old name) is still accepted by scrape command."""
result = subprocess.run(
["skill-seekers", "scrape", "--help"],
capture_output=True,
text=True,
)
assert result.returncode == 0
def test_no_preserve_code_alias_accepted_by_create(self):
"""Test --no-preserve-code (old name) is still accepted by create command."""
result = subprocess.run(

View File

@@ -101,395 +101,96 @@ class TestCreateCommandBasic:
# Verify help works
assert result.returncode in [0, 2]
def test_create_invalid_source_shows_error(self):
"""Test that invalid sources raise a helpful ValueError."""
from skill_seekers.cli.source_detector import SourceDetector
with pytest.raises(ValueError) as exc_info:
SourceDetector.detect("not_a_valid_source_123_xyz")
class TestCreateCommandConverterRouting:
"""Tests that create command routes to correct converters."""
error_message = str(exc_info.value)
assert "Cannot determine source type" in error_message
# Error should include helpful examples
assert "https://" in error_message or "github" in error_message.lower()
def test_get_converter_web(self):
"""Test that get_converter returns DocToSkillConverter for web."""
from skill_seekers.cli.skill_converter import get_converter
def test_create_supports_universal_flags(self):
"""Test that universal flags are accepted."""
import subprocess
config = {"name": "test", "base_url": "https://example.com"}
converter = get_converter("web", config)
result = subprocess.run(
["skill-seekers", "create", "--help"], capture_output=True, text=True, timeout=10
)
assert result.returncode == 0
assert converter.SOURCE_TYPE == "web"
assert converter.name == "test"
# Check that universal flags are present
assert "--name" in result.stdout
assert "--enhance" in result.stdout
assert "--chunk-for-rag" in result.stdout
assert "--preset" in result.stdout
assert "--dry-run" in result.stdout
def test_get_converter_github(self):
"""Test that get_converter returns GitHubScraper for github."""
from skill_seekers.cli.skill_converter import get_converter
config = {"name": "test", "repo": "owner/repo"}
converter = get_converter("github", config)
assert converter.SOURCE_TYPE == "github"
assert converter.name == "test"
def test_get_converter_pdf(self):
"""Test that get_converter returns PDFToSkillConverter for pdf."""
from skill_seekers.cli.skill_converter import get_converter
config = {"name": "test", "pdf_path": "/tmp/test.pdf"}
converter = get_converter("pdf", config)
assert converter.SOURCE_TYPE == "pdf"
assert converter.name == "test"
def test_get_converter_unknown_raises(self):
"""Test that get_converter raises ValueError for unknown type."""
from skill_seekers.cli.skill_converter import get_converter
with pytest.raises(ValueError, match="Unknown source type"):
get_converter("unknown_type", {})
class TestCreateCommandArgvForwarding:
"""Unit tests for _build_argv argument forwarding."""
class TestExecutionContextIntegration:
"""Tests that ExecutionContext flows correctly through the system."""
def _make_args(self, **kwargs):
def test_execution_context_auto_initializes(self):
"""ExecutionContext.get() returns defaults without explicit init."""
from skill_seekers.cli.execution_context import ExecutionContext
# Reset to ensure clean state
ExecutionContext.reset()
# Should not raise - returns default context
ctx = ExecutionContext.get()
assert ctx is not None
assert ctx.output.name is None # Default value
ExecutionContext.reset()
def test_execution_context_values_preserved(self):
"""Values set in context are preserved and accessible."""
from skill_seekers.cli.execution_context import ExecutionContext
import argparse
defaults = {
"source": "https://example.com",
"enhance_workflow": None,
"enhance_stage": None,
"var": None,
"workflow_dry_run": False,
"enhance_level": 2,
"output": None,
"name": None,
"description": None,
"config": None,
"api_key": None,
"dry_run": False,
"verbose": False,
"quiet": False,
"chunk_for_rag": False,
"chunk_size": 512,
"chunk_overlap": 50,
"preset": None,
"no_preserve_code_blocks": False,
"no_preserve_paragraphs": False,
"interactive_enhancement": False,
"agent": None,
"agent_cmd": None,
"doc_version": "",
}
defaults.update(kwargs)
return argparse.Namespace(**defaults)
ExecutionContext.reset()
def _collect_argv(self, args):
from skill_seekers.cli.create_command import CreateCommand
from skill_seekers.cli.source_detector import SourceDetector
cmd = CreateCommand(args)
cmd.source_info = SourceDetector.detect(args.source)
return cmd._build_argv("test_module", [])
def test_single_enhance_workflow_forwarded(self):
args = self._make_args(enhance_workflow=["security-focus"])
argv = self._collect_argv(args)
assert argv.count("--enhance-workflow") == 1
assert "security-focus" in argv
def test_multiple_enhance_workflows_all_forwarded(self):
"""Each workflow must appear as a separate --enhance-workflow flag."""
args = self._make_args(enhance_workflow=["security-focus", "minimal"])
argv = self._collect_argv(args)
assert argv.count("--enhance-workflow") == 2
idx1 = argv.index("security-focus")
idx2 = argv.index("minimal")
assert argv[idx1 - 1] == "--enhance-workflow"
assert argv[idx2 - 1] == "--enhance-workflow"
def test_no_enhance_workflow_not_forwarded(self):
args = self._make_args(enhance_workflow=None)
argv = self._collect_argv(args)
assert "--enhance-workflow" not in argv
# ── enhance_stage ────────────────────────────────────────────────────────
def test_single_enhance_stage_forwarded(self):
args = self._make_args(enhance_stage=["security:Check for vulnerabilities"])
argv = self._collect_argv(args)
assert "--enhance-stage" in argv
assert "security:Check for vulnerabilities" in argv
def test_multiple_enhance_stages_all_forwarded(self):
stages = ["sec:Check security", "cleanup:Remove boilerplate"]
args = self._make_args(enhance_stage=stages)
argv = self._collect_argv(args)
assert argv.count("--enhance-stage") == 2
for stage in stages:
assert stage in argv
def test_enhance_stage_none_not_forwarded(self):
args = self._make_args(enhance_stage=None)
argv = self._collect_argv(args)
assert "--enhance-stage" not in argv
# ── var ──────────────────────────────────────────────────────────────────
def test_single_var_forwarded(self):
args = self._make_args(var=["depth=comprehensive"])
argv = self._collect_argv(args)
assert "--var" in argv
assert "depth=comprehensive" in argv
def test_multiple_vars_all_forwarded(self):
args = self._make_args(var=["depth=comprehensive", "focus=security"])
argv = self._collect_argv(args)
assert argv.count("--var") == 2
assert "depth=comprehensive" in argv
assert "focus=security" in argv
def test_var_none_not_forwarded(self):
args = self._make_args(var=None)
argv = self._collect_argv(args)
assert "--var" not in argv
# ── workflow_dry_run ─────────────────────────────────────────────────────
def test_workflow_dry_run_forwarded(self):
args = self._make_args(workflow_dry_run=True)
argv = self._collect_argv(args)
assert "--workflow-dry-run" in argv
def test_workflow_dry_run_false_not_forwarded(self):
args = self._make_args(workflow_dry_run=False)
argv = self._collect_argv(args)
assert "--workflow-dry-run" not in argv
# ── mixed ────────────────────────────────────────────────────────────────
def test_workflow_and_stage_both_forwarded(self):
args = self._make_args(
enhance_workflow=["security-focus"],
enhance_stage=["cleanup:Remove boilerplate"],
var=["depth=basic"],
workflow_dry_run=True,
)
argv = self._collect_argv(args)
assert "--enhance-workflow" in argv
assert "security-focus" in argv
assert "--enhance-stage" in argv
assert "--var" in argv
assert "--workflow-dry-run" in argv
# ── _SKIP_ARGS exclusion ────────────────────────────────────────────────
def test_source_never_forwarded(self):
"""'source' is in _SKIP_ARGS and must never appear in argv."""
args = self._make_args(source="https://example.com")
argv = self._collect_argv(args)
assert "--source" not in argv
def test_func_never_forwarded(self):
"""'func' is in _SKIP_ARGS and must never appear in argv."""
args = self._make_args(func=lambda: None)
argv = self._collect_argv(args)
assert "--func" not in argv
def test_config_never_forwarded_by_build_argv(self):
"""'config' is in _SKIP_ARGS; forwarded manually by specific routes."""
args = self._make_args(config="/path/to/config.json")
argv = self._collect_argv(args)
assert "--config" not in argv
def test_subcommand_never_forwarded(self):
"""'subcommand' is in _SKIP_ARGS."""
args = self._make_args(subcommand="create")
argv = self._collect_argv(args)
assert "--subcommand" not in argv
def test_command_never_forwarded(self):
"""'command' is in _SKIP_ARGS."""
args = self._make_args(command="create")
argv = self._collect_argv(args)
assert "--command" not in argv
# ── _DEST_TO_FLAG mapping ───────────────────────────────────────────────
def test_async_mode_maps_to_async_flag(self):
"""async_mode dest should produce --async flag, not --async-mode."""
args = self._make_args(async_mode=True)
argv = self._collect_argv(args)
assert "--async" in argv
assert "--async-mode" not in argv
def test_skip_config_maps_to_skip_config_patterns(self):
"""skip_config dest should produce --skip-config-patterns flag."""
args = self._make_args(skip_config=True)
argv = self._collect_argv(args)
assert "--skip-config-patterns" in argv
assert "--skip-config" not in argv
# ── Boolean arg forwarding ──────────────────────────────────────────────
def test_boolean_true_appends_flag(self):
args = self._make_args(dry_run=True)
argv = self._collect_argv(args)
assert "--dry-run" in argv
def test_boolean_false_does_not_append_flag(self):
args = self._make_args(dry_run=False)
argv = self._collect_argv(args)
assert "--dry-run" not in argv
def test_verbose_true_forwarded(self):
args = self._make_args(verbose=True)
argv = self._collect_argv(args)
assert "--verbose" in argv
def test_quiet_true_forwarded(self):
args = self._make_args(quiet=True)
argv = self._collect_argv(args)
assert "--quiet" in argv
# ── List arg forwarding ─────────────────────────────────────────────────
def test_list_arg_each_item_gets_separate_flag(self):
"""Each list item gets its own --flag value pair."""
args = self._make_args(enhance_workflow=["a", "b", "c"])
argv = self._collect_argv(args)
assert argv.count("--enhance-workflow") == 3
for item in ["a", "b", "c"]:
idx = argv.index(item)
assert argv[idx - 1] == "--enhance-workflow"
# ── _is_explicitly_set ──────────────────────────────────────────────────
def test_is_explicitly_set_none_is_not_set(self):
"""None values should NOT be considered explicitly set."""
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
assert cmd._is_explicitly_set("name", None) is False
def test_is_explicitly_set_bool_true_is_set(self):
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
assert cmd._is_explicitly_set("dry_run", True) is True
def test_is_explicitly_set_bool_false_is_not_set(self):
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
assert cmd._is_explicitly_set("dry_run", False) is False
def test_is_explicitly_set_default_doc_version_empty_not_set(self):
"""doc_version defaults to '' which means not explicitly set."""
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
assert cmd._is_explicitly_set("doc_version", "") is False
def test_is_explicitly_set_nonempty_string_is_set(self):
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
assert cmd._is_explicitly_set("name", "my-skill") is True
def test_is_explicitly_set_non_default_value_is_set(self):
"""A value that differs from the known default IS explicitly set."""
from skill_seekers.cli.create_command import CreateCommand
args = self._make_args()
cmd = CreateCommand(args)
# max_issues default is 100; setting to 50 means explicitly set
assert cmd._is_explicitly_set("max_issues", 50) is True
# Setting to default value means NOT explicitly set
assert cmd._is_explicitly_set("max_issues", 100) is False
# ── Allowlist filtering ─────────────────────────────────────────────────
def test_allowlist_only_forwards_allowed_args(self):
"""When allowlist is provided, only those args are forwarded."""
from skill_seekers.cli.create_command import CreateCommand
from skill_seekers.cli.source_detector import SourceDetector
args = self._make_args(
args = argparse.Namespace(
source="https://example.com",
name="test_skill",
enhance_level=3,
dry_run=True,
verbose=True,
name="test-skill",
)
cmd = CreateCommand(args)
cmd.source_info = SourceDetector.detect(args.source)
# Only allow dry_run in the allowlist
allowlist = frozenset({"dry_run"})
argv = cmd._build_argv("test_module", [], allowlist=allowlist)
ctx = ExecutionContext.initialize(args=args)
assert ctx.output.name == "test_skill"
assert ctx.enhancement.level == 3
assert ctx.output.dry_run is True
assert "--dry-run" in argv
assert "--verbose" not in argv
assert "--name" not in argv
# Getting context again returns same values
ctx2 = ExecutionContext.get()
assert ctx2.output.name == "test_skill"
def test_allowlist_skips_non_allowed_even_if_set(self):
"""Args not in the allowlist are excluded even if explicitly set."""
from skill_seekers.cli.create_command import CreateCommand
from skill_seekers.cli.source_detector import SourceDetector
args = self._make_args(
enhance_workflow=["security-focus"],
quiet=True,
)
cmd = CreateCommand(args)
cmd.source_info = SourceDetector.detect(args.source)
allowlist = frozenset({"quiet"})
argv = cmd._build_argv("test_module", [], allowlist=allowlist)
assert "--quiet" in argv
assert "--enhance-workflow" not in argv
def test_allowlist_empty_forwards_nothing(self):
"""Empty allowlist should forward no user args (auto-name may still be added)."""
from skill_seekers.cli.create_command import CreateCommand
from skill_seekers.cli.source_detector import SourceDetector
args = self._make_args(dry_run=True, verbose=True)
cmd = CreateCommand(args)
cmd.source_info = SourceDetector.detect(args.source)
allowlist = frozenset()
argv = cmd._build_argv("test_module", ["pos"], allowlist=allowlist)
# User-set args (dry_run, verbose) should NOT be forwarded
assert "--dry-run" not in argv
assert "--verbose" not in argv
# Only module name, positional, and possibly auto-added --name
assert argv[0] == "test_module"
assert "pos" in argv
ExecutionContext.reset()
class TestBackwardCompatibility:
"""Test that old commands still work."""
class TestUnifiedCommands:
"""Test that unified commands still work."""
def test_scrape_command_still_works(self):
"""Old scrape command should still function."""
import subprocess
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True, timeout=10
)
assert result.returncode == 0
assert "scrape" in result.stdout.lower()
def test_github_command_still_works(self):
"""Old github command should still function."""
import subprocess
result = subprocess.run(
["skill-seekers", "github", "--help"], capture_output=True, text=True, timeout=10
)
assert result.returncode == 0
assert "github" in result.stdout.lower()
def test_analyze_command_still_works(self):
"""Old analyze command should still function."""
import subprocess
result = subprocess.run(
["skill-seekers", "analyze", "--help"], capture_output=True, text=True, timeout=10
)
assert result.returncode == 0
assert "analyze" in result.stdout.lower()
def test_main_help_shows_all_commands(self):
"""Main help should show both old and new commands."""
def test_main_help_shows_available_commands(self):
"""Main help should show available commands."""
import subprocess
result = subprocess.run(
@@ -498,14 +199,11 @@ class TestBackwardCompatibility:
assert result.returncode == 0
# Should show create command
assert "create" in result.stdout
# Should still show old commands
assert "scrape" in result.stdout
assert "github" in result.stdout
assert "analyze" in result.stdout
# Should show enhance command
assert "enhance" in result.stdout
def test_workflows_command_still_works(self):
"""The new workflows subcommand is accessible via the main CLI."""
"""The workflows subcommand is accessible via the main CLI."""
import subprocess
result = subprocess.run(
@@ -515,4 +213,29 @@ class TestBackwardCompatibility:
timeout=10,
)
assert result.returncode == 0
assert "workflow" in result.stdout.lower()
class TestRemovedCommands:
"""Test that old individual scraper commands are properly removed."""
def test_scrape_command_removed(self):
"""Old scrape command should not exist."""
import subprocess
result = subprocess.run(
["skill-seekers", "scrape", "--help"], capture_output=True, text=True, timeout=10
)
# Should fail - command removed
assert result.returncode == 2
assert "invalid choice" in result.stderr
def test_github_command_removed(self):
"""Old github command should not exist."""
import subprocess
result = subprocess.run(
["skill-seekers", "github", "--help"], capture_output=True, text=True, timeout=10
)
# Should fail - command removed
assert result.returncode == 2
assert "invalid choice" in result.stderr

View File

@@ -0,0 +1,511 @@
"""Tests for ExecutionContext singleton.
This module tests the ExecutionContext class which provides a single source
of truth for all configuration in Skill Seekers.
"""
import argparse
import json
import os
import tempfile
import pytest
from skill_seekers.cli.execution_context import (
ExecutionContext,
get_context,
)
class TestExecutionContextBasics:
"""Basic functionality tests."""
def setup_method(self):
"""Reset singleton before each test."""
ExecutionContext.reset()
def teardown_method(self):
"""Clean up after each test."""
ExecutionContext.reset()
def test_get_returns_defaults_when_not_initialized(self):
"""Should return default context when not explicitly initialized."""
ctx = ExecutionContext.get()
assert ctx is not None
assert ctx.enhancement.level == 2 # default
assert ctx.output.name is None # default
def test_get_context_shortcut(self):
"""get_context() should be equivalent to ExecutionContext.get()."""
args = argparse.Namespace(name="test-skill")
ExecutionContext.initialize(args=args)
ctx = get_context()
assert ctx.output.name == "test-skill"
def test_initialize_returns_instance(self):
"""initialize() should return the context instance."""
args = argparse.Namespace(name="test")
ctx = ExecutionContext.initialize(args=args)
assert isinstance(ctx, ExecutionContext)
assert ctx.output.name == "test"
def test_singleton_behavior(self):
"""Multiple calls should return same instance."""
args = argparse.Namespace(name="first")
ctx1 = ExecutionContext.initialize(args=args)
ctx2 = ExecutionContext.get()
assert ctx1 is ctx2
def test_reset_clears_instance(self):
"""reset() should clear the initialized instance, get() returns fresh defaults."""
args = argparse.Namespace(name="test-skill")
ExecutionContext.initialize(args=args)
assert ExecutionContext.get().output.name == "test-skill"
ExecutionContext.reset()
# After reset, get() returns default context (not the old one)
ctx = ExecutionContext.get()
assert ctx.output.name is None # default, not "test-skill"
class TestExecutionContextFromArgs:
"""Tests for building context from CLI args."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_basic_args(self):
"""Should extract basic args correctly."""
args = argparse.Namespace(
name="react-docs",
output="custom/output",
doc_version="18.2",
dry_run=True,
enhance_level=3,
agent="kimi",
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.output.name == "react-docs"
assert ctx.output.output_dir == "custom/output"
assert ctx.output.doc_version == "18.2"
assert ctx.output.dry_run is True
assert ctx.enhancement.level == 3
assert ctx.enhancement.agent == "kimi"
def test_scraping_args(self):
"""Should extract scraping args correctly."""
args = argparse.Namespace(
name="test",
max_pages=100,
rate_limit=1.5,
browser=True,
workers=4,
async_mode=True,
resume=True,
fresh=False,
skip_scrape=True,
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.scraping.max_pages == 100
assert ctx.scraping.rate_limit == 1.5
assert ctx.scraping.browser is True
assert ctx.scraping.workers == 4
assert ctx.scraping.async_mode is True
assert ctx.scraping.resume is True
assert ctx.scraping.skip_scrape is True
def test_analysis_args(self):
"""Should extract analysis args correctly."""
args = argparse.Namespace(
name="test",
depth="full",
skip_patterns=True,
skip_test_examples=True,
skip_how_to_guides=True,
file_patterns="*.py,*.js",
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.analysis.depth == "full"
assert ctx.analysis.skip_patterns is True
assert ctx.analysis.skip_test_examples is True
assert ctx.analysis.skip_how_to_guides is True
assert ctx.analysis.file_patterns == ["*.py", "*.js"]
def test_workflow_args(self):
"""Should extract workflow args correctly."""
args = argparse.Namespace(
name="test",
enhance_workflow=["security-focus", "api-docs"],
enhance_stage=["stage1:prompt1"],
var=["key1=value1", "key2=value2"],
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.enhancement.workflows == ["security-focus", "api-docs"]
assert ctx.enhancement.stages == ["stage1:prompt1"]
assert ctx.enhancement.workflow_vars == {"key1": "value1", "key2": "value2"}
def test_rag_args(self):
"""Should extract RAG args correctly."""
args = argparse.Namespace(
name="test",
chunk_for_rag=True,
chunk_tokens=1024,
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.rag.chunk_for_rag is True
assert ctx.rag.chunk_tokens == 1024
def test_api_mode_detection(self):
"""Should detect API mode from api_key."""
args = argparse.Namespace(
name="test",
api_key="test-key",
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.enhancement.mode == "api"
def test_local_mode_detection(self):
"""Should default to local/auto mode without API key."""
# Clean API key env vars to ensure test isolation
api_keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "MOONSHOT_API_KEY", "GOOGLE_API_KEY"]
saved = {k: os.environ.pop(k, None) for k in api_keys}
try:
args = argparse.Namespace(name="test")
ctx = ExecutionContext.initialize(args=args)
assert ctx.enhancement.mode in ("local", "auto")
finally:
for k, v in saved.items():
if v is not None:
os.environ[k] = v
def test_raw_args_access(self):
"""Should provide access to raw args for backward compatibility."""
args = argparse.Namespace(
name="test",
custom_field="custom_value",
)
ctx = ExecutionContext.initialize(args=args)
assert ctx.get_raw("name") == "test"
assert ctx.get_raw("custom_field") == "custom_value"
assert ctx.get_raw("nonexistent", "default") == "default"
class TestExecutionContextFromConfigFile:
"""Tests for building context from config files."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_unified_config_format(self):
"""Should load unified config with sources array."""
config = {
"name": "unity-docs",
"version": "2022.3",
"enhancement": {
"enabled": True,
"level": 2,
"mode": "local",
"agent": "kimi",
"timeout": "unlimited",
},
"workflows": ["unity-game-dev"],
"workflow_stages": ["custom:stage"],
"workflow_vars": {"var1": "value1"},
"sources": [{"type": "documentation", "base_url": "https://docs.unity3d.com/"}],
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
ctx = ExecutionContext.initialize(config_path=config_path)
assert ctx.output.name == "unity-docs"
assert ctx.output.doc_version == "2022.3"
assert ctx.enhancement.enabled is True
assert ctx.enhancement.level == 2
assert ctx.enhancement.mode == "local"
assert ctx.enhancement.agent == "kimi"
assert ctx.enhancement.workflows == ["unity-game-dev"]
assert ctx.enhancement.stages == ["custom:stage"]
assert ctx.enhancement.workflow_vars == {"var1": "value1"}
finally:
os.unlink(config_path)
def test_simple_web_config_format(self):
"""Should load simple web config format."""
config = {
"name": "react-docs",
"version": "18.2",
"base_url": "https://react.dev/",
"max_pages": 500,
"rate_limit": 0.5,
"browser": True,
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
ctx = ExecutionContext.initialize(config_path=config_path)
assert ctx.output.name == "react-docs"
assert ctx.output.doc_version == "18.2"
assert ctx.scraping.max_pages == 500
assert ctx.scraping.rate_limit == 0.5
assert ctx.scraping.browser is True
finally:
os.unlink(config_path)
def test_timeout_integer(self):
"""Should handle integer timeout in config."""
config = {
"name": "test",
"enhancement": {"timeout": 3600},
"sources": [],
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
ctx = ExecutionContext.initialize(config_path=config_path)
assert ctx.enhancement.timeout == 3600
finally:
os.unlink(config_path)
class TestExecutionContextPriority:
"""Tests for configuration priority (CLI > Config > Env > Defaults)."""
def setup_method(self):
ExecutionContext.reset()
self._original_env = {}
def teardown_method(self):
ExecutionContext.reset()
# Restore env vars
for key, value in self._original_env.items():
if value is not None:
os.environ[key] = value
else:
os.environ.pop(key, None)
def test_cli_overrides_config(self):
"""CLI args should override config file values."""
config = {"name": "config-name", "sources": []}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
args = argparse.Namespace(name="cli-name")
ctx = ExecutionContext.initialize(args=args, config_path=config_path)
# CLI should win
assert ctx.output.name == "cli-name"
finally:
os.unlink(config_path)
def test_config_overrides_defaults(self):
"""Config file should override default values."""
config = {
"name": "config-name",
"enhancement": {"level": 3},
"sources": [],
}
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(config, f)
config_path = f.name
try:
ctx = ExecutionContext.initialize(config_path=config_path)
# Config should override default (level=2)
assert ctx.enhancement.level == 3
finally:
os.unlink(config_path)
def test_env_overrides_defaults(self):
"""Environment variables should override defaults."""
self._original_env["SKILL_SEEKER_AGENT"] = os.environ.get("SKILL_SEEKER_AGENT")
os.environ["SKILL_SEEKER_AGENT"] = "claude"
ctx = ExecutionContext.initialize()
# Env var should override default (None)
assert ctx.enhancement.agent == "claude"
class TestExecutionContextSourceInfo:
"""Tests for source info integration."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_source_info_integration(self):
"""Should integrate source info from source_detector."""
class MockSourceInfo:
type = "web"
raw_source = "https://react.dev/"
parsed = {"url": "https://react.dev/"}
suggested_name = "react"
ctx = ExecutionContext.initialize(source_info=MockSourceInfo())
assert ctx.source is not None
assert ctx.source.type == "web"
assert ctx.source.raw_source == "https://react.dev/"
assert ctx.source.suggested_name == "react"
class TestExecutionContextOverride:
"""Tests for the override context manager."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_override_temporarily_changes_values(self):
"""override() should temporarily change values."""
args = argparse.Namespace(name="original", enhance_level=2)
ctx = ExecutionContext.initialize(args=args)
assert ctx.enhancement.level == 2
with ctx.override(enhancement__level=3):
ctx_from_get = ExecutionContext.get()
assert ctx_from_get.enhancement.level == 3
# After exit, original value restored
assert ExecutionContext.get().enhancement.level == 2
def test_override_restores_on_exception(self):
"""override() should restore values even on exception."""
args = argparse.Namespace(name="original", enhance_level=2)
ctx = ExecutionContext.initialize(args=args)
try:
with ctx.override(enhancement__level=3):
assert ExecutionContext.get().enhancement.level == 3
raise ValueError("Test error")
except ValueError:
pass
# Should still be restored
assert ExecutionContext.get().enhancement.level == 2
class TestExecutionContextValidation:
"""Tests for Pydantic validation."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_enhancement_level_bounds(self):
"""Enhancement level should be 0-3."""
args = argparse.Namespace(name="test", enhance_level=5)
with pytest.raises(ValueError) as exc_info:
ExecutionContext.initialize(args=args)
assert "level" in str(exc_info.value)
def test_analysis_depth_choices(self):
"""Analysis depth should reject invalid values."""
import pydantic
args = argparse.Namespace(name="test", depth="invalid")
with pytest.raises(pydantic.ValidationError):
ExecutionContext.initialize(args=args)
def test_analysis_depth_valid_choices(self):
"""Analysis depth should accept surface, deep, full."""
for depth in ("surface", "deep", "full"):
ExecutionContext.reset()
args = argparse.Namespace(name="test", depth=depth)
ctx = ExecutionContext.initialize(args=args)
assert ctx.analysis.depth == depth
class TestExecutionContextDefaults:
"""Tests for default values."""
def setup_method(self):
ExecutionContext.reset()
def teardown_method(self):
ExecutionContext.reset()
def test_default_values(self):
"""Should have sensible defaults."""
# Clear API key env vars so mode defaults to "auto" regardless of environment
api_keys = ("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "MOONSHOT_API_KEY", "GOOGLE_API_KEY")
saved = {k: os.environ.pop(k, None) for k in api_keys}
try:
ctx = ExecutionContext.initialize()
# Enhancement defaults
assert ctx.enhancement.enabled is True
assert ctx.enhancement.level == 2
assert ctx.enhancement.mode == "auto" # Default is auto, resolved at runtime
assert ctx.enhancement.timeout == 2700 # 45 minutes
finally:
for k, v in saved.items():
if v is not None:
os.environ[k] = v
# Output defaults
assert ctx.output.name is None
assert ctx.output.dry_run is False
# Scraping defaults
assert ctx.scraping.browser is False
assert ctx.scraping.workers == 1
assert ctx.scraping.languages == ["en"]
# Analysis defaults
assert ctx.analysis.depth == "surface"
assert ctx.analysis.skip_patterns is False
# RAG defaults
assert ctx.rag.chunk_for_rag is False
assert ctx.rag.chunk_tokens == 512

View File

@@ -46,31 +46,20 @@ class TestFrameworkDetection(unittest.TestCase):
" return render_template('index.html')\n"
)
# Run codebase analyzer
from skill_seekers.cli.codebase_scraper import main as scraper_main
import sys
# Run codebase analyzer directly
from skill_seekers.cli.codebase_scraper import analyze_codebase
old_argv = sys.argv
try:
sys.argv = [
"skill-seekers-codebase",
"--directory",
str(self.test_project),
"--output",
str(self.output_dir),
"--depth",
"deep",
"--ai-mode",
"none",
"--skip-patterns",
"--skip-test-examples",
"--skip-how-to-guides",
"--skip-config-patterns",
"--skip-docs",
]
scraper_main()
finally:
sys.argv = old_argv
analyze_codebase(
directory=self.test_project,
output_dir=self.output_dir,
depth="deep",
enhance_level=0,
detect_patterns=False,
extract_test_examples=False,
build_how_to_guides=False,
extract_config_patterns=False,
extract_docs=False,
)
# Verify Flask was detected
arch_file = self.output_dir / "references" / "architecture" / "architectural_patterns.json"
@@ -91,26 +80,15 @@ class TestFrameworkDetection(unittest.TestCase):
"import django\nfrom flask import Flask\nimport requests"
)
# Run codebase analyzer
from skill_seekers.cli.codebase_scraper import main as scraper_main
import sys
# Run codebase analyzer directly
from skill_seekers.cli.codebase_scraper import analyze_codebase
old_argv = sys.argv
try:
sys.argv = [
"skill-seekers-codebase",
"--directory",
str(self.test_project),
"--output",
str(self.output_dir),
"--depth",
"deep",
"--ai-mode",
"none",
]
scraper_main()
finally:
sys.argv = old_argv
analyze_codebase(
directory=self.test_project,
output_dir=self.output_dir,
depth="deep",
enhance_level=0,
)
# Verify file was analyzed
code_analysis = self.output_dir / "code_analysis.json"
@@ -143,26 +121,15 @@ class TestFrameworkDetection(unittest.TestCase):
# File with no framework imports
(app_dir / "utils.py").write_text("def my_function():\n return 'hello'\n")
# Run codebase analyzer
from skill_seekers.cli.codebase_scraper import main as scraper_main
import sys
# Run codebase analyzer directly
from skill_seekers.cli.codebase_scraper import analyze_codebase
old_argv = sys.argv
try:
sys.argv = [
"skill-seekers-codebase",
"--directory",
str(self.test_project),
"--output",
str(self.output_dir),
"--depth",
"deep",
"--ai-mode",
"none",
]
scraper_main()
finally:
sys.argv = old_argv
analyze_codebase(
directory=self.test_project,
output_dir=self.output_dir,
depth="deep",
enhance_level=0,
)
# Check frameworks detected
arch_file = self.output_dir / "references" / "architecture" / "architectural_patterns.json"

View File

@@ -52,8 +52,8 @@ class TestGitSourcesE2E:
"""Create a temporary git repository with sample configs."""
repo_dir = tempfile.mkdtemp(prefix="ss_repo_")
# Initialize git repository
repo = git.Repo.init(repo_dir)
# Initialize git repository with 'master' branch for test consistency
repo = git.Repo.init(repo_dir, initial_branch="master")
# Create sample config files
configs = {
@@ -685,8 +685,8 @@ class TestMCPToolsE2E:
"""Create a temporary git repository with sample configs."""
repo_dir = tempfile.mkdtemp(prefix="ss_mcp_repo_")
# Initialize git repository
repo = git.Repo.init(repo_dir)
# Initialize git repository with 'master' branch for test consistency
repo = git.Repo.init(repo_dir, initial_branch="master")
# Create sample config
config = {

View File

@@ -8,7 +8,6 @@ Tests verify complete fixes for:
3. Custom API endpoint support (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN)
"""
import contextlib
import os
import shutil
import subprocess
@@ -117,82 +116,48 @@ class TestIssue219Problem1LargeFiles(unittest.TestCase):
class TestIssue219Problem2CLIFlags(unittest.TestCase):
"""E2E Test: Problem #2 - CLI flags working through main.py dispatcher"""
"""E2E Test: Problem #2 - CLI flags working through create command"""
def test_github_command_has_enhancement_flags(self):
"""E2E: Verify --enhance-level flag exists in github command help"""
def test_create_command_has_enhancement_flags(self):
"""E2E: Verify --enhance-level flag exists in create command help"""
result = subprocess.run(
["skill-seekers", "github", "--help"], capture_output=True, text=True
["skill-seekers", "create", "--help"], capture_output=True, text=True
)
# VERIFY: Command succeeds
self.assertEqual(result.returncode, 0, "github --help should succeed")
self.assertEqual(result.returncode, 0, "create --help should succeed")
# VERIFY: Enhancement flags present
self.assertIn("--enhance-level", result.stdout, "Missing --enhance-level flag")
self.assertIn("--api-key", result.stdout, "Missing --api-key flag")
def test_github_command_accepts_enhance_level_flag(self):
"""E2E: Verify --enhance-level flag doesn't cause 'unrecognized arguments' error"""
# Strategy: Parse arguments directly without executing to avoid network hangs on CI
# This tests that the CLI accepts the flag without actually running the command
import argparse
def test_enhance_level_flag_accepted_by_create(self):
"""E2E: Verify --enhance-level flag is accepted by create command parser"""
from skill_seekers.cli.main import create_parser
# Get the argument parser from github_scraper
parser = argparse.ArgumentParser()
# Add the same arguments as github_scraper.main()
parser.add_argument("--repo", required=True)
parser.add_argument("--enhance-level", type=int, choices=[0, 1, 2, 3], default=2)
parser.add_argument("--api-key")
parser = create_parser()
# VERIFY: Parsing succeeds without "unrecognized arguments" error
try:
args = parser.parse_args(["--repo", "test/test", "--enhance-level", "2"])
# If we get here, argument parsing succeeded
args = parser.parse_args(["create", "owner/repo", "--enhance-level", "2"])
self.assertEqual(args.enhance_level, 2, "Flag should be parsed as 2")
self.assertEqual(args.repo, "test/test")
except SystemExit as e:
# Argument parsing failed
self.fail(f"Argument parsing failed with: {e}")
def test_cli_dispatcher_forwards_flags_to_github_scraper(self):
"""E2E: Verify main.py dispatcher forwards flags to github_scraper.py"""
from skill_seekers.cli import main
def test_github_scraper_class_accepts_enhance_level(self):
"""E2E: Verify GitHubScraper config accepts enhance_level."""
from skill_seekers.cli.github_scraper import GitHubScraper
# Mock sys.argv to simulate CLI call
test_args = [
"skill-seekers",
"github",
"--repo",
"test/test",
"--name",
"test",
"--enhance-level",
"2",
]
config = {
"repo": "test/test",
"name": "test",
"github_token": None,
"enhance_level": 2,
}
with (
patch("sys.argv", test_args),
patch("skill_seekers.cli.github_scraper.main") as mock_github_main,
):
mock_github_main.return_value = 0
# Call main dispatcher
with patch("sys.exit"), contextlib.suppress(SystemExit):
main.main()
# VERIFY: github_scraper.main was called
mock_github_main.assert_called_once()
# VERIFY: sys.argv contains --enhance-level flag
# (main.py should have added it before calling github_scraper)
called_with_enhance = any(
"--enhance-level" in str(call) for call in mock_github_main.call_args_list
)
self.assertTrue(
called_with_enhance or "--enhance-level" in sys.argv,
"Flag should be forwarded to github_scraper",
)
with patch("skill_seekers.cli.github_scraper.Github"):
scraper = GitHubScraper(config)
# Just verify it doesn't crash with enhance_level in config
self.assertIsNotNone(scraper)
@unittest.skipIf(not ANTHROPIC_AVAILABLE, "anthropic package not installed")
@@ -338,17 +303,16 @@ class TestIssue219IntegrationAll(unittest.TestCase):
def test_all_fixes_work_together(self):
"""E2E: Verify all 3 fixes work in combination"""
# This test verifies the complete workflow:
# 1. CLI accepts --enhance-level
# 1. CLI accepts --enhance-level via create command
# 2. Large files are downloaded
# 3. Custom API endpoints work
result = subprocess.run(
["skill-seekers", "github", "--help"], capture_output=True, text=True
["skill-seekers", "create", "--help"], capture_output=True, text=True
)
# Enhancement flags present
self.assertIn("--enhance-level", result.stdout)
self.assertIn("--api-key", result.stdout)
# Verify we can import all fixed modules
try:

View File

@@ -280,58 +280,69 @@ class TestScrapeDocsTool(unittest.IsolatedAsyncioTestCase):
os.chdir(self.original_cwd)
shutil.rmtree(self.temp_dir, ignore_errors=True)
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_basic(self, mock_streaming):
"""Test basic documentation scraping"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Scraping completed successfully", "", 0)
@patch("skill_seekers.mcp.tools.scraping_tools._run_converter")
@patch("skill_seekers.cli.skill_converter.get_converter")
async def test_scrape_docs_basic(self, mock_get_converter, mock_run_converter):
"""Test basic documentation scraping via in-process converter"""
from skill_seekers.mcp.tools.scraping_tools import TextContent
mock_run_converter.return_value = [
TextContent(type="text", text="Scraping completed successfully")
]
args = {"config_path": str(self.config_path)}
result = await skill_seeker_server.scrape_docs_tool(args)
self.assertIsInstance(result, list)
self.assertIn("success", result[0].text.lower())
mock_get_converter.assert_called_once()
mock_run_converter.assert_called_once()
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_skip_scrape(self, mock_streaming):
@patch("skill_seekers.mcp.tools.scraping_tools._run_converter")
@patch("skill_seekers.cli.skill_converter.get_converter")
async def test_scrape_docs_with_skip_scrape(self, mock_get_converter, mock_run_converter):
"""Test scraping with skip_scrape flag"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Using cached data", "", 0)
from skill_seekers.mcp.tools.scraping_tools import TextContent
mock_run_converter.return_value = [TextContent(type="text", text="Using cached data")]
args = {"config_path": str(self.config_path), "skip_scrape": True}
result = await skill_seeker_server.scrape_docs_tool(args)
_result = await skill_seeker_server.scrape_docs_tool(args)
self.assertIsInstance(result, list)
mock_get_converter.assert_called_once()
# Verify --skip-scrape was passed
call_args = mock_streaming.call_args[0][0]
self.assertIn("--skip-scrape", call_args)
@patch("skill_seekers.mcp.tools.scraping_tools._run_converter")
@patch("skill_seekers.cli.skill_converter.get_converter")
async def test_scrape_docs_with_dry_run(self, mock_get_converter, mock_run_converter):
"""Test scraping with dry_run flag sets converter.dry_run"""
from skill_seekers.mcp.tools.scraping_tools import TextContent
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_dry_run(self, mock_streaming):
"""Test scraping with dry_run flag"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Dry run completed", "", 0)
mock_converter = mock_get_converter.return_value
mock_run_converter.return_value = [TextContent(type="text", text="Dry run completed")]
args = {"config_path": str(self.config_path), "dry_run": True}
result = await skill_seeker_server.scrape_docs_tool(args)
_result = await skill_seeker_server.scrape_docs_tool(args)
self.assertIsInstance(result, list)
# Verify dry_run was set on the converter instance
self.assertTrue(mock_converter.dry_run)
call_args = mock_streaming.call_args[0][0]
self.assertIn("--dry-run", call_args)
@patch("skill_seekers.mcp.tools.scraping_tools._run_converter")
@patch("skill_seekers.cli.skill_converter.get_converter")
async def test_scrape_docs_with_enhance_local(self, mock_get_converter, mock_run_converter):
"""Test scraping with local enhancement flag"""
from skill_seekers.mcp.tools.scraping_tools import TextContent
@patch("skill_seekers.mcp.tools.scraping_tools.run_subprocess_with_streaming")
async def test_scrape_docs_with_enhance_local(self, mock_streaming):
"""Test scraping with local enhancement"""
# Mock successful subprocess run with streaming
mock_streaming.return_value = ("Scraping with enhancement", "", 0)
mock_run_converter.return_value = [
TextContent(type="text", text="Scraping with enhancement")
]
args = {"config_path": str(self.config_path), "enhance_local": True}
result = await skill_seeker_server.scrape_docs_tool(args)
_result = await skill_seeker_server.scrape_docs_tool(args)
call_args = mock_streaming.call_args[0][0]
self.assertIn("--enhance-local", call_args)
self.assertIsInstance(result, list)
mock_get_converter.assert_called_once()
@unittest.skipUnless(MCP_AVAILABLE, "MCP package not installed")

View File

@@ -13,8 +13,6 @@ import textwrap
import pytest
from skill_seekers.cli.config_validator import ConfigValidator
from skill_seekers.cli.main import COMMAND_MODULES
from skill_seekers.cli.parsers import PARSERS, get_parser_names
from skill_seekers.cli.source_detector import SourceDetector, SourceInfo
from skill_seekers.cli.unified_skill_builder import UnifiedSkillBuilder
@@ -554,58 +552,11 @@ class TestUnifiedSkillBuilderGenericMerge:
# ---------------------------------------------------------------------------
# 4. COMMAND_MODULES and parser wiring
# 4. New source types accessible via 'create' command
# ---------------------------------------------------------------------------
class TestCommandModules:
"""Test that all 10 new source types are wired into CLI."""
NEW_COMMAND_NAMES = [
"jupyter",
"html",
"openapi",
"asciidoc",
"pptx",
"rss",
"manpage",
"confluence",
"notion",
"chat",
]
def test_new_types_in_command_modules(self):
"""Test all 10 new source types are in COMMAND_MODULES."""
for cmd in self.NEW_COMMAND_NAMES:
assert cmd in COMMAND_MODULES, f"'{cmd}' not in COMMAND_MODULES"
def test_command_modules_values_are_module_paths(self):
"""Test COMMAND_MODULES values look like importable module paths."""
for cmd in self.NEW_COMMAND_NAMES:
module_path = COMMAND_MODULES[cmd]
assert module_path.startswith("skill_seekers.cli."), (
f"Module path for '{cmd}' doesn't start with 'skill_seekers.cli.'"
)
def test_new_parser_names_include_all_10(self):
"""Test that get_parser_names() includes all 10 new source types."""
names = get_parser_names()
for cmd in self.NEW_COMMAND_NAMES:
assert cmd in names, f"Parser '{cmd}' not registered"
def test_total_parser_count(self):
"""Test total PARSERS count is 36 (25 original + 10 new + 1 doctor)."""
assert len(PARSERS) == 36
def test_no_duplicate_parser_names(self):
"""Test no duplicate parser names exist."""
names = get_parser_names()
assert len(names) == len(set(names)), "Duplicate parser names found!"
def test_command_module_count(self):
"""Test COMMAND_MODULES has expected number of entries."""
# 25 original + 10 new + 1 doctor = 36
assert len(COMMAND_MODULES) == 36
# Individual scraper CLI commands (jupyter, html, etc.) were removed in the
# Grand Unification refactor. All 17 source types are now accessed via
# `skill-seekers create`. The routing is tested in TestCreateCommandRouting.
# ---------------------------------------------------------------------------
@@ -769,29 +720,37 @@ class TestSourceDetectorValidation:
class TestCreateCommandRouting:
"""Test that CreateCommand._route_to_scraper maps new types to _route_generic."""
"""Test that CreateCommand uses get_converter for all source types."""
# We can't easily call _route_to_scraper (it imports real scrapers),
# but we verify the routing table is correct by checking the method source.
NEW_SOURCE_TYPES = [
"jupyter",
"html",
"openapi",
"asciidoc",
"pptx",
"rss",
"manpage",
"confluence",
"notion",
"chat",
]
GENERIC_ROUTES = {
"jupyter": ("jupyter_scraper", "--notebook"),
"html": ("html_scraper", "--html-path"),
"openapi": ("openapi_scraper", "--spec"),
"asciidoc": ("asciidoc_scraper", "--asciidoc-path"),
"pptx": ("pptx_scraper", "--pptx"),
"rss": ("rss_scraper", "--feed-path"),
"manpage": ("man_scraper", "--man-path"),
"confluence": ("confluence_scraper", "--export-path"),
"notion": ("notion_scraper", "--export-path"),
"chat": ("chat_scraper", "--export-path"),
}
def test_get_converter_handles_all_new_types(self):
"""Test get_converter returns a converter for each new source type."""
from skill_seekers.cli.skill_converter import get_converter
def test_route_to_scraper_source_coverage(self):
"""Test _route_to_scraper method handles all 10 new types.
for source_type in self.NEW_SOURCE_TYPES:
# get_converter should not raise for known types
# (it may raise ImportError for missing optional deps, which is OK)
try:
converter_cls = get_converter(source_type, {"name": "test"})
assert converter_cls is not None, f"get_converter returned None for '{source_type}'"
except ImportError:
# Optional dependency not installed - that's fine
pass
We inspect the method source to verify each type has a branch.
"""
def test_route_to_scraper_uses_get_converter(self):
"""Test _route_to_scraper delegates to get_converter (not per-type branches)."""
import inspect
source = inspect.getsource(
@@ -800,24 +759,9 @@ class TestCreateCommandRouting:
fromlist=["CreateCommand"],
).CreateCommand._route_to_scraper
)
for source_type in self.GENERIC_ROUTES:
assert f'"{source_type}"' in source, (
f"_route_to_scraper missing branch for '{source_type}'"
)
def test_generic_route_module_names(self):
"""Test _route_generic is called with correct module names."""
import inspect
source = inspect.getsource(
__import__(
"skill_seekers.cli.create_command",
fromlist=["CreateCommand"],
).CreateCommand._route_to_scraper
assert "get_converter" in source, (
"_route_to_scraper should use get_converter for unified routing"
)
for source_type, (module, flag) in self.GENERIC_ROUTES.items():
assert f'"{module}"' in source, f"Module name '{module}' not found for '{source_type}'"
assert f'"{flag}"' in source, f"Flag '{flag}' not found for '{source_type}'"
if __name__ == "__main__":

View File

@@ -1,188 +0,0 @@
"""Test that unified CLI parsers stay in sync with scraper modules.
This test ensures that the unified CLI (skill-seekers <command>) has exactly
the same arguments as the standalone scraper modules. This prevents the
parsers from drifting out of sync (Issue #285).
"""
import argparse
class TestScrapeParserSync:
"""Ensure scrape_parser has all arguments from doc_scraper."""
def test_scrape_argument_count_matches(self):
"""Verify unified CLI parser has same argument count as doc_scraper."""
from skill_seekers.cli.doc_scraper import setup_argument_parser
from skill_seekers.cli.parsers.scrape_parser import ScrapeParser
# Get source arguments from doc_scraper
source_parser = setup_argument_parser()
source_count = len([a for a in source_parser._actions if a.dest != "help"])
# Get target arguments from unified CLI parser
target_parser = argparse.ArgumentParser()
ScrapeParser().add_arguments(target_parser)
target_count = len([a for a in target_parser._actions if a.dest != "help"])
assert source_count == target_count, (
f"Argument count mismatch: doc_scraper has {source_count}, "
f"but unified CLI parser has {target_count}"
)
def test_scrape_argument_dests_match(self):
"""Verify unified CLI parser has same argument destinations as doc_scraper."""
from skill_seekers.cli.doc_scraper import setup_argument_parser
from skill_seekers.cli.parsers.scrape_parser import ScrapeParser
# Get source arguments from doc_scraper
source_parser = setup_argument_parser()
source_dests = {a.dest for a in source_parser._actions if a.dest != "help"}
# Get target arguments from unified CLI parser
target_parser = argparse.ArgumentParser()
ScrapeParser().add_arguments(target_parser)
target_dests = {a.dest for a in target_parser._actions if a.dest != "help"}
# Check for missing arguments
missing = source_dests - target_dests
extra = target_dests - source_dests
assert not missing, f"scrape_parser missing arguments: {missing}"
assert not extra, f"scrape_parser has extra arguments not in doc_scraper: {extra}"
def test_scrape_specific_arguments_present(self):
"""Verify key scrape arguments are present in unified CLI."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
# Get the scrape subparser
subparsers_action = None
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
subparsers_action = action
break
assert subparsers_action is not None, "No subparsers found"
assert "scrape" in subparsers_action.choices, "scrape subparser not found"
scrape_parser = subparsers_action.choices["scrape"]
arg_dests = {a.dest for a in scrape_parser._actions if a.dest != "help"}
# Check key arguments that were missing in Issue #285
required_args = [
"interactive",
"url",
"verbose",
"quiet",
"resume",
"fresh",
"rate_limit",
"no_rate_limit",
"chunk_for_rag",
]
for arg in required_args:
assert arg in arg_dests, f"Required argument '{arg}' missing from scrape parser"
class TestGitHubParserSync:
"""Ensure github_parser has all arguments from github_scraper."""
def test_github_argument_count_matches(self):
"""Verify unified CLI parser has same argument count as github_scraper."""
from skill_seekers.cli.github_scraper import setup_argument_parser
from skill_seekers.cli.parsers.github_parser import GitHubParser
# Get source arguments from github_scraper
source_parser = setup_argument_parser()
source_count = len([a for a in source_parser._actions if a.dest != "help"])
# Get target arguments from unified CLI parser
target_parser = argparse.ArgumentParser()
GitHubParser().add_arguments(target_parser)
target_count = len([a for a in target_parser._actions if a.dest != "help"])
assert source_count == target_count, (
f"Argument count mismatch: github_scraper has {source_count}, "
f"but unified CLI parser has {target_count}"
)
def test_github_argument_dests_match(self):
"""Verify unified CLI parser has same argument destinations as github_scraper."""
from skill_seekers.cli.github_scraper import setup_argument_parser
from skill_seekers.cli.parsers.github_parser import GitHubParser
# Get source arguments from github_scraper
source_parser = setup_argument_parser()
source_dests = {a.dest for a in source_parser._actions if a.dest != "help"}
# Get target arguments from unified CLI parser
target_parser = argparse.ArgumentParser()
GitHubParser().add_arguments(target_parser)
target_dests = {a.dest for a in target_parser._actions if a.dest != "help"}
# Check for missing arguments
missing = source_dests - target_dests
extra = target_dests - source_dests
assert not missing, f"github_parser missing arguments: {missing}"
assert not extra, f"github_parser has extra arguments not in github_scraper: {extra}"
class TestUnifiedCLI:
"""Test the unified CLI main parser."""
def test_main_parser_creates_successfully(self):
"""Verify the main parser can be created without errors."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
assert parser is not None
def test_all_subcommands_present(self):
"""Verify all expected subcommands are present."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
# Find subparsers action
subparsers_action = None
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
subparsers_action = action
break
assert subparsers_action is not None, "No subparsers found"
# Check expected subcommands
expected_commands = ["scrape", "github"]
for cmd in expected_commands:
assert cmd in subparsers_action.choices, f"Subcommand '{cmd}' not found"
def test_scrape_help_works(self):
"""Verify scrape subcommand help can be generated."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
# This should not raise an exception
try:
parser.parse_args(["scrape", "--help"])
except SystemExit as e:
# --help causes SystemExit(0) which is expected
assert e.code == 0
def test_github_help_works(self):
"""Verify github subcommand help can be generated."""
from skill_seekers.cli.main import create_parser
parser = create_parser()
# This should not raise an exception
try:
parser.parse_args(["github", "--help"])
except SystemExit as e:
# --help causes SystemExit(0) which is expected
assert e.code == 0

View File

@@ -519,38 +519,5 @@ class TestJSONWorkflow(unittest.TestCase):
self.assertEqual(converter.extracted_data["total_pages"], 1)
class TestPDFCLIArguments(unittest.TestCase):
"""Test PDF subcommand CLI argument parsing via the main CLI."""
def setUp(self):
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from skill_seekers.cli.main import create_parser
self.parser = create_parser()
def test_api_key_stored_correctly(self):
"""Test --api-key is accepted and stored correctly after switching to add_pdf_arguments."""
args = self.parser.parse_args(["pdf", "--pdf", "test.pdf", "--api-key", "sk-ant-test"])
self.assertEqual(args.api_key, "sk-ant-test")
def test_enhance_level_accepted(self):
"""Test --enhance-level is accepted for pdf subcommand."""
args = self.parser.parse_args(["pdf", "--pdf", "test.pdf", "--enhance-level", "1"])
self.assertEqual(args.enhance_level, 1)
def test_enhance_workflow_accepted(self):
"""Test --enhance-workflow is accepted and stores a list."""
args = self.parser.parse_args(["pdf", "--pdf", "test.pdf", "--enhance-workflow", "minimal"])
self.assertEqual(args.enhance_workflow, ["minimal"])
def test_workflow_dry_run_accepted(self):
"""Test --workflow-dry-run is accepted."""
args = self.parser.parse_args(["pdf", "--pdf", "test.pdf", "--workflow-dry-run"])
self.assertTrue(args.workflow_dry_run)
if __name__ == "__main__":
unittest.main()

View File

@@ -207,100 +207,6 @@ class TestPresetApplication:
PresetManager.apply_preset("nonexistent", args)
class TestDeprecationWarnings:
"""Test deprecation warning functionality."""
def test_check_deprecated_flags_quick(self, capsys):
"""Test deprecation warning for --quick flag."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=True, comprehensive=False, depth=None, ai_mode="auto")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" in captured.out
assert "--quick" in captured.out
assert "--preset quick" in captured.out
assert "v4.0.0" in captured.out
def test_check_deprecated_flags_comprehensive(self, capsys):
"""Test deprecation warning for --comprehensive flag."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=False, comprehensive=True, depth=None, ai_mode="auto")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" in captured.out
assert "--comprehensive" in captured.out
assert "--preset comprehensive" in captured.out
assert "v4.0.0" in captured.out
def test_check_deprecated_flags_depth(self, capsys):
"""Test deprecation warning for --depth flag."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=False, comprehensive=False, depth="full", ai_mode="auto")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" in captured.out
assert "--depth full" in captured.out
assert "--preset comprehensive" in captured.out
assert "v4.0.0" in captured.out
def test_check_deprecated_flags_ai_mode(self, capsys):
"""Test deprecation warning for --ai-mode flag."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=False, comprehensive=False, depth=None, ai_mode="api")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" in captured.out
assert "--ai-mode api" in captured.out
assert "--enhance-level" in captured.out
assert "v4.0.0" in captured.out
def test_check_deprecated_flags_multiple(self, capsys):
"""Test deprecation warnings for multiple flags."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=True, comprehensive=False, depth="surface", ai_mode="local")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" in captured.out
assert "--depth surface" in captured.out
assert "--ai-mode local" in captured.out
assert "--quick" in captured.out
assert "MIGRATION TIP" in captured.out
assert "v4.0.0" in captured.out
def test_check_deprecated_flags_none(self, capsys):
"""Test no warnings when no deprecated flags used."""
from skill_seekers.cli.codebase_scraper import _check_deprecated_flags
import argparse
args = argparse.Namespace(quick=False, comprehensive=False, depth=None, ai_mode="auto")
_check_deprecated_flags(args)
captured = capsys.readouterr()
assert "DEPRECATED" not in captured.out
assert "v4.0.0" not in captured.out
class TestBackwardCompatibility:
"""Test backward compatibility with old flags."""

View File

@@ -574,62 +574,6 @@ def test_config_file_validation():
os.unlink(config_path)
# ===========================
# Unified CLI Argument Tests
# ===========================
class TestUnifiedCLIArguments:
"""Test that unified subcommand parser exposes the expected CLI flags."""
@pytest.fixture
def parser(self):
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from skill_seekers.cli.main import create_parser
return create_parser()
def test_api_key_stored_correctly(self, parser):
"""Test --api-key KEY is stored in args."""
args = parser.parse_args(["unified", "--config", "my.json", "--api-key", "sk-ant-test"])
assert args.api_key == "sk-ant-test"
def test_enhance_level_stored_correctly(self, parser):
"""Test --enhance-level 2 is stored in args."""
args = parser.parse_args(["unified", "--config", "my.json", "--enhance-level", "2"])
assert args.enhance_level == 2
def test_enhance_level_default_is_none(self, parser):
"""Test --enhance-level defaults to None (per-source values apply)."""
args = parser.parse_args(["unified", "--config", "my.json"])
assert args.enhance_level is None
def test_enhance_level_all_choices(self, parser):
"""Test all valid --enhance-level choices are accepted."""
for level in [0, 1, 2, 3]:
args = parser.parse_args(
["unified", "--config", "my.json", "--enhance-level", str(level)]
)
assert args.enhance_level == level
def test_enhance_workflow_accepted(self, parser):
"""Test --enhance-workflow is accepted."""
args = parser.parse_args(
["unified", "--config", "my.json", "--enhance-workflow", "security-focus"]
)
assert args.enhance_workflow == ["security-focus"]
def test_api_key_and_enhance_level_combined(self, parser):
"""Test --api-key and --enhance-level can be combined."""
args = parser.parse_args(
["unified", "--config", "my.json", "--api-key", "sk-ant-test", "--enhance-level", "3"]
)
assert args.api_key == "sk-ant-test"
assert args.enhance_level == 3
# ===========================
# Workflow JSON Config Tests
# ===========================

View File

@@ -168,35 +168,32 @@ class TestScrapeAllSourcesRouting:
class TestScrapeDocumentation:
"""_scrape_documentation() writes a temp config and runs doc_scraper as subprocess."""
"""_scrape_documentation() calls scrape_documentation() directly."""
def test_subprocess_called_with_config_and_fresh_flag(self, tmp_path):
"""subprocess.run is called with --config and --fresh for the doc scraper."""
def test_scrape_documentation_called_directly(self, tmp_path):
"""scrape_documentation is called directly (not via subprocess)."""
scraper = _make_scraper(tmp_path=tmp_path)
source = {"base_url": "https://docs.example.com/", "type": "documentation"}
with patch("skill_seekers.cli.unified_scraper.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
with patch("skill_seekers.cli.doc_scraper.scrape_documentation") as mock_scrape:
mock_scrape.return_value = 1 # simulate failure
scraper._scrape_documentation(source)
assert mock_run.called
cmd_args = mock_run.call_args[0][0]
assert "--fresh" in cmd_args
assert "--config" in cmd_args
assert mock_scrape.called
def test_nothing_appended_on_subprocess_failure(self, tmp_path):
"""If subprocess returns non-zero, scraped_data["documentation"] stays empty."""
def test_nothing_appended_on_scrape_failure(self, tmp_path):
"""If scrape_documentation returns non-zero, scraped_data["documentation"] stays empty."""
scraper = _make_scraper(tmp_path=tmp_path)
source = {"base_url": "https://docs.example.com/", "type": "documentation"}
with patch("skill_seekers.cli.unified_scraper.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="err")
with patch("skill_seekers.cli.doc_scraper.scrape_documentation") as mock_scrape:
mock_scrape.return_value = 1
scraper._scrape_documentation(source)
assert scraper.scraped_data["documentation"] == []
def test_llms_txt_url_forwarded_to_doc_config(self, tmp_path):
"""llms_txt_url from source is forwarded to the temporary doc config."""
"""llms_txt_url from source is forwarded to the doc config."""
scraper = _make_scraper(tmp_path=tmp_path)
source = {
"base_url": "https://docs.example.com/",
@@ -204,30 +201,21 @@ class TestScrapeDocumentation:
"llms_txt_url": "https://docs.example.com/llms.txt",
}
written_configs = []
captured_config = {}
original_json_dump = json.dumps
def fake_scrape(config, ctx=None): # noqa: ARG001
captured_config.update(config)
return 1 # fail so we don't need to set up output files
def capture_dump(obj, f, **kwargs):
if isinstance(f, str):
return original_json_dump(obj, f, **kwargs)
written_configs.append(obj)
return original_json_dump(obj)
with (
patch("skill_seekers.cli.unified_scraper.subprocess.run") as mock_run,
patch(
"skill_seekers.cli.unified_scraper.json.dump",
side_effect=lambda obj, _f, **_kw: written_configs.append(obj),
),
):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
with patch("skill_seekers.cli.doc_scraper.scrape_documentation", side_effect=fake_scrape):
scraper._scrape_documentation(source)
assert any("llms_txt_url" in s for c in written_configs for s in c.get("sources", [c]))
# The llms_txt_url should be in the sources list of the doc config
sources = captured_config.get("sources", [])
assert any("llms_txt_url" in s for s in sources)
def test_start_urls_forwarded_to_doc_config(self, tmp_path):
"""start_urls from source is forwarded to the temporary doc config."""
"""start_urls from source is forwarded to the doc config."""
scraper = _make_scraper(tmp_path=tmp_path)
source = {
"base_url": "https://docs.example.com/",
@@ -235,19 +223,17 @@ class TestScrapeDocumentation:
"start_urls": ["https://docs.example.com/intro"],
}
written_configs = []
captured_config = {}
with (
patch("skill_seekers.cli.unified_scraper.subprocess.run") as mock_run,
patch(
"skill_seekers.cli.unified_scraper.json.dump",
side_effect=lambda obj, _f, **_kw: written_configs.append(obj),
),
):
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
def fake_scrape(config, ctx=None): # noqa: ARG001
captured_config.update(config)
return 1
with patch("skill_seekers.cli.doc_scraper.scrape_documentation", side_effect=fake_scrape):
scraper._scrape_documentation(source)
assert any("start_urls" in s for c in written_configs for s in c.get("sources", [c]))
sources = captured_config.get("sources", [])
assert any("start_urls" in s for s in sources)
# ===========================================================================

View File

@@ -728,13 +728,12 @@ class TestVideoArguments(unittest.TestCase):
args = parser.parse_args([])
self.assertEqual(args.enhance_level, 0)
def test_unified_parser_has_video(self):
"""Test video subcommand is registered in main parser."""
from skill_seekers.cli.main import create_parser
def test_video_accessible_via_create(self):
"""Test video source is accessible via 'create' command (not as subcommand)."""
from skill_seekers.cli.source_detector import SourceDetector
parser = create_parser()
args = parser.parse_args(["video", "--url", "https://youtube.com/watch?v=test"])
self.assertEqual(args.url, "https://youtube.com/watch?v=test")
info = SourceDetector.detect("https://youtube.com/watch?v=test")
self.assertEqual(info.type, "video")
# =============================================================================

View File

@@ -711,21 +711,16 @@ class TestVideoArgumentSetup(unittest.TestCase):
class TestVideoScraperSetupEarlyExit(unittest.TestCase):
"""Test that --setup exits before source validation."""
"""Test that --setup triggers run_setup via video setup module."""
@patch("skill_seekers.cli.video_setup.run_setup", return_value=0)
def test_setup_skips_source_validation(self, mock_setup):
"""--setup without --url should NOT error about missing source."""
from skill_seekers.cli.video_scraper import main
def test_setup_runs_successfully(self, mock_setup):
"""run_setup(interactive=True) should return 0 on success."""
from skill_seekers.cli.video_setup import run_setup
old_argv = sys.argv
try:
sys.argv = ["video_scraper", "--setup"]
rc = main()
assert rc == 0
mock_setup.assert_called_once_with(interactive=True)
finally:
sys.argv = old_argv
rc = run_setup(interactive=True)
assert rc == 0
mock_setup.assert_called_once_with(interactive=True)
if __name__ == "__main__":

View File

@@ -572,61 +572,6 @@ class TestWordJSONWorkflow(unittest.TestCase):
self.assertTrue(skill_md.exists())
class TestWordCLIArguments(unittest.TestCase):
"""Test word subcommand CLI argument parsing via the main CLI."""
def setUp(self):
import sys
from pathlib import Path as P
sys.path.insert(0, str(P(__file__).parent.parent / "src"))
from skill_seekers.cli.main import create_parser
self.parser = create_parser()
def test_docx_argument_accepted(self):
"""--docx flag is accepted for the word subcommand."""
args = self.parser.parse_args(["word", "--docx", "test.docx"])
self.assertEqual(args.docx, "test.docx")
def test_api_key_accepted(self):
"""--api-key is accepted for word subcommand."""
args = self.parser.parse_args(["word", "--docx", "test.docx", "--api-key", "sk-ant-test"])
self.assertEqual(args.api_key, "sk-ant-test")
def test_enhance_level_accepted(self):
"""--enhance-level is accepted for word subcommand."""
args = self.parser.parse_args(["word", "--docx", "test.docx", "--enhance-level", "1"])
self.assertEqual(args.enhance_level, 1)
def test_enhance_workflow_accepted(self):
"""--enhance-workflow is accepted and stores a list."""
args = self.parser.parse_args(
["word", "--docx", "test.docx", "--enhance-workflow", "minimal"]
)
self.assertEqual(args.enhance_workflow, ["minimal"])
def test_workflow_dry_run_accepted(self):
"""--workflow-dry-run is accepted."""
args = self.parser.parse_args(["word", "--docx", "test.docx", "--workflow-dry-run"])
self.assertTrue(args.workflow_dry_run)
def test_dry_run_accepted(self):
"""--dry-run is accepted for word subcommand."""
args = self.parser.parse_args(["word", "--docx", "test.docx", "--dry-run"])
self.assertTrue(args.dry_run)
def test_from_json_accepted(self):
"""--from-json is accepted."""
args = self.parser.parse_args(["word", "--from-json", "data.json"])
self.assertEqual(args.from_json, "data.json")
def test_name_accepted(self):
"""--name is accepted."""
args = self.parser.parse_args(["word", "--docx", "test.docx", "--name", "myskill"])
self.assertEqual(args.name, "myskill")
class TestWordHelperFunctions(unittest.TestCase):
"""Test module-level helper functions."""