Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
90 changes: 90 additions & 0 deletions docs/information/executor-metrics.md
Original file line number Diff line number Diff line change
@@ -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

<!-- docs:section purpose -->
## 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.

<!-- docs:section scope -->
## 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.

<!-- docs:section evidence -->
## 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.

<!-- docs:section content -->
## 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.

<!-- docs:section limitations -->
## 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).

<!-- docs:section next_actions -->
## 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.
23 changes: 17 additions & 6 deletions planfile/executor_standalone.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
from planfile.core.models import Strategy, Task


if __name__ == "__main__":
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)


@dataclass
Expand Down Expand Up @@ -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')}

"""

Expand All @@ -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():
Expand All @@ -264,18 +266,27 @@ 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

return {
'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


Expand Down
14 changes: 14 additions & 0 deletions project/ticket-061/README.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 13 additions & 0 deletions project/ticket-061/intent.json
Original file line number Diff line number Diff line change
@@ -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
}
78 changes: 78 additions & 0 deletions tests/test_executor_metrics.py
Original file line number Diff line number Diff line change
@@ -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
Loading