diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6199266 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Ruff lint + run: ruff check scripts/ + - name: Ruff format check + run: ruff format --check scripts/ + - name: Mypy + run: mypy scripts/ --ignore-missing-imports + + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: pip install -e ".[dev]" + - name: Run tests + run: pytest --cov=scripts --cov-report=xml -v + - name: Upload coverage + if: matrix.python-version == '3.12' + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + continue-on-error: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e5ec9be --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: + - types-PyYAML + - tomli>=2.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f505b83 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[project] +name = "mini-wiki" +version = "3.0.8" +description = "AI Agent skill package for automatic project documentation generation" +authors = [ + { name = "trsoliu" } +] +license = { text = "Apache-2.0" } +requires-python = ">=3.10" +dependencies = [ + "PyYAML>=6.0", + "tomli>=2.0; python_version<'3.11'", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-cov>=5.0", + "mypy>=1.10", + "ruff>=0.5.0", + "types-PyYAML", +] + +[tool.ruff] +target-version = "py310" +line-length = 120 +src = ["scripts"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "N", # pep8-naming + "UP", # pyupgrade + "B", # flake8-bugbear + "A", # flake8-builtins + "SIM", # flake8-simplify + "TCH", # flake8-type-checking + "RUF", # ruff-specific rules +] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["scripts"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..532dfa5 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Mini-Wiki test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d45c553 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +"""Shared pytest fixtures for Mini-Wiki tests.""" + +import json +from pathlib import Path +from typing import Dict + +import pytest + + +@pytest.fixture +def tmp_project(tmp_path: Path) -> Path: + """Create a temporary project directory structure.""" + # Create basic project structure + (tmp_path / "src").mkdir() + (tmp_path / "tests").mkdir() + (tmp_path / "docs").mkdir() + + # Create some sample files + (tmp_path / "src" / "main.py").write_text("def main():\n pass\n") + (tmp_path / "src" / "utils.py").write_text("def helper():\n return True\n") + (tmp_path / "README.md").write_text("# Test Project\n") + + return tmp_path + + +@pytest.fixture +def sample_markdown(tmp_path: Path) -> Path: + """Create a sample markdown file for testing.""" + content = """# Module Documentation + +## Overview +This is a test module. + +## API Reference + +### Function: test_func + +```python +def test_func(x: int) -> int: + return x * 2 +``` + +## Examples + +```python +result = test_func(5) +assert result == 10 +``` + +## Architecture + +```mermaid +flowchart TB + A[Input] --> B[Process] + B --> C[Output] +``` +""" + md_file = tmp_path / "test.md" + md_file.write_text(content) + return md_file + + +@pytest.fixture +def sample_structure() -> Dict: + """Sample project structure data.""" + return { + "project_type": ["python", "nodejs"], + "modules": [ + {"name": "core", "path": "src/core", "files": 5}, + {"name": "utils", "path": "src/utils", "files": 3}, + {"name": "api", "path": "src/api", "files": 8}, + ], + "tech_stack": ["python", "fastapi", "pytest"], + } diff --git a/tests/test_check_quality.py b/tests/test_check_quality.py new file mode 100644 index 0000000..35bb599 --- /dev/null +++ b/tests/test_check_quality.py @@ -0,0 +1,64 @@ +"""Tests for check_quality.py module.""" + +import sys +from pathlib import Path + +import pytest + +# Add scripts to path +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from check_quality import QualityMetrics, analyze_document + + +def test_analyze_document_basic(sample_markdown): + """Test basic document analysis.""" + metrics = analyze_document(str(sample_markdown)) + + assert metrics.file_path == str(sample_markdown) + assert metrics.line_count > 0 + assert metrics.section_count >= 2 # At least 2 H2 sections + assert metrics.diagram_count >= 1 # At least 1 mermaid diagram + assert metrics.code_example_count >= 1 # At least 1 code example + + +def test_analyze_document_quality_levels(tmp_path): + """Test quality level classification.""" + # Basic quality document + basic_doc = tmp_path / "basic.md" + basic_doc.write_text("""# Title +## Section 1 +Some content. +""") + + metrics = analyze_document(str(basic_doc)) + assert metrics.section_count < 8 + assert metrics.quality_level in ["basic", "standard"] + + +def test_analyze_document_nonexistent(): + """Test handling of nonexistent file.""" + metrics = analyze_document("/nonexistent/file.md") + assert len(metrics.issues) > 0 + assert "无法读取文件" in metrics.issues[0] + + +def test_analyze_document_mermaid_detection(tmp_path): + """Test mermaid diagram detection.""" + doc = tmp_path / "with_diagram.md" + doc.write_text("""# Title + +```mermaid +flowchart TB + A --> B +``` + +```mermaid +classDiagram + class Foo +``` +""") + + metrics = analyze_document(str(doc)) + assert metrics.diagram_count == 2 + assert metrics.class_diagram_count == 1 diff --git a/tests/test_detect_changes.py b/tests/test_detect_changes.py new file mode 100644 index 0000000..5d228ef --- /dev/null +++ b/tests/test_detect_changes.py @@ -0,0 +1,238 @@ +"""Tests for scripts/detect_changes.py.""" + +import json +from pathlib import Path + +from detect_changes import ( + calculate_file_hash, + should_include_file, + scan_project_files, + detect_changes, + DEFAULT_EXCLUDES, + CODE_EXTENSIONS, + DOC_EXTENSIONS, +) + + +# --- calculate_file_hash --- + + +def test_calculate_file_hash_returns_hex_string(tmp_path): + """Hash of a known file should be a 16-char hex string.""" + f = tmp_path / "hello.txt" + f.write_text("hello world", encoding="utf-8") + + result = calculate_file_hash(str(f)) + + assert isinstance(result, str) + assert len(result) == 16 + assert all(c in "0123456789abcdef" for c in result) + + +def test_calculate_file_hash_deterministic(tmp_path): + """Same content should always produce the same hash.""" + f = tmp_path / "a.txt" + f.write_text("deterministic", encoding="utf-8") + + assert calculate_file_hash(str(f)) == calculate_file_hash(str(f)) + + +def test_calculate_file_hash_different_content(tmp_path): + """Different content should produce different hashes.""" + f1 = tmp_path / "a.txt" + f1.write_text("content A", encoding="utf-8") + f2 = tmp_path / "b.txt" + f2.write_text("content B", encoding="utf-8") + + assert calculate_file_hash(str(f1)) != calculate_file_hash(str(f2)) + + +def test_calculate_file_hash_empty_file(tmp_path): + """Empty file should still return a valid hash.""" + f = tmp_path / "empty.txt" + f.write_bytes(b"") + + result = calculate_file_hash(str(f)) + assert len(result) == 16 + + +def test_calculate_file_hash_nonexistent_file(): + """Non-existent file should return empty string.""" + result = calculate_file_hash("/no/such/file.txt") + assert result == "" + + +# --- should_include_file --- + + +def test_should_include_code_files(): + """Code files with known extensions should be included.""" + for ext in [".py", ".ts", ".js", ".go", ".rs", ".vue"]: + p = Path(f"src/app{ext}") + assert should_include_file(p, DEFAULT_EXCLUDES) is True + + +def test_should_include_doc_files(): + """Documentation files should be included.""" + for ext in [".md", ".mdx", ".rst", ".txt"]: + p = Path(f"docs/readme{ext}") + assert should_include_file(p, DEFAULT_EXCLUDES) is True + + +def test_should_exclude_non_code_files(): + """Non-code, non-doc files should be excluded.""" + for name in ["image.png", "data.csv", "archive.zip", "style.css"]: + p = Path(f"assets/{name}") + assert should_include_file(p, DEFAULT_EXCLUDES) is False + + +def test_should_exclude_directories(): + """Files inside excluded directories should be excluded.""" + excluded_dirs = ["node_modules", ".git", "__pycache__", "dist", ".mini-wiki"] + for d in excluded_dirs: + p = Path(d) / "some_file.py" + assert should_include_file(p, DEFAULT_EXCLUDES) is False + + +def test_should_include_with_custom_excludes(): + """Custom exclude set should be respected.""" + p = Path("mydir/app.py") + assert should_include_file(p, {"mydir"}) is False + assert should_include_file(p, {"otherdir"}) is True + + +def test_should_exclude_glob_pattern(): + """Glob patterns like *.min.js should work via the startswith('*') logic.""" + p = Path("src/bundle.min.js") + excludes = {"*.min.js"} + assert should_include_file(p, excludes) is False + + +# --- scan_project_files --- + + +def test_scan_project_files_finds_code_and_docs(tmp_project): + """Scan should find code and doc files, skip excluded dirs.""" + result = scan_project_files(str(tmp_project)) + + assert "src/core/app.py" in result + assert "src/core/main.ts" in result + assert "src/utils/helpers.js" in result + assert "docs/readme.md" in result + + +def test_scan_project_files_excludes_node_modules(tmp_project): + """Files in node_modules should not appear.""" + result = scan_project_files(str(tmp_project)) + + for key in result: + assert "node_modules" not in key + + +def test_scan_project_files_excludes_pycache(tmp_project): + """Files in __pycache__ should not appear.""" + result = scan_project_files(str(tmp_project)) + + for key in result: + assert "__pycache__" not in key + + +def test_scan_project_files_skips_binary(tmp_project): + """Binary / non-code files like .png should not appear.""" + result = scan_project_files(str(tmp_project)) + + for key in result: + assert not key.endswith(".png") + + +def test_scan_project_files_empty_dir(tmp_path): + """Empty directory should return empty dict.""" + result = scan_project_files(str(tmp_path)) + assert result == {} + + +def test_scan_project_files_checksums_are_valid(tmp_project): + """All returned checksums should be 16-char hex strings.""" + result = scan_project_files(str(tmp_project)) + + for path, checksum in result.items(): + assert len(checksum) == 16, f"Bad checksum length for {path}" + assert all(c in "0123456789abcdef" for c in checksum) + + +# --- detect_changes --- + + +def test_detect_changes_all_new(tmp_project): + """With no cache, all files should be reported as added.""" + changes = detect_changes(str(tmp_project)) + + assert changes["has_changes"] is True + assert len(changes["added"]) > 0 + assert changes["modified"] == [] + assert changes["deleted"] == [] + assert "新增" in changes["summary"] + + +def test_detect_changes_no_changes(tmp_project): + """After caching current checksums, detect_changes should report no changes.""" + # First pass: populate cache + first = detect_changes(str(tmp_project)) + cache_dir = tmp_project / ".mini-wiki" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "checksums.json" + cache_data = { + k: {"hash": v} for k, v in first["current_checksums"].items() + } + cache_file.write_text(json.dumps(cache_data), encoding="utf-8") + + # Second pass: should detect no changes + second = detect_changes(str(tmp_project)) + + assert second["has_changes"] is False + assert second["added"] == [] + assert second["modified"] == [] + assert second["deleted"] == [] + assert "无变更" in second["summary"] + + +def test_detect_changes_modified_file(tmp_project): + """Modifying a file should be detected.""" + first = detect_changes(str(tmp_project)) + cache_dir = tmp_project / ".mini-wiki" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "checksums.json" + cache_data = { + k: {"hash": v} for k, v in first["current_checksums"].items() + } + cache_file.write_text(json.dumps(cache_data), encoding="utf-8") + + # Modify a file + (tmp_project / "src" / "core" / "app.py").write_text("print('changed')\n", encoding="utf-8") + + second = detect_changes(str(tmp_project)) + + assert second["has_changes"] is True + assert "src/core/app.py" in second["modified"] + assert "修改" in second["summary"] + + +def test_detect_changes_deleted_file(tmp_project): + """Deleting a file should be detected.""" + first = detect_changes(str(tmp_project)) + cache_dir = tmp_project / ".mini-wiki" / "cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "checksums.json" + cache_data = { + k: {"hash": v} for k, v in first["current_checksums"].items() + } + cache_file.write_text(json.dumps(cache_data), encoding="utf-8") + + # Delete a file + (tmp_project / "src" / "utils" / "helpers.js").unlink() + + second = detect_changes(str(tmp_project)) + + assert second["has_changes"] is True + assert "src/utils/helpers.js" in second["deleted"] + assert "删除" in second["summary"] diff --git a/tests/test_generate_toc.py b/tests/test_generate_toc.py new file mode 100644 index 0000000..406e761 --- /dev/null +++ b/tests/test_generate_toc.py @@ -0,0 +1,63 @@ +"""Tests for scripts/generate_toc.py.""" + +from pathlib import Path + +from generate_toc import extract_title_from_markdown, generate_toc + + +# --- extract_title_from_markdown --- + + +def test_extract_title_from_h1(tmp_path): + """Should extract the first H1 heading as the title.""" + f = tmp_path / "doc.md" + f.write_text("# My Title\n\nBody text.\n", encoding="utf-8") + + assert extract_title_from_markdown(str(f)) == "My Title" + + +def test_extract_title_skips_non_h1(tmp_path): + """Should skip H2/H3 and find the first H1.""" + f = tmp_path / "doc.md" + f.write_text("## Not this\n\n# Real Title\n", encoding="utf-8") + + assert extract_title_from_markdown(str(f)) == "Real Title" + + +def test_extract_title_fallback_to_filename(tmp_path): + """Without an H1, should derive title from filename.""" + f = tmp_path / "my-module.md" + f.write_text("No heading here.\n", encoding="utf-8") + + result = extract_title_from_markdown(str(f)) + assert "my" in result.lower() and "module" in result.lower() + + +def test_extract_title_nonexistent_file(): + """Non-existent file should return the stem of the path.""" + result = extract_title_from_markdown("/no/such/cool-feature.md") + assert "cool-feature" in result or "cool" in result.lower() + + +# --- generate_toc --- + + +def test_generate_toc_empty_dir(tmp_path): + """Empty wiki dir should return a fallback message.""" + result = generate_toc(str(tmp_path / "nonexistent")) + assert "目录为空" in result + + +def test_generate_toc_with_index(tmp_path): + """Should include index.md in the TOC.""" + wiki = tmp_path / "wiki" + wiki.mkdir() + (wiki / "index.md").write_text("# Home\n\nWelcome.\n", encoding="utf-8") + + result = generate_toc(str(wiki)) + + assert "Home" in result + assert "index.md" in result + + +# PLACEHOLDER_TOC_PART2