Skip to content

fix(quality): eliminate silent failures in cli_documentation scripts (#878 #879 #880)#886

Open
rysweet wants to merge 5 commits intomainfrom
fix/issue-878-879-880-cli-documentation-quality
Open

fix(quality): eliminate silent failures in cli_documentation scripts (#878 #879 #880)#886
rysweet wants to merge 5 commits intomainfrom
fix/issue-878-879-880-cli-documentation-quality

Conversation

@rysweet
Copy link
Copy Markdown
Owner

@rysweet rysweet commented Mar 19, 2026

Summary

Fixes three quality issues in the scripts/cli_documentation/ package:

Additional supporting changes:

  • models.py: Adds DocumentationError exception class (exported via __all__)
  • scripts/__init__.py: New empty file making scripts/ a proper Python package
  • pyproject.toml: Adds [tool.pytest.ini_options] pythonpath = ["."] and dev dependencies
  • uv.lock: Updated to reflect dependency changes

Related Issues

Closes #878
Closes #879
Closes #880

Step 16b: Outside-In Testing Results

Scenario 1 — Corrupt JSON raises DocumentationError (fix for #879)

Command: python -c "from scripts.cli_documentation.hasher import CLIHasher; CLIHasher('/tmp/corrupt.json')"
Result: PASS
Output: DocumentationError: Hash file /tmp/corrupt.json is corrupt: Expecting property name...

Scenario 2 — Missing hash file returns empty dict without error

Command: python -c "CLIHasher('/tmp/nonexistent.json')" (file does not exist)
Result: PASS
Output: No exception raised; hashes initialized to empty dict

Scenario 3 — Missing 'command' field raises DocumentationError (fix for #880)

Command: ExampleManager.load_examples('vm') with YAML lacking 'command' field
Result: PASS
Output: DocumentationError: Example entry in 'vm.yaml' is missing required field 'command'

Scenario 4 — Path traversal in command name raises ValueError (fix for #878)

Command: ExampleManager.load_examples('../etc/passwd')
Result: PASS
Output: ValueError: Invalid command name: ../etc/passwd

Scenario 5 — UTF-8 unicode roundtrip works end-to-end (fix for #879)

Command: Write unicode YAML (日本語テスト héllo wörld), load via ExampleManager
Result: PASS
Output: Description preserved exactly after save/load roundtrip

All 41 unit tests pass. Fix iterations: 0 (no fixes required during outside-in testing).

Resolves #878 by propagating errors instead of swallowing them
Resolves #879 by enforcing UTF-8 encoding in all file I/O
Resolves #880 by validating required 'command' field in examples

Changes:
- Add DocumentationError exception class to models.py
- example_manager.py: propagate ValueError, use encoding='utf-8', validate required fields
- extractor.py: raise DocumentationError instead of printing warnings
- hasher.py: use encoding='utf-8', raise on failure
- sync_manager.py: use encoding='utf-8', narrow except clauses
- scripts/__init__.py: new empty file making scripts/ a Python package
- pyproject.toml: add pythonpath and dev dependencies for tests
- Add comprehensive test suite in tests/unit/test_cli_documentation.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

📊 Test Coverage Report — PR #886

PR: fix(quality): eliminate silent failures in cli_documentation scripts (#878 #879 #880)


Coverage Impact Assessment

This PR adds 535 lines of new tests (41 test methods across 13 test classes) targeting the scripts/cli_documentation/ package — the first dedicated unit tests for this package.

Metric Value
New test file tests/unit/test_cli_documentation.py
Test classes 13
Test methods 41
Source lines changed ~96 net additions across 5 modules

Estimated coverage change: 44% → ~47–49% (+3–5%)

This is a meaningful positive contribution toward the 80% target.


Modules Covered by New Tests ✅

Module Tests Added Coverage Focus
scripts/cli_documentation/hasher.py (210 lines) TestHasherCorruptFile, TestHasherUtf8, TestHasherComputeHash, TestHasherHasChanged, TestHasherCompareHashes, TestHasherClearHashes Broad — corrupt files, UTF-8, hash computation, change detection, clear
scripts/cli_documentation/example_manager.py (236 lines) TestExampleManagerCorruptFile, TestExampleManagerUtf8, TestExampleManagerSanitize, TestExampleManagerInvalidCommandName, TestExampleManagerLoadAll Broad — corrupt YAML, UTF-8 round-trip, sanitization, validation
scripts/cli_documentation/models.py (190 lines) TestDocumentationError, TestChangeSet DocumentationError exception class and ChangeSet model

Modules Still Uncovered — High Priority for Next PR 🎯

Module Lines Priority Suggested Tests
scripts/cli_documentation/extractor.py 277 🔴 Critical Test extract_command with valid Click commands; test DocumentationError raised on bad module paths
scripts/cli_documentation/sync_manager.py 238 🔴 Critical Test sync_all, sync_command; test _sanitize_path_component edge cases
scripts/cli_documentation/generator.py 207 🟡 Medium Test markdown generation from CLIMetadata fixtures
scripts/cli_documentation/validator.py 178 🟡 Medium Test validation rules against known-good and known-bad markdown
scripts/cli_documentation/__init__.py 73 🟢 Low Smoke test public exports
scripts/generate_docs.py 827 🔴 Critical Large script with no tests — high value to cover
scripts/validate_documentation.py 461 🔴 Critical No tests — validation logic should be unit-tested
src/azlin/rust_bridge.py 225 🟡 Medium Core bridge to Rust layer

What This PR Does Well ✅


Suggested Next Steps (Toward 80%)

  1. Add TestExtractorBasic — mock a minimal Click command and verify extract_command returns correct CLIMetadata. This alone could add ~3% coverage.
  2. Add TestSyncManagerBasic — test sync_all with a single mocked command. Covers the orchestration layer.
  3. Add TestGeneratorMarkdown — construct a CLIMetadata object and assert the generated markdown contains expected headings.

These three additions could push coverage to ~55–58%, making solid progress toward the monthly goal of 52%.


Progress Toward 80% Goal 📈

Current:  ████████░░░░░░░░░░░░  44%
After PR: █████████░░░░░░░░░░░  ~47–49%
Target:   ████████████████████  80%

Month 1 goal (44% → 52%): On track — this PR contributes ~3–5 points. One more focused test PR for extractor.py + sync_manager.py would reach the monthly milestone.


Thank you for adding these regression tests alongside the bug fixes — this is exactly the kind of test-with-your-fix discipline that builds toward 80% coverage! 🎯

Generated by Test Coverage Improvement Tracker for issue #886

@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

Note: This PR is primarily a code quality fix (issues #878, #879, #880), not a Dependabot dependency update. However, it does introduce new dev dependencies via pyproject.toml and uv.lock.


New Dev Dependencies Added

Package Version Type
click 8.3.1 Dev dependency
pytest >=9.0.2 (locked: latest) Dev dependency
pyyaml latest Dev dependency
colorama 0.4.6 Transitive (Windows only, via click)
iniconfig 2.3.0 Transitive (via pytest)
packaging 26.0 Transitive (via pytest)
pluggy latest Transitive (via pytest)

Priority: Low — all changes are dev-only dependencies with no impact on production runtime.


Key Changes

  • [dependency-groups] dev added to pyproject.toml: introduces click, pytest>=9.0.2, and pyyaml as dev dependencies to support the new test suite (tests/unit/test_cli_documentation.py, 535 lines)
  • uv.lock: Updated to lock in all new dev dependency packages at well-known stable versions
  • No changes to runtime/production dependencies

Risk Assessment

  • Breaking changes: No — all additions are dev-only
  • Security risk: None identified — click 8.3.1, pytest, pyyaml, colorama 0.4.6 are all well-established, widely-used packages with no known active CVEs at these versions
  • Test coverage: The PR introduces 535 lines of new tests specifically for the changed code — coverage is good
  • Compatibility: pytest>=9.0.2 is a modern constraint; ensure CI Python version aligns (PR targets Python ≥ 3.11 per pyproject.toml, which is compatible)
  • Recommendation: Merge — low-risk dev dependencies supporting a quality improvement PR

Action Items

  • Review changelog — dev deps at stable, known-good versions
  • Run tests locally — PR author confirms uv run pytest tests/unit/test_cli_documentation.py -v passes
  • Check for breaking changes — none; all deps are dev-only
  • Confirm CI passes before merge (currently mergeable_state: unstable — awaiting CI results)

Generated by Dependency Review and Prioritization for issue #886

Per TDD methodology (Step 7), write tests that:
- FAIL on main (DocumentationError does not yet exist in models.py)
- PASS once the implementation in this PR is merged

Test coverage (41 tests across 5 groups):

TestDocumentationError (3):
  - DocumentationError is an Exception subclass
  - Can be raised with a message
  - Exported in models.__all__

TestCLIHasherErrorHandling (9):
  - Corrupt JSON raises DocumentationError (not silent {})
  - Missing file returns empty dict (first-run behaviour)
  - JSONDecodeError is chained via 'from e' (SEC-R-14)
  - save_hashes() uses encoding='utf-8' (SEC-R-10)
  - Unicode hash round-trip
  - OSError on save raises DocumentationError

TestExampleManagerValidation (16):
  - Missing/empty/None 'command' field raises DocumentationError (SEC-R-11)
  - Valid 'command' field returns examples
  - load/save use encoding='utf-8' (SEC-R-10)
  - Unicode content survives round-trip
  - ValueError from _sanitize_command_name propagates (SEC-R-08)
  - yaml.safe_load rejects !!python/object (SEC-R-09)
  - Corrupt YAML raises DocumentationError

TestDocSyncManagerExceptionNarrowing (8):
  - DocumentationError is caught; AttributeError/TypeError propagate (SEC-R-12)
  - write_text() uses encoding='utf-8' (SEC-R-10)
  - Path traversal rejected in _get_output_path

TestCLIExtractorSecurity (5):
  - ALLOWED_MODULES whitelist unchanged (SEC-R-13)
  - Unlisted module rejected
  - Parse failure raises DocumentationError (not silent None)
  - Missing command returns None (not found != error)
  - yaml.load() absence verified in source

Also adds tests/conftest.py with repo-root sys.path setup so both
azlin and scripts.cli_documentation are importable during tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
import re

# Match 'yaml.load(' but not 'yaml.safe_load('
forbidden = re.findall(r"\byaml\.load\s*\(", source)

Check failure

Code scanning / CodeQL

Potentially uninitialized local variable Error test

Local variable 'source' may be used before it is initialized.

Copilot Autofix

AI 11 days ago

In general, to fix “potentially uninitialized local variable” issues, ensure the variable is definitely assigned along all code paths before it is used. This can be done by initializing it with a safe default before any conditional logic, or by restructuring the code so that any use of the variable is confined to blocks that only execute when assignment has succeeded.

For this specific test, the cleanest fix without changing behavior is to initialize source to None before the try block, and then only run the re.findall and assertion if source is not None. If inspect.getsource raises OSError, we already pytest.skip the test; if it raises any other exception, source will remain None and we should let that exception propagate (so the test errors rather than silently doing nothing). To preserve that behavior while satisfying the analyzer, we can add an else clause to the try/except and move the re import and subsequent logic into that else, which only executes if the try block succeeded and source is definitely initialized.

Concretely:

  • In tests/unit/test_cli_documentation.py, in TestCLIExtractorSecurity.test_yaml_load_not_used_in_source, wrap the re import, the forbidden computation, and the assert inside a try/except else block, so they only run when inspect.getsource succeeds.
  • No new imports or helper functions are required; we only restructure existing lines.
Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -797,13 +797,13 @@
             source = inspect.getsource(extractor_module)
         except OSError:
             pytest.skip("Source not available for inspection")
+        else:
+            # Bare yaml.load( calls (not yaml.safe_load) are forbidden
+            import re
 
-        # Bare yaml.load( calls (not yaml.safe_load) are forbidden
-        import re
-
-        # Match 'yaml.load(' but not 'yaml.safe_load('
-        forbidden = re.findall(r"\byaml\.load\s*\(", source)
-        assert not forbidden, (
-            "extractor.py must not call yaml.load() — use yaml.safe_load() only. "
-            f"Found: {forbidden}"
-        )
+            # Match 'yaml.load(' but not 'yaml.safe_load('
+            forbidden = re.findall(r"\byaml\.load\s*\(", source)
+            assert not forbidden, (
+                "extractor.py must not call yaml.load() — use yaml.safe_load() only. "
+                f"Found: {forbidden}"
+            )
EOF
@@ -797,13 +797,13 @@
source = inspect.getsource(extractor_module)
except OSError:
pytest.skip("Source not available for inspection")
else:
# Bare yaml.load( calls (not yaml.safe_load) are forbidden
import re

# Bare yaml.load( calls (not yaml.safe_load) are forbidden
import re

# Match 'yaml.load(' but not 'yaml.safe_load('
forbidden = re.findall(r"\byaml\.load\s*\(", source)
assert not forbidden, (
"extractor.py must not call yaml.load() — use yaml.safe_load() only. "
f"Found: {forbidden}"
)
# Match 'yaml.load(' but not 'yaml.safe_load('
forbidden = re.findall(r"\byaml\.load\s*\(", source)
assert not forbidden, (
"extractor.py must not call yaml.load() — use yaml.safe_load() only. "
f"Found: {forbidden}"
)
Copilot is powered by AI and may make mistakes. Always verify output.
msg = "corrupt JSON in .cli_doc_hashes.json"
with pytest.raises(DocumentationError) as exc_info:
raise DocumentationError(msg)
assert msg in str(exc_info.value)

Check warning

Code scanning / CodeQL

Unreachable code Warning test

This statement is unreachable.

Copilot Autofix

AI 11 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

def test_exported_in_models_all(self) -> None:
"""DocumentationError must be listed in models.__all__ so callers can
do 'from scripts.cli_documentation.models import DocumentationError'."""
import scripts.cli_documentation.models as models_module

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'scripts.cli_documentation.models' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 11 days ago

General fix: avoid importing the same module both with from module import ... and import module. Keep one style. Since the file already uses from scripts.cli_documentation.models import (...) for all the needed classes, the best fix is to remove the second plain import and adjust the test_exported_in_models_all test to access __all__ without re-importing the module.

Concrete change in tests/unit/test_cli_documentation.py:

  • Remove the line import scripts.cli_documentation.models as models_module inside test_exported_in_models_all.
  • Replace uses of models_module in that test by something that doesn’t require a second import. The simplest approach that doesn’t change functionality is:
    • Build the expected __all__ as a list of names currently imported from the models module (e.g. "CLIArgument", "CLIMetadata", "CLIOption", "CommandExample", "DocumentationError").
    • Assert that __all__ exists on that module by importing it once at module level or, to avoid another import form, by importing it locally with the same from pattern. However, CodeQL’s complaint is about mixing import and from import; adding another from is fine. But we can also avoid any additional imports by turning the test into a check that DocumentationError can be imported via the from form, which is already implied by the test docstring. That’s even cleaner:
      • Use import importlib (new standard-library import at top) and importlib.import_module("scripts.cli_documentation.models") locally to obtain the module object in a way that doesn’t use a second import form for the same module.
  • This preserves behavior (we still verify that models.__all__ exists and contains "DocumentationError") while removing the duplicate import/from usage.

Specifically, adjust the body of test_exported_in_models_all:

  • Add import importlib at the top of the file (new dependency, standard library).

  • Replace:

    import scripts.cli_documentation.models as models_module
    
    assert hasattr(models_module, "__all__"), "models.py must define __all__"
    assert "DocumentationError" in models_module.__all__, (
        "DocumentationError must be in models.__all__"
    )

    with:

    import importlib
    
    models_module = importlib.import_module("scripts.cli_documentation.models")
    
    assert hasattr(models_module, "__all__"), "models.py must define __all__"
    assert "DocumentationError" in models_module.__all__, (
        "DocumentationError must be in models.__all__"
    )

This way we only use from scripts.cli_documentation.models import ... and import the module object via importlib without a direct second import of the same module.

Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -30,6 +30,7 @@
 from pathlib import Path
 from unittest.mock import MagicMock, PropertyMock, patch
 
+import importlib
 import pytest
 import yaml
 
@@ -99,7 +100,7 @@
     def test_exported_in_models_all(self) -> None:
         """DocumentationError must be listed in models.__all__ so callers can
         do 'from scripts.cli_documentation.models import DocumentationError'."""
-        import scripts.cli_documentation.models as models_module
+        models_module = importlib.import_module("scripts.cli_documentation.models")
 
         assert hasattr(models_module, "__all__"), "models.py must define __all__"
         assert "DocumentationError" in models_module.__all__, (
EOF
@@ -30,6 +30,7 @@
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, patch

import importlib
import pytest
import yaml

@@ -99,7 +100,7 @@
def test_exported_in_models_all(self) -> None:
"""DocumentationError must be listed in models.__all__ so callers can
do 'from scripts.cli_documentation.models import DocumentationError'."""
import scripts.cli_documentation.models as models_module
models_module = importlib.import_module("scripts.cli_documentation.models")

assert hasattr(models_module, "__all__"), "models.py must define __all__"
assert "DocumentationError" in models_module.__all__, (
Copilot is powered by AI and may make mistakes. Always verify output.
the same guarantee within the pytest suite.
"""
import inspect
import scripts.cli_documentation.extractor as extractor_module

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note test

Module 'scripts.cli_documentation.extractor' is imported with both 'import' and 'import from'.

Copilot Autofix

AI 11 days ago

In general, to fix this issue you remove either the from module import Name or the plain import module so that each module is only imported in one style. If you still need the imported name, you can reference it via the remaining module import (e.g., module.Name), or add a simple alias (Name = module.Name) right after the import.

Here, the cleanest fix is to stop using from scripts.cli_documentation.extractor import CLIExtractor and instead import only the module once, then reference CLIExtractor via extractor_module.CLIExtractor everywhere. Since the bottom of the file already uses a module import (import scripts.cli_documentation.extractor as extractor_module), we can move that import to the top and reuse it, eliminating the from ... import CLIExtractor statement. Concretely:

  • At the top of tests/unit/test_cli_documentation.py, remove the from scripts.cli_documentation.extractor import CLIExtractor line.
  • Add a single module-level import, e.g. import scripts.cli_documentation.extractor as extractor_module, alongside the other scripts.cli_documentation.* imports.
  • Replace all uses of CLIExtractor in the file with extractor_module.CLIExtractor.
  • Remove the bottom local import in test_yaml_load_not_used_in_source and use the already-imported extractor_module there (inspect.getsource(extractor_module)).

This keeps functionality identical (same class and module used) while satisfying the CodeQL rule and simplifying imports.

Suggested changeset 1
tests/unit/test_cli_documentation.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/unit/test_cli_documentation.py b/tests/unit/test_cli_documentation.py
--- a/tests/unit/test_cli_documentation.py
+++ b/tests/unit/test_cli_documentation.py
@@ -49,7 +49,7 @@
 from scripts.cli_documentation.example_manager import ExampleManager
 from scripts.cli_documentation.hasher import CLIHasher
 from scripts.cli_documentation.sync_manager import DocSyncManager
-from scripts.cli_documentation.extractor import CLIExtractor
+import scripts.cli_documentation.extractor as extractor_module
 
 
 # ---------------------------------------------------------------------------
@@ -715,7 +715,7 @@
         SEC-R-13: new modules must be explicitly reviewed before addition.
         """
         expected = {"azlin.cli", "azlin.storage", "azlin.context"}
-        actual = set(CLIExtractor.ALLOWED_MODULES)
+        actual = set(extractor_module.CLIExtractor.ALLOWED_MODULES)
         assert actual == expected, (
             f"ALLOWED_MODULES changed unexpectedly.\n"
             f"  Expected: {sorted(expected)}\n"
@@ -725,7 +725,7 @@
     def test_unlisted_module_rejected(self) -> None:
         """extract_command() must return None (with a warning) when the module
         path is not in ALLOWED_MODULES — this prevents arbitrary code execution."""
-        extractor = CLIExtractor()
+        extractor = extractor_module.CLIExtractor()
         result = extractor.extract_command("os.path", "join")
         assert result is None, (
             "Modules not in ALLOWED_MODULES must be rejected (return None)"
@@ -768,7 +768,7 @@
         error (raises DocumentationError) — these two cases are distinct.
         """
 
-        extractor = CLIExtractor()
+        extractor = extractor_module.CLIExtractor()
 
         # Mock the module so importlib.import_module succeeds, but the attribute
         # is not a Click.Command instance (simulating a missing command).
@@ -791,7 +791,6 @@
         the same guarantee within the pytest suite.
         """
         import inspect
-        import scripts.cli_documentation.extractor as extractor_module
 
         try:
             source = inspect.getsource(extractor_module)
EOF
@@ -49,7 +49,7 @@
from scripts.cli_documentation.example_manager import ExampleManager
from scripts.cli_documentation.hasher import CLIHasher
from scripts.cli_documentation.sync_manager import DocSyncManager
from scripts.cli_documentation.extractor import CLIExtractor
import scripts.cli_documentation.extractor as extractor_module


# ---------------------------------------------------------------------------
@@ -715,7 +715,7 @@
SEC-R-13: new modules must be explicitly reviewed before addition.
"""
expected = {"azlin.cli", "azlin.storage", "azlin.context"}
actual = set(CLIExtractor.ALLOWED_MODULES)
actual = set(extractor_module.CLIExtractor.ALLOWED_MODULES)
assert actual == expected, (
f"ALLOWED_MODULES changed unexpectedly.\n"
f" Expected: {sorted(expected)}\n"
@@ -725,7 +725,7 @@
def test_unlisted_module_rejected(self) -> None:
"""extract_command() must return None (with a warning) when the module
path is not in ALLOWED_MODULESthis prevents arbitrary code execution."""
extractor = CLIExtractor()
extractor = extractor_module.CLIExtractor()
result = extractor.extract_command("os.path", "join")
assert result is None, (
"Modules not in ALLOWED_MODULES must be rejected (return None)"
@@ -768,7 +768,7 @@
error (raises DocumentationError) — these two cases are distinct.
"""

extractor = CLIExtractor()
extractor = extractor_module.CLIExtractor()

# Mock the module so importlib.import_module succeeds, but the attribute
# is not a Click.Command instance (simulating a missing command).
@@ -791,7 +791,6 @@
the same guarantee within the pytest suite.
"""
import inspect
import scripts.cli_documentation.extractor as extractor_module

try:
source = inspect.getsource(extractor_module)
Copilot is powered by AI and may make mistakes. Always verify output.
@github-actions
Copy link
Copy Markdown
Contributor

Dependency Review

Note: This PR is a code quality fix, not a Dependabot dependency update. The dependency changes are minor dev-only additions introduced to support the new test suite.


Dependency Changes

Package Change Type
pytest>=9.0.2 New addition Dev dependency
click New addition Dev dependency
pyyaml New addition Dev dependency

Priority: Low — all additions are dev-only dependencies ([dependency-groups] dev), not production runtime dependencies. They are already likely transitive dependencies of the project.


Risk Assessment

  • Breaking changes: None. Dev dependencies only; no production runtime impact.
  • Security surface: No new network-facing or privileged code introduced via these deps.
  • uv.lock churn: +145/-1 lines — consistent with resolving click, pytest>=9.0.2, and pyyaml plus their transitive deps. No unexpected packages pinned.
  • pythonpath = ["."] in [tool.pytest.ini_options]: Makes scripts/ importable during test runs. Standard pattern; no side effects outside of pytest invocation.

Code Quality Review (Primary Purpose of PR)

Issues resolved (#878, #879, #880):

Issue Fix Assessment
#878 — Silent failure propagation except blocks now raise DocumentationError instead of printing warnings and returning empty/False Correct. Swallowing errors in library code is an anti-pattern; callers need to know about failures.
#879 — Missing UTF-8 encoding All open() calls now pass encoding="utf-8" Correct. Platform-dependent default encoding causes non-reproducible failures on Windows.
#880 — Missing required field validation example_manager.py validates command field presence before constructing CommandExample Correct. Silent construction with an empty command string would produce invalid documentation.

Additional improvements noted:

  • Pre-compiled regex patterns (_VALID_NAME_RE, _VALID_PATH_RE) — avoids recompiling on every call; good practice.
  • sync_manager.py set-union for needs_sync — O(1) vs O(n) membership test; correct micro-optimization.
  • yaml.dump(..., allow_unicode=True) — prevents YAML from escaping non-ASCII characters unnecessarily.
  • DocumentationError exception class is well-documented and properly exported via __all__.

One observation: sync_manager.py:sync_command now only catches DocumentationError (not the broad except Exception). Any non-DocumentationError exception from the sync path will propagate uncaught to sync_all. This is arguably the correct behavior (fail loudly on unexpected errors), but callers of sync_all should be prepared to handle uncaught exceptions. Worth verifying the call sites handle this.


Recommendation

Merge — the changes are well-scoped, the test plan is solid (809 lines of new tests), and the dependency additions are minimal and appropriate. No blocking concerns.

Generated by Dependency Review and Prioritization for issue #886

@github-actions
Copy link
Copy Markdown
Contributor

📊 Test Coverage Report

PR: fix(quality): eliminate silent failures in cli_documentation scripts (#878 #879 #880)
Branch: fix/issue-878-879-880-cli-documentation-quality


Coverage Impact Assessment

⚠️ Note: No coverage.xml artifact was found in CI for this run, so exact line-level percentages are estimated from static analysis of the diff. The assessment below is based on code inspection.

Estimated Coverage Change: 44% → ~47–49% (+3–5%) ✅

This PR adds 41 new tests across 809 lines of test code, targeting the scripts/cli_documentation/ package (1,609 source lines across 8 files). This is a meaningful positive contribution to coverage.


Newly Covered Areas

Module Tests Added Coverage Focus
scripts/cli_documentation/hasher.py 6 tests Corrupt JSON error, missing files, UTF-8 round-trip, OSError on save
scripts/cli_documentation/example_manager.py 10 tests Missing command field validation, YAML safety, Unicode round-trip, encoding
scripts/cli_documentation/sync_manager.py 8 tests Exception narrowing (SEC-R-12), UTF-8 write, path traversal, SyncResult
scripts/cli_documentation/extractor.py 5 tests Module whitelist (SEC-R-13), parse failures, graceful not-found
scripts/cli_documentation/models.py 3 tests DocumentationError exception class, __all__ export

Still Uncovered (Priority for Next PR)

Module Est. Coverage Lines Priority
scripts/cli_documentation/generator.py ~0% 207 🔴 Critical
scripts/cli_documentation/validator.py ~0% 178 🔴 Critical
scripts/cli_documentation/extractor.py partial 277 🟡 Medium (happy path, complex extraction logic)
scripts/cli_documentation/hasher.py partial 210 🟡 Medium (concurrent write, large payload edge cases)

Highest-impact next steps:

  • generator.py — 207 lines, zero tests. This generates markdown docs; test the main generation paths, template rendering, and edge cases (empty commands, nested subcommands).
  • validator.py — 178 lines, zero tests. Test valid/invalid doc structure detection, missing fields, and schema enforcement.

Quality Highlights ✅

This PR goes beyond simple coverage — it adds security-validating tests:

  • SEC-R-08: ValueError from _sanitize_command_name propagates (not swallowed)
  • SEC-R-09: yaml.safe_load rejects !!python/object injection tags
  • SEC-R-10: All open() calls verified to use encoding='utf-8'
  • SEC-R-11: Required command field validated with DocumentationError
  • SEC-R-12: Exception narrowing — only DocumentationError caught, not AttributeError/TypeError
  • SEC-R-13: ALLOWED_MODULES whitelist enforced in extractor
  • SEC-R-14: JSONDecodeError chained via from e for proper traceback

Progress Toward 80% Goal

Current:    ████████████████████░░░░░░░░░░░░░░░░░░░░  44%
After PR:   █████████████████████░░░░░░░░░░░░░░░░░░░  ~47%
Target:     ████████████████████████████████████████  80%
Gap:        ~33 percentage points remaining

Month 1 Goal: 44% → 52% — this PR contributes meaningfully toward that target.


Recommendation

No blocking coverage concerns. Coverage is moving in the right direction.

For the next PR focused on coverage, consider targeting generator.py and validator.py — together they are ~385 lines with no tests and represent the highest-leverage opportunity to close the gap toward 80%.


Coverage estimated via static diff analysis. For precise numbers, ensure pytest-cov is configured in pyproject.toml and coverage.xml is uploaded as a CI artifact.

Generated by Test Coverage Improvement Tracker for issue #886

Ubuntu and others added 3 commits March 19, 2026 15:38
… in cli_documentation

_extract_from_click_command now raises DocumentationError instead of
returning None, so the `if metadata:` and `if sub_metadata:` guards in
the callers were unreachable dead code. Remove them and update the return
type annotation from `CLIMetadata | None` to `CLIMetadata`.

Also remove the redundant `{e}` interpolation in save_examples()
DocumentationError — the cause is already chained via `from e`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ator

hasher.py: replace bare `except Exception` with specific handlers for
  json.JSONDecodeError and OSError, each carrying a file-path-aware
  message — satisfies SEC-R-14 (exception chaining).

validator.py: pass encoding='utf-8' to path.read_text() — closes the
  last remaining instance of the locale-dependent I/O pattern from #879.

Resolves #878 (error-swallowing detail)
Closes #879 (encoding='utf-8' — final file)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PATCH bump for bug fixes in issues #878, #879, #880 (cli_documentation quality).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

Dependency & PR Review

Note: This PR is a code quality fix (issues #878, #879, #880), not a Dependabot dependency update. However, it does introduce dependency changes via pyproject.toml, so a dependency review is included below.


Dependency Changes

New dev dependencies added to [dependency-groups] dev:

Package Type Assessment
click Dev dependency Low risk — well-maintained CLI library, already likely a transitive dep
pytest>=9.0.2 Dev dependency Low risk — standard test runner, pinned to recent stable major
pyyaml Dev dependency Low risk — standard YAML parser, widely used

Priority: Low — all additions are dev-only dependencies (not shipped in production), well-established packages, and required for the new test suite introduced by this PR.

Risk Assessment:

  • Breaking changes: No — dev deps only, no runtime impact
  • Test coverage: Good — 41/41 tests pass per PR description
  • Recommendation: Merge when code scanning issues are resolved

Code Scanning Findings (GitHub Advanced Security)

Four issues flagged in tests/unit/test_cli_documentation.py — all in test code, not production code:

  1. Line 805 — Potentially uninitialized local variable source (#1966)

    • Risk: Low (test code only), but should be fixed to avoid flaky tests
  2. Line 97 — Unreachable code (#1967)

    • Risk: Low (test code only), indicates dead test logic that should be removed
  3. Line 102 — Module scripts.cli_documentation.models imported with both import and import from (#1964)

    • Risk: Low, but can cause confusion and linter noise
  4. Line 794 — Module scripts.cli_documentation.extractor imported with both import and import from (#1965)

    • Risk: Low, same as above

Action Items:

  • Fix potentially uninitialized variable source at line 805
  • Remove or fix unreachable code at line 97
  • Consolidate duplicate imports at lines 102 and 794 to use a single import style

Overall Recommendation

The production code changes (error propagation, UTF-8 encoding, required field validation) are solid quality improvements with good test coverage. The four code scanning findings are all confined to test code and are straightforward to fix before merging.

Generated by Dependency Review and Prioritization for issue #886

@github-actions
Copy link
Copy Markdown
Contributor

📊 Test Coverage Report — PR #886

41/41 tests passing


Coverage Summary (scripts/ package)

Module Coverage Lines Covered Status
scripts/__init__.py 100% 0 0
scripts/cli_documentation/__init__.py 100% 9 9
scripts/cli_documentation/models.py 94% 67 63
scripts/cli_documentation/example_manager.py 85% 62 53
scripts/cli_documentation/sync_manager.py 68% 69 47 🟡
scripts/cli_documentation/hasher.py 66% 65 43 🟡
scripts/cli_documentation/extractor.py 39% 101 39 🔴
scripts/cli_documentation/validator.py 20% 64 13 🔴
scripts/cli_documentation/generator.py 10% 98 10 🔴
scripts/audit_key_operations.py 0% 79 0
scripts/doc_sync.py 0% 97 0
scripts/extract_help.py 0% 176 0
scripts/generate_docs.py 0% 54 0
scripts/test_audit_key_operations.py 0% 71 0
scripts/validate_documentation.py 0% 228 0

Overall scripts/ coverage: 22% (277/1240 lines)


What This PR Adds

This PR introduces 41 new tests targeting the three quality issues (#878, #879, #880), with strong coverage on the modules it directly changes:

  • models.py 94% — DocumentationError class and all data structures well covered
  • example_manager.py 85% — command field validation, UTF-8 encoding, YAML safety, and ValueError propagation all covered
  • hasher.py 66% — JSON decode error chaining, UTF-8 encoding, and FileNotFoundError handling covered
  • sync_manager.py 68% — exception narrowing, write_text UTF-8, and path traversal rejection covered

The uncovered lines in each of these modules are mostly secondary branches (e.g. hasher.py:100–107 covers the full hash-computation path; sync_manager.py:79–109 covers the full sync loop). These are reasonable gaps for a targeted security/quality fix PR.


Still Uncovered — High Priority for Follow-on Work

Module Coverage Uncovered Lines Priority
scripts/cli_documentation/extractor.py 39% 74–77, 99–123, 152–164, 181, 197–247, 252–270 🔴 Critical
scripts/cli_documentation/validator.py 20% 56–94, 116–126, 130–161, 165–175 🔴 Critical
scripts/cli_documentation/generator.py 10% 40–72, 78–84, 88–204 🔴 Critical
scripts/validate_documentation.py 0% all 🟡 Medium
scripts/doc_sync.py 0% all 🟡 Medium
scripts/extract_help.py 0% all 🟡 Medium

Progress Toward 80% Goal

Baseline (pre-PR, scripts/cli_documentation/ modules only):
  models.py        ~0%  (DocumentationError didn't exist)
  hasher.py        ~0%  (no tests existed)
  example_manager  ~0%  (no tests existed)
  sync_manager     ~0%  (no tests existed)
  extractor        ~0%  (no tests existed)

After this PR:
  models.py        94%  ↑ +94
  example_manager  85%  ↑ +85
  sync_manager     68%  ↑ +68
  hasher.py        66%  ↑ +66
  extractor        39%  ↑ +39

This PR makes a significant positive contribution to the coverage baseline for the cli_documentation package. The overall 22% across all scripts reflects the many untested top-level scripts (doc_sync.py, validate_documentation.py, extract_help.py, generate_docs.py) which are outside this PR's scope.


Recommended Next Steps (to reach 80%)

  1. extractor.py (39% → target 85%): Add tests for CLIExtractor.extract(), _parse_module(), and the import loading path. These exercise the core Click introspection logic.
  2. validator.py (20% → target 85%): Add tests for CLIValidator.validate_command(), schema checks, and the full validation pipeline.
  3. generator.py (10% → target 80%): Add tests for CLIGenerator.generate_command_doc() and the markdown rendering methods using mock CLIMetadata.
  4. hasher.py (66% → target 90%): Cover the hash computation path (lines 100–107) and the save/load roundtrip for non-empty hashes.
  5. sync_manager.py (68% → target 85%): Cover the full sync loop (lines 79–109) using a fixture with mock extractor, generator, and hasher.

Great work on this PR! The TDD approach (writing failing tests first, then implementing) is exactly the right pattern. The 41 tests provide solid regression coverage for all three security/quality issues, and the coverage on the directly-modified modules is strong.

Generated by Test Coverage Improvement Tracker for issue #886

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant