diff --git a/docs/README.md b/docs/README.md index 4e63913..5d450cf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # Generated Planfile reference Current maintained [Python API](information/python-api.md). +Optional executor context: [metrics diagnostics](information/executor-metrics.md). For maintained documentation navigation, start with [`NAVIGATION.md`](information/navigation.md). The content below is a generated diff --git a/docs/information/executor-metrics.md b/docs/information/executor-metrics.md new file mode 100644 index 0000000..7b2d083 --- /dev/null +++ b/docs/information/executor-metrics.md @@ -0,0 +1,90 @@ +--- +{ + "schema": "wellmanifest.docs/document/v1", + "id": "executor-metrics", + "kind": "information", + "version": 1, + "title": "Optional executor metrics and incomplete file reads", + "status": "implemented", + "owner": "semcod/planfile", + "created": "2026-09-09", + "updated": "2026-09-09", + "review_after": "2026-10-09", + "source_revision": "59dcc7a65f04de5cb31f4caa99df157697875058", + "affected_repositories": ["semcod/planfile"], + "evidence": [ + "https://github.com/semcod/planfile/blob/59dcc7a65f04de5cb31f4caa99df157697875058/planfile/executor_standalone.py", + "https://github.com/subactor/doctor-agent/issues/377", + "https://github.com/subactor/doctor-agent/issues/378", + "repo://semcod/planfile/tests/test_executor_metrics.py" + ] +} +--- + +# Optional executor metrics and incomplete file reads + + +## Purpose + +Make a missing portion of optional project context visible to operators and LLM +consumers. At the baseline source revision above, the standalone executor caught +all exceptions during project metrics collection without recording a diagnostic. +Doctor detected both the per-file catch and the outer fallback. + + +## Scope + +This document describes `StrategyExecutor._get_project_metrics` and the prompt +it supplies to the standalone executor. The source revision identifies the +investigated baseline; the implementation and regression tests are versioned +with this document. Publication and runtime deployment require separate receipts. + + +## Evidence + +`tests/test_executor_metrics.py` reproduces undecodable Python input, denied file +reads, enumeration failure and an unexpected programming error. Before the fix, +five regression cases failed because the collector omitted coverage state, +emitted no diagnostic, or swallowed a programming error. The suite also checks +that partial file-read coverage reaches the LLM prompt. + +The local full test run completed with 475 passed and 6 skipped tests. Six tests +cover this change. Required hosted checks and independent publication are recorded +on the delivery PR, separately from this local result. + + +## Behavior + +Expected file read errors (`OSError`, `UnicodeDecodeError`) keep the best-effort +metrics path available. The returned dictionary retains the existing metric +fields and adds `files_failed` and `complete`. The prompt includes both fields. + +The module logger emits stable warning codes: + +| Code | Meaning | +| --- | --- | +| `PLANFILE_METRICS_FILE_READ_FAILED` | A discovered Python file could not be read; its content is omitted. | +| `PLANFILE_METRICS_UNAVAILABLE` | An outer filesystem operation failed; optional metrics return `None`. | + +Warnings include the exception class only. They omit exception messages, file +paths and source contents. Unexpected programming errors propagate from the +collector to the existing caller error boundary. Module-level logger initialization +also makes that boundary usable when the executor is imported as a library. + + +## Limitations + +`complete` means no observed file-read failure among files processed by this +collector. It does not certify whole-repository coverage, filesystem snapshot +consistency, absence of symlink traversal or accurate cyclomatic complexity. +The existing keyword-count estimate and denominator remain unchanged. An outer +filesystem failure produces a warning and no metrics section in the prompt. + +These changes do not authorize repairs or replace the scanner and Planfile/GitHub +projection described in the [published Doctor integration report](https://github.com/subactor/docs/blob/9a8dc3769e2dfeddbb65c4648c42496406d98679/architecture/analysis/organism-guard-integration.md). + + +## Next actions + +Use these codes when diagnosing incomplete executor context. Resolve any follow-up +about complexity accuracy or filesystem enumeration as a separately scoped change. diff --git a/planfile/executor_standalone.py b/planfile/executor_standalone.py index 3624e7c..26263e1 100644 --- a/planfile/executor_standalone.py +++ b/planfile/executor_standalone.py @@ -10,8 +10,7 @@ from planfile.core.models import Strategy, Task -if __name__ == "__main__": - logger = logging.getLogger(__name__) +logger = logging.getLogger(__name__) @dataclass @@ -228,6 +227,8 @@ def _build_prompt(self, task: Task, project_path: str | Path) -> str: - Lines of code: {metrics.get('total_lines', 'N/A')} - Average complexity: {metrics.get('avg_cc', 'N/A')} - Max complexity: {metrics.get('max_cc', 'N/A')} + - File-read coverage complete: {metrics.get('complete', 'unknown')} + - Files omitted due to read errors: {metrics.get('files_failed', 'unknown')} """ @@ -253,6 +254,7 @@ def _get_project_metrics(self, project_path: str | Path) -> dict | None: total_lines = 0 max_cc = 0 total_files = len(py_files) + files_failed = 0 for py_file in py_files: if py_file.is_file(): @@ -264,8 +266,12 @@ def _get_project_metrics(self, project_path: str | Path) -> dict | None: # Simple CC estimation (count control flow keywords) cc = content.count(' if ') + content.count(' for ') + content.count(' while ') + content.count(' except ') max_cc = max(max_cc, cc) - except Exception: - pass + except (OSError, UnicodeDecodeError) as exc: + files_failed += 1 + logger.warning( + "PLANFILE_METRICS_FILE_READ_FAILED error_type=%s", + type(exc).__name__, + ) avg_cc = max_cc / total_files if total_files > 0 else 0 @@ -273,9 +279,14 @@ def _get_project_metrics(self, project_path: str | Path) -> dict | None: 'total_files': total_files, 'total_lines': total_lines, 'avg_cc': round(avg_cc, 1), - 'max_cc': max_cc + 'max_cc': max_cc, + 'files_failed': files_failed, + 'complete': files_failed == 0, } - except Exception: + except OSError as exc: + logger.warning( + "PLANFILE_METRICS_UNAVAILABLE error_type=%s", type(exc).__name__ + ) return None diff --git a/project/ticket-061/README.md b/project/ticket-061/README.md new file mode 100644 index 0000000..a3dbbcb --- /dev/null +++ b/project/ticket-061/README.md @@ -0,0 +1,14 @@ +# ticket-061 — Observable executor metrics failures + +Intent: retain best-effort project metrics for expected read failures, report +incomplete file-read coverage in diagnostics and LLM context, and stop swallowing +unexpected programming errors in the metrics collector. + +Inputs: [Doctor #377](https://github.com/subactor/doctor-agent/issues/377), +[Doctor #378](https://github.com/subactor/doctor-agent/issues/378). + +Acceptance: regression tests cover invalid encoding, denied reads, enumeration +failure, unexpected errors, successful reads and partial LLM context. Run the +existing test suite and repository-required checks before independent publication. + +Canonical result: [Executor metrics diagnostics](../../docs/information/executor-metrics.md). diff --git a/project/ticket-061/intent.json b/project/ticket-061/intent.json new file mode 100644 index 0000000..e11fee9 --- /dev/null +++ b/project/ticket-061/intent.json @@ -0,0 +1,13 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-061", + "summary": "Make optional executor metrics read failures observable after Doctor findings 377 and 378", + "workstream": "executor-metrics", + "classification": {"kind": "SERVICE", "priority": "P2", "origin": "health"}, + "allowedPaths": ["planfile/executor_standalone.py", "tests/test_executor_metrics.py", "docs/information/executor-metrics.md", "docs/README.md", "project/ticket-061/**"], + "forbiddenPaths": [".env", "secrets/**", ".github/**"], + "stacks": ["python"], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/tests/test_executor_metrics.py b/tests/test_executor_metrics.py new file mode 100644 index 0000000..fe30398 --- /dev/null +++ b/tests/test_executor_metrics.py @@ -0,0 +1,78 @@ +"""Regression coverage for optional context metrics and diagnostic failures.""" + +import logging +from pathlib import Path + +import pytest + +from planfile.core.models import Task +from planfile.executor_standalone import StrategyExecutor + + +def test_invalid_encoding_keeps_valid_metrics_and_reports_partial(tmp_path, caplog): + (tmp_path / "valid.py").write_text("x = 1\n", encoding="utf-8") + (tmp_path / "invalid.py").write_bytes(b"\xffprivate-source") + with caplog.at_level(logging.WARNING): + result = StrategyExecutor()._get_project_metrics(tmp_path) + assert result["total_lines"] == 1 + assert result["total_files"] == 2 + assert result["files_failed"] == 1 + assert result["complete"] is False + assert "PLANFILE_METRICS_FILE_READ_FAILED" in caplog.text + assert "UnicodeDecodeError" in caplog.text + assert "private-source" not in caplog.text + assert str(tmp_path) not in caplog.text + + +def test_permission_error_is_observable_without_exception_payload(tmp_path, monkeypatch, caplog): + (tmp_path / "denied.py").touch() + + def denied(*args, **kwargs): + raise PermissionError("private-path-and-credential") + + monkeypatch.setattr(Path, "read_text", denied) + with caplog.at_level(logging.WARNING): + result = StrategyExecutor()._get_project_metrics(tmp_path) + assert result["complete"] is False + assert result["files_failed"] == 1 + assert "PermissionError" in caplog.text + assert "private-path-and-credential" not in caplog.text + + +def test_enumeration_failure_keeps_optional_fallback_with_diagnostic(monkeypatch, caplog): + def denied(*args, **kwargs): + raise OSError("private-directory") + + monkeypatch.setattr(Path, "rglob", denied) + with caplog.at_level(logging.WARNING): + assert StrategyExecutor()._get_project_metrics(".") is None + assert "PLANFILE_METRICS_UNAVAILABLE" in caplog.text + assert "private-directory" not in caplog.text + + +def test_programming_error_is_not_silently_converted_to_metrics(tmp_path, monkeypatch): + (tmp_path / "source.py").touch() + + def broken(*args, **kwargs): + raise RuntimeError("unexpected implementation bug") + + monkeypatch.setattr(Path, "read_text", broken) + with pytest.raises(RuntimeError, match="implementation bug"): + StrategyExecutor()._get_project_metrics(tmp_path) + + +def test_readable_project_is_complete(tmp_path, caplog): + (tmp_path / "source.py").write_text("x = 1\n", encoding="utf-8") + result = StrategyExecutor()._get_project_metrics(tmp_path) + assert result["complete"] is True + assert result["files_failed"] == 0 + assert not caplog.records + + +def test_llm_prompt_marks_partial_file_read_coverage(tmp_path): + (tmp_path / "invalid.py").write_bytes(b"\xff") + prompt = StrategyExecutor()._build_prompt( + Task(name="review", description="Review the project"), tmp_path + ) + assert "File-read coverage complete: False" in prompt + assert "Files omitted due to read errors: 1" in prompt