Guide for contributors and developers who want to work on licit.
Prerequisites
- Python 3.12+ (required; the project uses
StrEnumand other 3.12 features) - Git (for project detection tests)
- pip (comes with Python)
Verify your version:
python3.12 --version
# Python 3.12.x
Environment Setup
# Clone the repository
git clone https://github.com/Diego303/licit-cli.git
cd licit-cli
# Install in development mode with dev dependencies
python3.12 -m pip install -e ".[dev]"
# Verify the installation
licit --version
# licit, version 0.2.0
Development Dependencies
| Package | Version | Purpose |
|---|---|---|
| pytest | 8.0+ | Testing framework |
| pytest-cov | 5.0+ | Code coverage |
| ruff | 0.4+ | Linter and formatter |
| mypy | 1.9+ | Strict type checking |
Development Commands
# Tests
python3.12 -m pytest tests/ -q # Run all tests
python3.12 -m pytest tests/ -q -x # Stop at first failure
python3.12 -m pytest tests/test_cli.py -q # CLI tests only
python3.12 -m pytest tests/ -q --tb=short # Short tracebacks
python3.12 -m pytest tests/ --cov=licit # With coverage
# Linting
python3.12 -m ruff check src/licit/ # Check for errors
python3.12 -m ruff check src/licit/ --fix # Auto-fix
# Type checking
python3.12 -m mypy src/licit/ --strict # Check types (strict mode)
# CLI
python3.12 -m licit --help # General help
python3.12 -m licit init # Test init
python3.12 -m licit status # Test status
Code Structure
src/licit/
├── __init__.py # __version__ = "0.2.0"
├── __main__.py # Entry point: python -m licit
├── py.typed # PEP 561 marker
├── cli.py # All Click commands
├── config/
│ ├── schema.py # Pydantic v2 models (LicitConfig, etc.)
│ ├── loader.py # load_config(), save_config()
│ └── defaults.py # DEFAULTS, CONFIG_FILENAME, DATA_DIR
├── core/
│ ├── models.py # Enums + domain dataclasses
│ ├── project.py # ProjectDetector
│ └── evidence.py # EvidenceCollector + EvidenceBundle
├── logging/
│ └── setup.py # setup_logging(verbose)
├── provenance/ # Phase 2 (COMPLETED)
│ ├── heuristics.py # 6 AI detection heuristics
│ ├── git_analyzer.py # Git history analysis
│ ├── store.py # Append-only JSONL store
│ ├── attestation.py # HMAC-SHA256 + Merkle tree
│ ├── tracker.py # Provenance orchestrator
│ ├── report.py # Markdown report generator
│ └── session_readers/
│ ├── base.py # Protocol SessionReader
│ └── claude_code.py # Claude Code reader
├── changelog/ # Phase 3
├── frameworks/ # Phases 4-5
├── connectors/ # Phase 7
└── reports/ # Phase 6
Code Conventions
1. Pydantic Only for Configuration
# Correct — config uses Pydantic
class ProvenanceConfig(BaseModel):
enabled: bool = True
# Correct — domain uses dataclasses
@dataclass
class ProvenanceRecord:
file_path: str
source: str
2. StrEnum for Enums
# Correct — Python 3.12+
class ComplianceStatus(StrEnum):
COMPLIANT = "compliant"
# Incorrect — ruff UP042
class ComplianceStatus(str, Enum):
COMPLIANT = "compliant"
3. Protocols for Interfaces
# Correct — typing.Protocol
class Evaluator(Protocol):
def evaluate(self, evidence: EvidenceBundle) -> list[ControlResult]: ...
# Incorrect — ABC
class Evaluator(ABC):
@abstractmethod
def evaluate(self, evidence: EvidenceBundle) -> list[ControlResult]: ...
4. structlog for Logging
import structlog
logger = structlog.get_logger()
# Correct — events + structured data
logger.info("config_loaded", path=str(config_path), framework="eu-ai-act")
# Incorrect — free-text messages
logger.info(f"Config loaded from {config_path} for framework eu-ai-act")
5. Lazy Imports for Future Modules
When a command needs a module that does not yet exist, use lazy imports with type: ignore:
@main.command()
def changelog() -> None:
"""Generate agent config changelog."""
try:
from licit.changelog.renderer import ( # type: ignore[import-not-found]
ChangelogRenderer,
)
except ImportError:
click.echo("Changelog not yet implemented.")
raise SystemExit(1)
Note: Phase 2 modules (provenance) are already implemented and are imported directly without
type: ignore.
6. Ruff and mypy
- ruff with rules:
E(errors),F(f-strings),I(imports),UP(upgrades) - mypy in
--strictmode - Max line length: 100 characters
- Target: Python 3.12
Testing
Test Structure
tests/
├── conftest.py # Shared fixtures
├── test_cli.py # 13 tests
├── test_qa_edge_cases.py # 61 tests (QA Phase 1)
├── test_config/
│ ├── test_schema.py # 7 tests
│ └── test_loader.py # 9 tests
├── test_core/
│ ├── test_project.py # 12 tests
│ └── test_evidence.py # 11 tests
└── test_provenance/
├── test_heuristics.py # 23 tests
├── test_git_analyzer.py # 15 tests
├── test_store.py # 15 tests
├── test_attestation.py # 13 tests
├── test_tracker.py # 7 tests
├── test_session_reader.py # 13 tests
├── test_qa_edge_cases.py # 81 tests (QA Phase 2)
└── fixtures/ # Test data
Total: 280 tests
Available Fixtures (conftest.py)
# Temporary project with pyproject.toml
def tmp_project(tmp_path) -> Path: ...
# Temporary project with git initialized
def git_project(tmp_path) -> Path: ...
# ProjectContext factory
def make_context(root_dir, name, languages, ...) -> ProjectContext: ...
# EvidenceBundle factory
def make_evidence(has_provenance, has_fria, ...) -> EvidenceBundle: ...
Log Suppression in Tests
Tests configure structlog at CRITICAL level to avoid noise:
# tests/conftest.py
structlog.configure(
wrapper_class=structlog.make_filtering_bound_logger(logging.CRITICAL),
cache_logger_on_first_use=False,
)
Writing a New Test
# tests/test_core/test_my_module.py
def test_my_feature(tmp_project: Path) -> None:
"""Clear description of what is being tested."""
# Arrange
(tmp_project / "CLAUDE.md").write_text("# Agent config")
# Act
result = my_function(str(tmp_project))
# Assert
assert result.expected_value == "something"
Tests with Click CLI
from click.testing import CliRunner
from licit.cli import main
def test_my_command(tmp_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(main, ["my-command", "--flag"])
assert result.exit_code == 0
assert "expected text" in result.output
Adding a New CLI Command
- Define the command in
src/licit/cli.py:
@main.command()
@click.pass_context
def my_command(ctx: click.Context) -> None:
"""Command description."""
config = ctx.obj["config"]
# ... implementation ...
click.echo("Done.")
- Add tests in
tests/test_cli.py:
def test_my_command(tmp_path: Path) -> None:
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=tmp_path):
result = runner.invoke(main, ["my-command"])
assert result.exit_code == 0
- Verify:
python3.12 -m pytest tests/test_cli.py -q
python3.12 -m ruff check src/licit/cli.py
python3.12 -m mypy src/licit/cli.py --strict
Adding a New Configuration Model
- Define the model in
src/licit/config/schema.py:
class MyConfig(BaseModel):
enabled: bool = True
my_field: str = "default"
- Add it to
LicitConfig:
class LicitConfig(BaseModel):
# ... existing fields ...
my_config: MyConfig = Field(default_factory=MyConfig)
- Add tests in
tests/test_config/test_schema.py.
Recommended Workflow
1. Create feature branch
git checkout -b feat/my-feature
2. Implement
- Code in src/licit/
- Tests in tests/
3. Verify
python3.12 -m pytest tests/ -q # 280+ tests passing
python3.12 -m ruff check src/licit/ # All checks passed
python3.12 -m mypy src/licit/ --strict # No issues found
4. Commit and PR
git add src/licit/ tests/
git commit -m "feat: my new feature"
Implementation Phases
| Phase | Modules | Directory | Status |
|---|---|---|---|
| 1 | cli.py, config/, core/, logging/ | multiple | COMPLETED |
| 2 | heuristics.py, git_analyzer.py, store.py, attestation.py, tracker.py, report.py, session_readers/ | provenance/ | COMPLETED |
| 3 | watcher.py, differ.py, classifier.py, renderer.py | changelog/ | Pending |
| 4 | requirements.py, evaluator.py, fria.py, annex_iv.py, templates/ | frameworks/eu_ai_act/ | Pending |
| 5 | requirements.py, evaluator.py, templates/ | frameworks/owasp_agentic/ | Pending |
| 6 | unified.py, gap_analyzer.py, markdown.py, json_fmt.py, html.py | reports/ | Pending |
| 7 | base.py, architect.py, vigil.py | connectors/ | Pending |
Each phase has its detailed section in the implementation plan.