Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -211,3 +211,6 @@ outputs/
.DS_Store
doc-issues.json
pdf_ready.json
ui-tests.json
doc-source.json
spec.md
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CHANGELOG.md following Keep a Changelog format
- Copyright header template for Python files
- Updated README.md with project overview and quickstart
- `packages/services/coverage_matrix` — new `living-doc-service-coverage-matrix` package
- `loader.py` — I/O boundary: loads `doc-source.json` (bare array or envelope) and `ui-tests.json`
- `matcher.py` — pure function: builds `CoverageMatrix` from parsed doc and test lists
- `summary.py` — pure tallying: `compute_us_summary()` / `compute_summary()` (deprecated ACs excluded from `coverage_pct`)
- `model/coverage_item.py` — output dataclasses serialising to `coverage-matrix.json`
- `service.py` — orchestration with optional `--fail-under` threshold enforcement
- `schema/coverage-matrix-v1.0.0-schema.json` — JSON Schema draft-07 for the output contract
- `living-doc coverage-matrix` CLI command (`apps/cli/src/living_doc_cli/commands/coverage_matrix.py`)
- `make py-qa-coverage` target for the new service package

### Changed

Expand Down
15 changes: 12 additions & 3 deletions DEVELOPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ pip install -e packages/core[dev]
pip install -e packages/datasets_pdf[dev]
pip install -e packages/adapters/collector_gh[dev]
pip install -e packages/services/normalize_issues[dev]
pip install -e packages/services/coverage_matrix[dev]
pip install -e "apps/cli[dev]"
```

Expand All @@ -53,6 +54,7 @@ pip install -e "apps/cli[dev]"
| `packages/datasets_pdf` | `living-doc-datasets-pdf` | Pydantic models and JSON schemas for PDF contracts |
| `packages/adapters/collector_gh` | `living-doc-adapter-collector-gh` | Detector and parser for collector-gh output |
| `packages/services/normalize_issues` | `living-doc-service-normalize-issues` | Issue normalization service |
| `packages/services/coverage_matrix` | `living-doc-service-coverage-matrix` | AC-level test coverage matrix generator |
| `apps/cli` | `living-doc-cli` | CLI entry point (`living-doc` command) |

Each package has its own `pyproject.toml`, `src/` layout, and `tests/` directory.
Expand Down Expand Up @@ -103,6 +105,7 @@ make py-qa-core # packages/core
make py-qa-datasets-pdf # packages/datasets_pdf
make py-qa-collector-gh # packages/adapters/collector_gh
make py-qa-normalize # packages/services/normalize_issues
make py-qa-coverage # packages/services/coverage_matrix
make py-qa-cli # apps/cli
```

Expand Down Expand Up @@ -151,7 +154,7 @@ pylint src/living_doc_core/json_utils.py
From the **repository root**:

```shell
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues apps/cli; do
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues packages/services/coverage_matrix apps/cli; do
echo "=== Pylint: $pkg ==="
(cd "$pkg" && pylint $(git ls-files '*.py' | grep -v '^tests/'))
done
Expand Down Expand Up @@ -211,7 +214,7 @@ mypy .
From the **repository root**:

```shell
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues apps/cli; do
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues packages/services/coverage_matrix apps/cli; do
echo "=== mypy: $pkg ==="
(cd "$pkg" && mypy .)
done
Expand Down Expand Up @@ -242,7 +245,7 @@ pytest --cov=src -v tests/ --cov-fail-under=80
From the **repository root**:

```shell
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues apps/cli; do
for pkg in packages/core packages/datasets_pdf packages/adapters/collector_gh packages/services/normalize_issues packages/services/coverage_matrix apps/cli; do
echo "=== Tests: $pkg ==="
(cd "$pkg" && pytest --cov=src -v tests/ --cov-fail-under=80)
done
Expand Down Expand Up @@ -304,6 +307,12 @@ living-doc normalize-issues \
--source auto \
--document-title "Sprint 42 Report" \
--document-version "1.0.0"

living-doc coverage-matrix \
--doc-input doc-source.json \
--tests-input ui-tests.json \
--output coverage-matrix.json \
--fail-under 80
```

## Branch Naming Convention (PID:H-1)
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
PACKAGES := packages/core \
packages/datasets_pdf \
packages/adapters/collector_gh \
packages/services/normalize_issues
packages/services/normalize_issues \
packages/services/coverage_matrix
APPS := apps/cli
ALL_TARGETS := $(PACKAGES) $(APPS)

Expand All @@ -30,6 +31,7 @@ install: ## Install all packages with [dev] dependencies
$(PYTHON) -m pip install -e packages/datasets_pdf[dev]
$(PYTHON) -m pip install -e packages/adapters/collector_gh[dev]
$(PYTHON) -m pip install -e packages/services/normalize_issues[dev]
$(PYTHON) -m pip install -e packages/services/coverage_matrix[dev]
$(PYTHON) -m pip install -e "apps/cli[dev]"
@echo "$(GREEN)✓ All packages installed$(NC)"

Expand All @@ -53,6 +55,7 @@ help: ## Show this help message
@echo " make py-qa-datasets-pdf Run all QA on packages/datasets_pdf"
@echo " make py-qa-collector-gh Run all QA on packages/adapters/collector_gh"
@echo " make py-qa-normalize Run all QA on packages/services/normalize_issues"
@echo " make py-qa-coverage Run all QA on packages/services/coverage_matrix"
@echo " make py-qa-cli Run all QA on apps/cli"
@echo ""
@echo "$(YELLOW)Run individual checks by package:$(NC)"
Expand All @@ -62,7 +65,7 @@ help: ## Show this help message
@echo " make pytest-unit-core Run tests on packages/core"
@echo ""
@echo "$(YELLOW)Package shortcuts (replace 'core' with any package):$(NC)"
@echo " - datasets_pdf, collector_gh, normalize, cli"
@echo " - datasets_pdf, collector_gh, normalize, coverage, cli"

# ============================================================================
# ALL PACKAGES: Aggregated QA targets
Expand Down Expand Up @@ -111,6 +114,9 @@ py-qa-collector-gh: black-packages/adapters/collector_gh pylint-packages/adapter
py-qa-normalize: black-packages/services/normalize_issues pylint-packages/services/normalize_issues mypy-packages/services/normalize_issues pytest-unit-packages/services/normalize_issues
@echo "$(GREEN)✓ All QA checks passed for packages/services/normalize_issues$(NC)"

py-qa-coverage: black-packages/services/coverage_matrix pylint-packages/services/coverage_matrix mypy-packages/services/coverage_matrix pytest-unit-packages/services/coverage_matrix
@echo "$(GREEN)✓ All QA checks passed for packages/services/coverage_matrix$(NC)"

py-qa-cli: black-apps/cli pylint-apps/cli mypy-apps/cli pytest-unit-apps/cli
@echo "$(GREEN)✓ All QA checks passed for apps/cli$(NC)"

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ living-doc normalize-issues \
--source auto \
--document-title "Sprint 42 Report" \
--document-version "1.0.0"

# Generate an AC-level test coverage matrix
living-doc coverage-matrix \
--doc-input doc-source.json \
--tests-input ui-tests.json \
--output coverage-matrix.json \
--fail-under 80
```

## Documentation
Expand All @@ -78,6 +85,11 @@ Converts collector output (`doc-issues.json`) into PDF-ready canonical JSON (`pd
- [Recipe: Local usage](docs/recipes/local-normalize-issues.md) — Run the CLI on your machine
- [Recipe: GitHub Actions](docs/recipes/github-actions-normalize-issues.md) — CI/CD workflow integration

### `coverage-matrix`
Cross-references a `doc-source.json` (User Stories + acceptance criteria) with a `ui-tests.json` (E2E test scenarios) and produces `coverage-matrix.json`: an AC-level test coverage matrix per User Story.

- [Package README](packages/services/coverage_matrix/README.md) — Matching logic, CLI reference, module layout

## License

Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for full details.
1 change: 1 addition & 0 deletions apps/cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ requires-python = ">=3.14"
dependencies = [
"living-doc-core",
"living-doc-service-normalize-issues",
"living-doc-service-coverage-matrix",
"click>=8.1.0,<9.0.0"
]

Expand Down
76 changes: 76 additions & 0 deletions apps/cli/src/living_doc_cli/commands/coverage_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Copyright 2026 ABSA Group Limited. Apache License, Version 2.0.

"""
coverage-matrix CLI command.
"""

import sys

import click

from living_doc_core.errors import ToolkitError # type: ignore[import-untyped]
from living_doc_service_coverage_matrix.service import run_service # type: ignore[import-untyped]


@click.command("coverage-matrix")
@click.option(
"--doc-input",
"doc_input",
required=True,
type=click.Path(exists=False),
help="Path to the US+AC doc JSON file (doc-source.json / doc-issues.json)",
)
@click.option(
"--tests-input",
"tests_input",
required=True,
type=click.Path(exists=False),
help="Path to the ui-tests JSON file",
)
@click.option(
"--output",
"output_path",
required=True,
type=click.Path(),
help="Destination path for coverage-matrix.json",
)
@click.option(
"--fail-under",
"fail_under",
type=float,
default=None,
help="Exit with code 1 if coverage_pct is below this threshold",
)
@click.option("--verbose", is_flag=True, help="Enable verbose logging")
@click.pass_context
def coverage_matrix( # pylint: disable=too-many-arguments,too-many-positional-arguments
ctx: click.Context,
doc_input: str,
tests_input: str,
output_path: str,
fail_under: float | None,
verbose: bool,
) -> None:
"""
Generate an AC-level test coverage matrix.

Cross-references doc-source output (User Stories + acceptance criteria) with
ui-tests output (test scenarios) into coverage-matrix.json.
"""
global_verbose = ctx.obj.get("verbose", False) if ctx.obj else False
options = {
"verbose": verbose or global_verbose,
"fail_under": fail_under,
}

try:
run_service(doc_input, tests_input, output_path, options)
click.echo(f"Successfully generated coverage matrix -> {output_path}")

except ToolkitError as e:
click.echo(f"Error: {e.message}", err=True)
sys.exit(1)

except Exception as e: # pylint: disable=broad-exception-caught
click.echo(f"Error: Unexpected error: {e}", err=True)
sys.exit(1)
2 changes: 2 additions & 0 deletions apps/cli/src/living_doc_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import click

from living_doc_cli import __version__
from living_doc_cli.commands.coverage_matrix import coverage_matrix
from living_doc_cli.commands.normalize_issues import normalize_issues


Expand All @@ -27,6 +28,7 @@ def cli(ctx: click.Context, verbose: bool) -> None:


cli.add_command(normalize_issues)
cli.add_command(coverage_matrix)


if __name__ == "__main__":
Expand Down
111 changes: 111 additions & 0 deletions apps/cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,114 @@ def test_normalize_issues_unexpected_error(mock_run_service, runner):
assert result.exit_code == 1
assert "Unexpected error:" in result.output
assert "Unexpected failure" in result.output


def test_cli_help_lists_coverage_matrix(runner):
"""Test that coverage-matrix appears in the top-level help."""
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "coverage-matrix" in result.output


def test_coverage_matrix_help(runner):
"""Test that coverage-matrix --help displays usage information."""
result = runner.invoke(cli, ["coverage-matrix", "--help"])
assert result.exit_code == 0
assert "--doc-input" in result.output
assert "--tests-input" in result.output
assert "--output" in result.output
assert "--fail-under" in result.output


def test_coverage_matrix_missing_required_args(runner):
"""Test that missing required arguments shows an error."""
result = runner.invoke(cli, ["coverage-matrix"])
assert result.exit_code != 0
assert "Missing option" in result.output or "Error" in result.output


@patch("living_doc_cli.commands.coverage_matrix.run_service")
def test_coverage_matrix_success(mock_run_service, runner):
"""Test successful execution of coverage-matrix command."""
mock_run_service.return_value = None

result = runner.invoke(
cli,
[
"coverage-matrix",
"--doc-input",
"doc.json",
"--tests-input",
"tests.json",
"--output",
"out.json",
],
)

assert result.exit_code == 0
assert "Successfully generated coverage matrix" in result.output
mock_run_service.assert_called_once()

call_args = mock_run_service.call_args
assert call_args[0][0] == "doc.json"
assert call_args[0][1] == "tests.json"
assert call_args[0][2] == "out.json"
options = call_args[0][3]
assert options["verbose"] is False
assert options["fail_under"] is None


@patch("living_doc_cli.commands.coverage_matrix.run_service")
def test_coverage_matrix_with_fail_under_and_verbose(mock_run_service, runner):
"""Test coverage-matrix with --fail-under and --verbose options."""
mock_run_service.return_value = None

result = runner.invoke(
cli,
[
"coverage-matrix",
"--doc-input",
"doc.json",
"--tests-input",
"tests.json",
"--output",
"out.json",
"--fail-under",
"75",
"--verbose",
],
)

assert result.exit_code == 0
options = mock_run_service.call_args[0][3]
assert options["verbose"] is True
assert options["fail_under"] == 75.0


@patch("living_doc_cli.commands.coverage_matrix.run_service")
def test_coverage_matrix_toolkit_error_exits_1(mock_run_service, runner):
"""Test exit code 1 when the service raises a ToolkitError."""
mock_run_service.side_effect = InvalidInputError("Doc input must contain an 'items' array")

result = runner.invoke(
cli,
["coverage-matrix", "--doc-input", "doc.json", "--tests-input", "tests.json", "--output", "out.json"],
)

assert result.exit_code == 1
assert "Doc input must contain an 'items' array" in result.output


@patch("living_doc_cli.commands.coverage_matrix.run_service")
def test_coverage_matrix_unexpected_error_exits_1(mock_run_service, runner):
"""Test exit code 1 for unexpected errors."""
mock_run_service.side_effect = RuntimeError("boom")

result = runner.invoke(
cli,
["coverage-matrix", "--doc-input", "doc.json", "--tests-input", "tests.json", "--output", "out.json"],
)

assert result.exit_code == 1
assert "Unexpected error:" in result.output
assert "boom" in result.output
Loading
Loading