diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da9d72d..97e2588 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,10 @@ jobs: run: uv run --group test pytest tests -q - name: Self harness - run: uv run python-project-harness . + run: uv run --group test python-project-harness . + + - name: Agent snapshot + run: uv run --group test python-project-harness --agent-snapshot . - name: Build package run: uv build diff --git a/README.md b/README.md index e71a83b..3326175 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,15 @@ from pathlib import Path from python_lang_project_harness import ( __version__, + PythonOwnerResponsibility, + PythonVerificationProfileHint, + PythonVerificationTaskKind, assert_python_project_harness_clean, + default_python_harness_config, + plan_python_project_verification_with_config, render_python_lang_harness, render_python_reasoning_tree, + render_python_verification_plan, run_python_project_harness, ) @@ -69,15 +75,57 @@ shadows without forcing an LLM to consume the full JSON report first. In project-scoped runs, tree paths are rendered relative to the project root to avoid repeating long absolute prefixes. +`render_python_project_harness_agent_snapshot(".")` and the +`--agent-snapshot` CLI mode bundle compact policy findings, reasoning-tree +facts, verification-profile reminders, and active verification tasks into one +low-noise Agent repair surface. The snapshot uses capped module summaries, +branches, public owners, import edges, and branch-first profile candidates; it +does not print clean-run file counts or empty section summaries. + The console script follows the same render contract: ```shell python-project-harness . python-project-harness --json . +python-project-harness --agent-snapshot . python-project-harness --source-dir lib --extra-path tools --no-tests . python -m python_lang_project_harness . ``` +## Verification Planning + +Verification is a library-first Agent contract. The harness does not execute +benchmark, security, stress, or chaos tools. It plans parser-backed obligations +that external skills can satisfy with receipts or complete waivers: + +```python +config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + .with_rationale("this public API needs a security review") +) +plan = plan_python_project_verification_with_config(Path("."), config) +print(render_python_verification_plan(plan)) +``` + +Profile hints, dependency signals, receipts, waivers, task-kind mappings, and +skill bindings are configurable through `PythonVerificationPolicy` or +`[tool.python-lang-project-harness.verification]`. Parser facts win over config +hints; mismatches become `responsibility_review` tasks instead of silent trust. +`build_python_verification_profile_index(...)` exposes `active_profile_hints()` +so Agents can turn parser-suggested owners into config-ready verification +hints. Public package branches aggregate child-module public API signals, so +large packages surface owner decisions instead of one reminder per file. Report +helpers can render or persist `verification_plan.json`, +`verification_task_index.json`, and `performance_index.json` obligations; source +manifests list only source-baseline artifacts, while runtime manifests carry +the complete bundle with `project_root`. +Profile drift output includes both configured and parser-suggested +responsibilities. + ## Pytest Dev Dependency Downstream projects can load the harness through their test/dev dependency @@ -126,4 +174,4 @@ Detailed package material lives under [`docs/`](docs/index.md). GitHub Actions runs the package contract on every pull request and on pushes to the default branch: `uv sync --group test --locked`, ruff format/check, pytest, -self-harness, wheel/sdist build, and diff hygiene. +self-harness, agent snapshot, wheel/sdist build, and diff hygiene. diff --git a/development.md b/development.md index a0e7787..34b5720 100644 --- a/development.md +++ b/development.md @@ -6,7 +6,8 @@ direnv exec . uv run --group test ruff format --check src tests direnv exec . uv run --group test ruff check src tests direnv exec . uv run --group test pytest tests -q -direnv exec . uv run python-project-harness . +direnv exec . uv run --group test python-project-harness . +direnv exec . uv run --group test python-project-harness --agent-snapshot . direnv exec . uv build direnv exec . git diff --check ``` @@ -16,7 +17,7 @@ consistently. GitHub Actions runs the same validation surface without `direnv`: `uv sync --group test --locked`, ruff format/check, pytest, self-harness, package build, -and `git diff --check`. +agent snapshot, and `git diff --check`. ## Library Boundary diff --git a/docs/03_features/201_rule_catalog.md b/docs/03_features/201_rule_catalog.md index c8c5b2c..0f75c70 100644 --- a/docs/03_features/201_rule_catalog.md +++ b/docs/03_features/201_rule_catalog.md @@ -51,6 +51,8 @@ The other default packs are blocking through `Warning` or `Error` findings. resolve to parser-visible project module owners. - `PY-PROJ-R009`: console script, GUI script, and entry point targets should resolve to parser-visible project modules. +- `PY-PROJ-R010`: projects that declare the harness as a test/dev dependency + should mount a parser-visible pytest gate. - `PY-MOD-R001`: wildcard imports must become explicit imports. - `PY-MOD-R002`: library modules should not use bare `print`. - `PY-MOD-R003`: package facades with re-exports should declare `__all__`. diff --git a/docs/03_features/203_cli.md b/docs/03_features/203_cli.md index 4c8b1bf..5e94299 100644 --- a/docs/03_features/203_cli.md +++ b/docs/03_features/203_cli.md @@ -11,8 +11,8 @@ The package exposes a thin command-line adapter over the default project harness runner: ```shell -python-project-harness [--json] [--no-tests] [--source-dir DIR] [--test-dir DIR] [--extra-path PATH] [--disable-rule RULE_ID] [--block-rule RULE_ID] [PROJECT_ROOT] -python -m python_lang_project_harness [--json] [--no-tests] [--source-dir DIR] [--test-dir DIR] [--extra-path PATH] [--disable-rule RULE_ID] [--block-rule RULE_ID] [PROJECT_ROOT] +python-project-harness [--json | --agent-snapshot] [--no-tests] [--source-dir DIR] [--test-dir DIR] [--extra-path PATH] [--disable-rule RULE_ID] [--block-rule RULE_ID] [PROJECT_ROOT] +python -m python_lang_project_harness [--json | --agent-snapshot] [--no-tests] [--source-dir DIR] [--test-dir DIR] [--extra-path PATH] [--disable-rule RULE_ID] [--block-rule RULE_ID] [PROJECT_ROOT] ``` When `PROJECT_ROOT` is omitted, the current working directory is used. @@ -73,6 +73,16 @@ Use `--json` when a tool needs the structured `PythonHarnessReport` payload: python-project-harness --json . ``` +Use `--agent-snapshot` when an Agent needs capped parser facts, project +metadata, active policy findings, branch-first verification profile reminders, +and active verification tasks without clean-run counters: + +```shell +python-project-harness --agent-snapshot . +``` + +`--json` and `--agent-snapshot` are mutually exclusive. + ## Exit Codes - `0`: no configured-blocking findings diff --git a/docs/03_features/204_pytest.md b/docs/03_features/204_pytest.md index fd4972d..d7ecda7 100644 --- a/docs/03_features/204_pytest.md +++ b/docs/03_features/204_pytest.md @@ -38,6 +38,12 @@ quiet unless `--python-project-harness` is enabled. This keeps the package safe as a normal library dependency while making the policy gate easy to opt into from pytest config. +Project policy validates this wiring. If parser-owned `pyproject.toml` facts +show that a project depends on `python-lang-project-harness` for test/dev use, +the project must expose either `--python-project-harness` in pytest addopts or +an explicit `python_project_harness_test()` callable. This keeps the dependency +from becoming decorative metadata that CI can bypass. + Project-local policy can live beside pytest config in `pyproject.toml`: ```toml diff --git a/docs/03_features/205_verification.md b/docs/03_features/205_verification.md new file mode 100644 index 0000000..530f821 --- /dev/null +++ b/docs/03_features/205_verification.md @@ -0,0 +1,95 @@ +# Verification Planning + +:PROPERTIES: +:ID: 884403d80a274f9d975119079f286b5e +:TYPE: FEATURE +:STATUS: ACTIVE +:LAST_SYNC: 2026-05-03 +:END: + +Python verification planning is a library-first Agent contract. The harness +does not run benchmark, security, stress, or chaos tools. It uses parser-owned +project facts to produce external obligations that an Agent skill can satisfy +with receipts or complete waivers. + +```python +from python_lang_project_harness import ( + PythonOwnerResponsibility, + PythonVerificationProfileHint, + PythonVerificationTaskKind, + default_python_harness_config, + plan_python_project_verification_with_config, + render_python_verification_plan, +) + +config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + .with_rationale("this public API needs a security review") +) + +plan = plan_python_project_verification_with_config(".", config) +print(render_python_verification_plan(plan)) +``` + +The compact renderer emits active `[verify]` tasks and `[verify-report]` +obligations. Report helpers can render or persist `verification_plan.json`, +`verification_task_index.json`, and `performance_index.json`. Source manifests +only list source-baseline artifacts; runtime manifests carry the complete +bundle with `project_root`, so an Agent can reconstruct the verification +contract from the cache without reading source-control-only baselines first. + +## Parser Priority + +Profile hints and dependency signals are configuration, not authority. Parser +facts decide whether an owner path exists and whether a declared responsibility +matches the source tree. When configuration drifts from parser facts, the plan +emits a `responsibility_review` task instead of trusting the stale hint. + +`build_python_verification_profile_index(...)` is the low-noise discovery +surface for this policy. Each index exposes parser-suggested candidates and +`active_profile_hints()` for config-ready hints that still need attention. The +same parser-visible owner map is used by the planner, so metadata owners such +as `pyproject.toml` and script entry-point owners can be accepted without +falling into false `responsibility_review` tasks. +Profile candidates are branch-first: public package branches aggregate their +child-module public API signal, while unowned public leaves still surface as +their own candidates. When the same owner has multiple responsibilities, the +index merges them into one candidate and one config-ready hint. +For drift, compact output includes both `configured` and `suggest`, so an Agent +can patch the policy from the profile index without reparsing `pyproject.toml`. + +## Config Surface + +The verification policy supports profile hints, dependency signals, receipts, +waivers, responsibility task-kind mappings, task contracts, skill bindings, and +skill descriptors through `PythonVerificationPolicy` or +`[tool.python-lang-project-harness.verification]`. + +```toml +[tool.python-lang-project-harness.verification] +profile_hints = [ + { owner_path = "src/pkg/api.py", responsibilities = ["public_api"], task_kinds = ["security"], rationale = "authz-sensitive public API" }, +] + +[tool.python-lang-project-harness.verification.task_contracts] +security = { phase = "before_release", summary = "security skill must report authz evidence", requirements = [{ label = "authz", detail = "tenant authorization result" }] } + +[tool.python-lang-project-harness.verification.skill_bindings] +security = { skill = "python-security-review", adapter = "bandit" } + +[tool.python-lang-project-harness.verification.skill_descriptors] +python-security-review = { task_kind = "security", adapter = "bandit", summary = "run bandit plus tenant authz probes", requirements = [{ label = "bandit", detail = "bandit report artifact" }] } +``` + +When a task has a skill binding and matching descriptor, compact verification +output stays short with `skill=` and `contract_ref=`. +Agents can call `render_python_verification_skill_contracts(plan)` only when +they need to expand the referenced contract. + +:RELATIONS: +:LINKS: [Harness Boundary](../01_core/101_harness_boundary.md), [CLI](203_cli.md) +:END: diff --git a/docs/index.md b/docs/index.md index c19ab4c..f2db3b9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,9 +29,11 @@ entrypoint into a catch-all reference page. modes, and exit-code contract. - [Pytest Dev Dependency](03_features/204_pytest.md): pytest plugin entry point, one-line test helper, and downstream dev dependency examples. +- [Verification Planning](03_features/205_verification.md): parser-backed + verification tasks, profile hints, receipts, waivers, and report artifacts. :RELATIONS: -:LINKS: [Harness Boundary](01_core/101_harness_boundary.md), [Rule Catalog](03_features/201_rule_catalog.md), [Runner Modes](03_features/202_runner_modes.md), [CLI](03_features/203_cli.md), [Pytest Dev Dependency](03_features/204_pytest.md) +:LINKS: [Harness Boundary](01_core/101_harness_boundary.md), [Rule Catalog](03_features/201_rule_catalog.md), [Runner Modes](03_features/202_runner_modes.md), [CLI](03_features/203_cli.md), [Pytest Dev Dependency](03_features/204_pytest.md), [Verification Planning](03_features/205_verification.md) :END: --- diff --git a/pyproject.toml b/pyproject.toml index 69e3bc4..92ebd51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,35 @@ packages = [ [tool.uv] package = true +[tool.pytest.ini_options] +addopts = [ + "--python-project-harness", +] + +[[tool.python-lang-project-harness.verification.profile_hints]] +owner_path = "pyproject.toml" +responsibilities = ["pytest_gate"] +verification_tasks_enabled = false +rationale = "self pytest addopts and CI cover the harness gate contract" + +[[tool.python-lang-project-harness.verification.profile_hints]] +owner_path = "src/python_lang_parser/__init__.py" +responsibilities = ["public_api"] +verification_tasks_enabled = false +rationale = "self parser tests, CLI harness, and build cover the parser public facade" + +[[tool.python-lang-project-harness.verification.profile_hints]] +owner_path = "src/python_lang_project_harness/__init__.py" +responsibilities = ["public_api", "cli"] +verification_tasks_enabled = false +rationale = "self public API tests, CLI tests, pytest gate, and build cover the harness facade" + +[[tool.python-lang-project-harness.verification.profile_hints]] +owner_path = "src/python_lang_project_harness/pytest_plugin.py" +responsibilities = ["cli"] +verification_tasks_enabled = false +rationale = "self pytest addopts exercise the pytest plugin gate" + [tool.ruff] line-length = 88 target-version = "py312" diff --git a/src/python_lang_parser/__init__.py b/src/python_lang_parser/__init__.py index 6e18c86..f250c53 100644 --- a/src/python_lang_parser/__init__.py +++ b/src/python_lang_parser/__init__.py @@ -13,10 +13,12 @@ ) from ._name_policy import python_name_is_public, python_scope_is_public from ._project_model import ( + PythonProjectDependency, PythonProjectEntryPoint, PythonProjectImportName, PythonProjectMetadata, PythonProjectScript, + PythonPytestOptions, ) from ._pyproject_metadata import parse_python_project_metadata from ._reasoning_tree import ( @@ -71,10 +73,12 @@ "PythonModuleReport", "PythonModuleShape", "PythonNameBinding", + "PythonProjectDependency", "PythonProjectEntryPoint", "PythonProjectImportName", "PythonProjectMetadata", "PythonProjectScript", + "PythonPytestOptions", "PythonReference", "PythonReferenceKind", "PythonReasoningTreeBranch", diff --git a/src/python_lang_parser/_ast_collector.py b/src/python_lang_parser/_ast_collector.py index 433cbe8..5439ffb 100644 --- a/src/python_lang_parser/_ast_collector.py +++ b/src/python_lang_parser/_ast_collector.py @@ -222,6 +222,11 @@ def _visit_symbol( decorators=tuple( unparse(decorator) for decorator in node.decorator_list ), + base_classes=( + tuple(unparse(base) for base in node.bases) + if isinstance(node, ast.ClassDef) + else () + ), docstring=ast.get_docstring(node), has_annotations=symbol_has_annotations(node), is_public=is_public_name(node.name), diff --git a/src/python_lang_parser/_project_model.py b/src/python_lang_parser/_project_model.py index 63d60d6..c93ecfd 100644 --- a/src/python_lang_parser/_project_model.py +++ b/src/python_lang_parser/_project_model.py @@ -6,6 +6,28 @@ from pathlib import Path +@dataclass(frozen=True, slots=True) +class PythonProjectDependency: + """One dependency declaration from parser-owned project metadata.""" + + requirement: str + name: str + source: str + group: str | None = None + extra: str | None = None + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "requirement": self.requirement, + "name": self.name, + "source": self.source, + "group": self.group, + "extra": self.extra, + } + + @dataclass(frozen=True, slots=True) class PythonProjectImportName: """One declared import name from Python project metadata.""" @@ -74,6 +96,27 @@ def to_dict(self) -> dict[str, object]: } +@dataclass(frozen=True, slots=True) +class PythonPytestOptions: + """Parser-owned pytest options from `pyproject.toml`.""" + + addopts: tuple[str, ...] = () + + @property + def enables_python_project_harness(self) -> bool: + """Return whether pytest addopts mounts the project harness plugin.""" + + return "--python-project-harness" in self.addopts + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "addopts": list(self.addopts), + "enables_python_project_harness": self.enables_python_project_harness, + } + + @dataclass(frozen=True, slots=True) class PythonProjectMetadata: """Compact `pyproject.toml` metadata needed by parser consumers.""" @@ -88,11 +131,13 @@ class PythonProjectMetadata: build_requires: tuple[str, ...] wheel_packages: tuple[str, ...] package_roots: tuple[Path, ...] + dependencies: tuple[PythonProjectDependency, ...] = () import_names: tuple[PythonProjectImportName, ...] = () import_namespaces: tuple[PythonProjectImportName, ...] = () scripts: tuple[PythonProjectScript, ...] = () gui_scripts: tuple[PythonProjectScript, ...] = () entry_points: tuple[PythonProjectEntryPoint, ...] = () + pytest_options: PythonPytestOptions = PythonPytestOptions() def to_dict(self) -> dict[str, object]: """Return a JSON-compatible representation.""" @@ -106,6 +151,7 @@ def to_dict(self) -> dict[str, object]: "requires_python": self.requires_python, "build_backend": self.build_backend, "build_requires": list(self.build_requires), + "dependencies": [item.to_dict() for item in self.dependencies], "wheel_packages": list(self.wheel_packages), "package_roots": [str(path) for path in self.package_roots], "import_names": [item.to_dict() for item in self.import_names], @@ -113,4 +159,5 @@ def to_dict(self) -> dict[str, object]: "scripts": [item.to_dict() for item in self.scripts], "gui_scripts": [item.to_dict() for item in self.gui_scripts], "entry_points": [item.to_dict() for item in self.entry_points], + "pytest_options": self.pytest_options.to_dict(), } diff --git a/src/python_lang_parser/_pyproject_metadata.py b/src/python_lang_parser/_pyproject_metadata.py index c6a5766..97c5353 100644 --- a/src/python_lang_parser/_pyproject_metadata.py +++ b/src/python_lang_parser/_pyproject_metadata.py @@ -2,15 +2,18 @@ from __future__ import annotations +import shlex import tomllib from pathlib import Path from typing import Any from ._project_model import ( + PythonProjectDependency, PythonProjectEntryPoint, PythonProjectImportName, PythonProjectMetadata, PythonProjectScript, + PythonPytestOptions, ) @@ -33,6 +36,8 @@ def parse_python_project_metadata( has_build_system_table = isinstance(payload.get("build-system"), dict) project = _table(payload.get("project")) build_system = _table(payload.get("build-system")) + dependency_groups = _table(payload.get("dependency-groups")) + tool = _table(payload.get("tool")) wheel_packages = _hatch_wheel_packages(payload) import_names = _project_import_names(project.get("import-names")) import_namespaces = _project_import_names(project.get("import-namespaces")) @@ -57,6 +62,8 @@ def parse_python_project_metadata( scripts=_project_scripts(project.get("scripts"), kind="console"), gui_scripts=_project_scripts(project.get("gui-scripts"), kind="gui"), entry_points=_project_entry_points(project.get("entry-points")), + dependencies=_project_dependencies(project, dependency_groups), + pytest_options=_pytest_options(tool), ) @@ -176,6 +183,115 @@ def _project_entry_points(value: object) -> tuple[PythonProjectEntryPoint, ...]: return tuple(entry_points) +def _project_dependencies( + project: dict[str, Any], + dependency_groups: dict[str, Any], +) -> tuple[PythonProjectDependency, ...]: + dependencies: list[PythonProjectDependency] = [] + seen: set[tuple[str, str, str | None, str | None]] = set() + _extend_dependencies( + dependencies, + seen, + project.get("dependencies"), + source="project.dependencies", + ) + optional_dependencies = _table(project.get("optional-dependencies")) + for extra, value in sorted(optional_dependencies.items()): + if not isinstance(extra, str): + continue + _extend_dependencies( + dependencies, + seen, + value, + source="project.optional-dependencies", + extra=extra, + ) + for group, value in sorted(dependency_groups.items()): + if not isinstance(group, str): + continue + _extend_dependencies( + dependencies, + seen, + value, + source="dependency-groups", + group=group, + ) + return tuple(dependencies) + + +def _extend_dependencies( + dependencies: list[PythonProjectDependency], + seen: set[tuple[str, str, str | None, str | None]], + value: object, + *, + source: str, + group: str | None = None, + extra: str | None = None, +) -> None: + if not isinstance(value, list): + return + for item in value: + if isinstance(item, str): + requirement = item.strip() + elif isinstance(item, dict): + requirement = _dependency_object_requirement(item) + else: + continue + if not requirement: + continue + dependency = PythonProjectDependency( + requirement=requirement, + name=_dependency_name(requirement), + source=source, + group=group, + extra=extra, + ) + key = ( + dependency.requirement, + dependency.source, + dependency.group, + dependency.extra, + ) + if key in seen: + continue + seen.add(key) + dependencies.append(dependency) + + +def _dependency_object_requirement(value: dict[str, Any]) -> str: + dependency = value.get("dependency") + if isinstance(dependency, str): + return dependency.strip() + package = value.get("package") + if isinstance(package, str): + return package.strip() + return "" + + +def _dependency_name(requirement: str) -> str: + name = requirement.split(";", 1)[0].split("[", 1)[0].strip() + for marker in ("<", ">", "=", "!", "~", " "): + name = name.split(marker, 1)[0].strip() + return name + + +def _pytest_options(tool: dict[str, Any]) -> PythonPytestOptions: + pytest_table = _table(tool.get("pytest")) + ini_options = _table(pytest_table.get("ini_options")) + return PythonPytestOptions(addopts=_pytest_addopts(ini_options.get("addopts"))) + + +def _pytest_addopts(value: object) -> tuple[str, ...]: + if isinstance(value, str): + try: + return tuple(shlex.split(value)) + except ValueError: + return tuple(part for part in value.split() if part) + if isinstance(value, list): + return tuple(item for item in value if isinstance(item, str)) + return () + + def _target_facts(target: str) -> tuple[str, tuple[str, ...], tuple[str, ...]]: target_without_extras = target.split("[", 1)[0].strip() module_part, _, object_part = target_without_extras.partition(":") diff --git a/src/python_lang_parser/_symbol_model.py b/src/python_lang_parser/_symbol_model.py index a77c7a6..a7bc62d 100644 --- a/src/python_lang_parser/_symbol_model.py +++ b/src/python_lang_parser/_symbol_model.py @@ -65,6 +65,7 @@ class PythonSymbol: location: SourceLocation end_line: int | None decorators: tuple[str, ...] = field(default_factory=tuple) + base_classes: tuple[str, ...] = field(default_factory=tuple) docstring: str | None = None has_annotations: bool = False is_public: bool = False @@ -76,6 +77,7 @@ def to_dict(self) -> dict[str, object]: payload = asdict(self) payload["kind"] = self.kind.value payload["decorators"] = list(self.decorators) + payload["base_classes"] = list(self.base_classes) return payload diff --git a/src/python_lang_parser/model.py b/src/python_lang_parser/model.py index 89e6f44..26da8f6 100644 --- a/src/python_lang_parser/model.py +++ b/src/python_lang_parser/model.py @@ -12,10 +12,12 @@ ) from ._export_model import PythonExportContract, PythonExportContractKind from ._project_model import ( + PythonProjectDependency, PythonProjectEntryPoint, PythonProjectImportName, PythonProjectMetadata, PythonProjectScript, + PythonPytestOptions, ) from ._symbol_model import ( PythonAssignmentTarget, @@ -114,10 +116,12 @@ def to_dict(self) -> dict[str, Any]: "PythonModuleReport", "PythonModuleShape", "PythonNameBinding", + "PythonProjectDependency", "PythonProjectEntryPoint", "PythonProjectImportName", "PythonProjectMetadata", "PythonProjectScript", + "PythonPytestOptions", "PythonReference", "PythonReferenceKind", "PythonScope", diff --git a/src/python_lang_project_harness/__init__.py b/src/python_lang_project_harness/__init__.py index bf1ea0f..0238011 100644 --- a/src/python_lang_project_harness/__init__.py +++ b/src/python_lang_project_harness/__init__.py @@ -14,10 +14,12 @@ PythonModuleReport, PythonModuleShape, PythonNameBinding, + PythonProjectDependency, PythonProjectEntryPoint, PythonProjectImportName, PythonProjectMetadata, PythonProjectScript, + PythonPytestOptions, PythonReasoningTreeBranch, PythonReasoningTreeFacts, PythonReasoningTreeImportEdge, @@ -60,16 +62,47 @@ PythonLangRulePack, PythonModernDesignRulePack, PythonModularityRulePack, + PythonOwnerResponsibility, PythonProjectHarnessScope, PythonProjectPolicyRulePack, PythonRulePackDescriptor, PythonSyntaxRulePack, PythonTestLayoutRulePack, + PythonVerificationDependencySignal, + PythonVerificationEvidence, + PythonVerificationPhase, + PythonVerificationPlan, + PythonVerificationPolicy, + PythonVerificationProfileCandidate, + PythonVerificationProfileHint, + PythonVerificationProfileIndex, + PythonVerificationReceipt, + PythonVerificationReportArtifact, + PythonVerificationReportBundle, + PythonVerificationReportObligation, + PythonVerificationReportPersistence, + PythonVerificationReportWriteConfig, + PythonVerificationReportWriteReceipt, + PythonVerificationRequirement, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTask, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationTaskState, + PythonVerificationWaiver, assert_python_lang_harness_clean, assert_python_project_harness_clean, + build_python_verification_performance_index, + build_python_verification_profile_index, + build_python_verification_profile_index_with_config, + build_python_verification_report_bundle, + build_python_verification_task_index, default_python_harness_config, default_python_lang_rule_packs, discover_python_files, + plan_python_project_verification, + plan_python_project_verification_with_config, python_agent_policy_rules, python_modern_design_rules, python_modularity_rules, @@ -84,11 +117,23 @@ render_python_lang_harness, render_python_lang_harness_advice, render_python_lang_harness_json, + render_python_project_harness_agent_snapshot, + render_python_project_harness_agent_snapshot_with_config, render_python_reasoning_tree, + render_python_verification_performance_index_json, + render_python_verification_plan, + render_python_verification_plan_json, + render_python_verification_profile_index, + render_python_verification_profile_index_json, + render_python_verification_report_artifact_json, + render_python_verification_report_bundle_json, + render_python_verification_skill_contracts, + render_python_verification_task_index_json, run_cli, run_cli_from_env, run_python_lang_harness, run_python_project_harness, + write_python_verification_reports, ) __all__ = [ @@ -112,12 +157,15 @@ "PythonModuleReport", "PythonModuleShape", "PythonNameBinding", + "PythonOwnerResponsibility", + "PythonProjectDependency", "PythonProjectEntryPoint", "PythonProjectHarnessScope", "PythonProjectImportName", "PythonProjectMetadata", "PythonProjectPolicyRulePack", "PythonProjectScript", + "PythonPytestOptions", "PythonReference", "PythonReferenceKind", "PythonReasoningTreeBranch", @@ -131,6 +179,29 @@ "PythonSymbolKind", "PythonSyntaxRulePack", "PythonTestLayoutRulePack", + "PythonVerificationDependencySignal", + "PythonVerificationEvidence", + "PythonVerificationPhase", + "PythonVerificationPlan", + "PythonVerificationPolicy", + "PythonVerificationProfileCandidate", + "PythonVerificationProfileHint", + "PythonVerificationProfileIndex", + "PythonVerificationReceipt", + "PythonVerificationReportArtifact", + "PythonVerificationReportBundle", + "PythonVerificationReportObligation", + "PythonVerificationReportPersistence", + "PythonVerificationReportWriteConfig", + "PythonVerificationReportWriteReceipt", + "PythonVerificationRequirement", + "PythonVerificationSkillBinding", + "PythonVerificationSkillDescriptor", + "PythonVerificationTask", + "PythonVerificationTaskContract", + "PythonVerificationTaskKind", + "PythonVerificationTaskState", + "PythonVerificationWaiver", "SourceLocation", "__version__", "assert_python_lang_harness_clean", @@ -138,6 +209,13 @@ "default_python_harness_config", "default_python_lang_rule_packs", "discover_python_files", + "build_python_verification_performance_index", + "build_python_verification_profile_index", + "build_python_verification_profile_index_with_config", + "build_python_verification_report_bundle", + "build_python_verification_task_index", + "plan_python_project_verification", + "plan_python_project_verification_with_config", "parse_python_file", "parse_python_project_metadata", "parse_python_source", @@ -171,9 +249,21 @@ "render_python_lang_harness", "render_python_lang_harness_advice", "render_python_lang_harness_json", + "render_python_project_harness_agent_snapshot", + "render_python_project_harness_agent_snapshot_with_config", "render_python_reasoning_tree", + "render_python_verification_performance_index_json", + "render_python_verification_plan", + "render_python_verification_plan_json", + "render_python_verification_profile_index", + "render_python_verification_profile_index_json", + "render_python_verification_report_artifact_json", + "render_python_verification_report_bundle_json", + "render_python_verification_skill_contracts", + "render_python_verification_task_index_json", "run_cli", "run_cli_from_env", "run_python_lang_harness", "run_python_project_harness", + "write_python_verification_reports", ] diff --git a/src/python_lang_project_harness/_agent_snapshot.py b/src/python_lang_project_harness/_agent_snapshot.py new file mode 100644 index 0000000..fde8169 --- /dev/null +++ b/src/python_lang_project_harness/_agent_snapshot.py @@ -0,0 +1,100 @@ +"""Agent-facing project snapshot renderer for Python harness runs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from ._agent_snapshot_tree import render_python_agent_snapshot_tree +from ._render import ( + render_python_lang_harness, +) +from ._rule_packs import resolve_project_harness_config +from ._runner import run_python_project_harness +from .verification import ( + build_python_verification_profile_index_report, + plan_python_project_verification_report, + render_python_verification_plan, + render_python_verification_profile_index, +) + +if TYPE_CHECKING: + from ._model import PythonHarnessConfig, PythonHarnessReport + + +def render_python_project_harness_agent_snapshot(project_root: str | Path) -> str: + """Render a compact parser-backed project snapshot for repair agents.""" + + return render_python_project_harness_agent_snapshot_with_config( + project_root, + None, + ) + + +def render_python_project_harness_agent_snapshot_with_config( + project_root: str | Path, + config: PythonHarnessConfig | None, +) -> str: + """Render an agent snapshot using an explicit harness config.""" + + root = Path(project_root) + selected_config = resolve_project_harness_config(root, config, rule_packs=None) + report = run_python_project_harness(root, config=selected_config) + return render_python_project_harness_agent_snapshot_report( + report, + config=selected_config, + ) + + +def render_python_project_harness_agent_snapshot_report( + report: PythonHarnessReport, + *, + config: PythonHarnessConfig | None = None, +) -> str: + """Render an already-built project harness report as an agent snapshot.""" + + project_root = ( + None if report.project_scope is None else report.project_scope.project_root + ) + target = ", ".join( + _display_path(Path(path), project_root=project_root) + for path in report.root_paths + ) + sections = [f"[agent-snapshot] {target} python"] + policy = _render_policy_section(report) + if policy: + sections.append(policy.rstrip("\n")) + tree = render_python_agent_snapshot_tree(report, target=target) + if tree: + sections.append(tree.rstrip("\n")) + if config is not None: + verification_profile = render_python_verification_profile_index( + build_python_verification_profile_index_report(report, config), + max_candidates=6, + ) + if verification_profile: + sections.append(verification_profile.rstrip("\n")) + verification = render_python_verification_plan( + plan_python_project_verification_report(report, config) + ) + if verification: + sections.append(verification.rstrip("\n")) + return "\n".join(sections) + "\n" + + +def _render_policy_section(report: PythonHarnessReport) -> str: + rendered = render_python_lang_harness(report) + if rendered.startswith("[ok]"): + return "" + return "[policy]\n" + rendered + + +def _display_path(path: Path, *, project_root: Path | None) -> str: + if project_root is None: + return str(path) + try: + return str( + path.resolve(strict=False).relative_to(project_root.resolve(strict=False)) + ) + except ValueError: + return str(path) diff --git a/src/python_lang_project_harness/_agent_snapshot_tree.py b/src/python_lang_project_harness/_agent_snapshot_tree.py new file mode 100644 index 0000000..4a13702 --- /dev/null +++ b/src/python_lang_project_harness/_agent_snapshot_tree.py @@ -0,0 +1,301 @@ +"""Compact parser-backed tree section for Agent snapshots.""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +from .verification.facts import is_test_path, verification_reasoning_tree_facts + +if TYPE_CHECKING: + from python_lang_parser import ( + PythonProjectMetadata, + PythonReasoningTreeBranch, + PythonReasoningTreeImportEdge, + PythonReasoningTreeNode, + PythonReasoningTreeShadow, + ) + + from ._model import PythonHarnessReport + + +@dataclass(frozen=True, slots=True) +class _SnapshotTreeLimits: + branch_lines: int = 24 + import_edges: int = 8 + public_owners: int = 12 + children: int = 8 + public_names: int = 6 + + +_LIMITS = _SnapshotTreeLimits() + + +def render_python_agent_snapshot_tree( + report: PythonHarnessReport, + *, + target: str, +) -> str: + """Render the capped parser reasoning-tree section for an Agent snapshot.""" + + return _SnapshotTreeRenderer(report, target, _LIMITS).render() + + +@dataclass(frozen=True, slots=True) +class _SnapshotTreeRenderer: + report: PythonHarnessReport + target: str + limits: _SnapshotTreeLimits + + @property + def project_root(self) -> Path | None: + if self.report.project_scope is None: + return None + return self.report.project_scope.project_root + + def render(self) -> str: + facts = verification_reasoning_tree_facts(self.report) + if not facts.nodes and facts.project_metadata is None: + return "" + source_nodes = tuple( + node for node in facts.nodes if not self.is_test(node.path) + ) + source_branches = tuple( + branch for branch in facts.branches if not self.is_test(branch.path) + ) + source_edges = tuple( + edge + for edge in facts.import_edges + if not self.is_test(edge.importer_path) + and not self.is_test(edge.imported_path) + ) + source_shadows = tuple( + shadow + for shadow in facts.shadowed_module_sources + if not self.is_test(shadow.module_path) + and not self.is_test(shadow.package_init_path) + ) + lines = [f"[tree] {self.target} python"] + lines.append( + self.module_summary( + source_nodes=source_nodes, + test_count=len(facts.nodes) - len(source_nodes), + branch_count=len(source_branches), + dependency_count=len(source_edges), + shadowed_count=len(source_shadows), + ) + ) + if facts.project_metadata is not None: + lines.extend(self.metadata_lines(facts.project_metadata)) + self.add_branches(lines, source_branches) + self.add_public_owners(lines, source_nodes) + self.add_imports(lines, source_edges) + self.add_shadows(lines, source_shadows) + return "\n".join(lines) + "\n" + + def module_summary( + self, + *, + source_nodes: Sequence[PythonReasoningTreeNode], + test_count: int, + branch_count: int, + dependency_count: int, + shadowed_count: int, + ) -> str: + parts = [f"source={len(source_nodes)}"] + root_count = sum(1 for node in source_nodes if node.parent_namespace is None) + self.append_metric(parts, "tests", test_count, test_count > 0) + self.append_metric(parts, "roots", root_count, root_count > 1) + self.append_metric(parts, "branches", branch_count, branch_count > 0) + self.append_metric(parts, "imports", dependency_count, dependency_count > 0) + self.append_metric(parts, "shadowed", shadowed_count, shadowed_count > 0) + return "Modules: " + " ".join(parts) + + def metadata_lines(self, metadata: PythonProjectMetadata) -> list[str]: + lines = ["Project:"] + identity = [] + if metadata.project_name: + identity.append(f"name={metadata.project_name}") + if metadata.requires_python: + identity.append(f"requires-python={metadata.requires_python}") + if metadata.build_backend: + identity.append(f"build-backend={metadata.build_backend}") + if identity: + lines.append("- " + " ".join(identity)) + import_names = (*metadata.import_names, *metadata.import_namespaces) + if import_names: + lines.append( + "- import-names=" + + self.compact_values(tuple(item.name for item in import_names)) + ) + if metadata.package_roots: + lines.append( + "- package-roots=" + + self.compact_values( + tuple(self.display(path) for path in metadata.package_roots) + ) + ) + script_names = tuple(script.name for script in metadata.scripts) + if script_names: + lines.append("- scripts=" + self.compact_values(script_names)) + entry_points = tuple( + f"{entry.group}:{entry.name}" for entry in metadata.entry_points + ) + if entry_points: + lines.append("- entry-points=" + self.compact_values(entry_points)) + if metadata.pytest_options.enables_python_project_harness: + lines.append("- pytest=python-project-harness") + return lines + + def add_branches( + self, + lines: list[str], + branches: Sequence[PythonReasoningTreeBranch], + ) -> None: + if not branches: + return + lines.append("Branches:") + lines.extend( + self.limited_lines( + (self.render_branch(branch) for branch in branches), + limit=self.limits.branch_lines, + omitted_label="branches", + ) + ) + + def add_public_owners( + self, + lines: list[str], + nodes: Sequence[PythonReasoningTreeNode], + ) -> None: + public_nodes = tuple(node for node in nodes if node.has_public_surface) + if not public_nodes: + return + lines.append("PublicOwners:") + lines.extend( + self.limited_lines( + (self.render_public_owner(node) for node in public_nodes), + limit=self.limits.public_owners, + omitted_label="public owners", + ) + ) + + def add_imports( + self, + lines: list[str], + edges: Sequence[PythonReasoningTreeImportEdge], + ) -> None: + if not edges: + return + lines.append("Imports:") + lines.extend( + self.limited_lines( + (self.render_import_edge(edge) for edge in edges), + limit=self.limits.import_edges, + omitted_label="imports", + ) + ) + + def add_shadows( + self, + lines: list[str], + shadows: Sequence[PythonReasoningTreeShadow], + ) -> None: + if not shadows: + return + lines.append("Shadows:") + lines.extend(self.render_shadow(shadow) for shadow in shadows) + + def render_branch(self, branch: PythonReasoningTreeBranch) -> str: + flags = ["doc" if branch.has_intent_doc else "no-doc"] + if branch.has_public_surface: + flags.append("public") + return ( + f"- {self.display(branch.path)} owner={self.namespace(branch.namespace)} " + f"flags={','.join(flags)} " + f"children={self.compact_values(branch.child_names, limit=self.limits.children)}" + ) + + def render_public_owner(self, node: PythonReasoningTreeNode) -> str: + flags = [node.kind, "doc" if node.has_intent_doc else "no-doc"] + if node.child_names: + flags.append( + "children=" + + self.compact_values(node.child_names, limit=self.limits.children) + ) + return ( + f"- {self.display(node.path)} owner={self.namespace(node.namespace)} " + f"flags={','.join(flags)} " + f"public={self.compact_values(node.public_names, limit=self.limits.public_names)} " + f"lines={node.effective_code_lines}" + ) + + def render_import_edge(self, edge: PythonReasoningTreeImportEdge) -> str: + import_label = edge.import_name + if edge.bound_name and edge.bound_name != edge.import_name: + import_label += f" as {edge.bound_name}" + relation = "relative" if edge.is_relative else "absolute" + return ( + f"- {self.display(edge.importer_path)} --{relation}:{import_label}--> " + f"{self.display(edge.imported_path)}" + ) + + def render_shadow(self, shadow: PythonReasoningTreeShadow) -> str: + return ( + f"- {self.namespace(shadow.namespace)}: " + f"{self.display(shadow.module_path)} <-> {self.display(shadow.package_init_path)}" + ) + + def compact_values(self, values: Sequence[str], *, limit: int | None = None) -> str: + limit = self.limits.children if limit is None else limit + rendered = ",".join(values[:limit]) + omitted = len(values) - limit + if omitted <= 0: + return rendered + return f"+{omitted}" if not rendered else f"{rendered},+{omitted}" + + def limited_lines( + self, + lines: Iterable[str], + *, + limit: int, + omitted_label: str, + ) -> list[str]: + values = list(lines) + rendered = values[:limit] + omitted = len(values) - limit + if omitted > 0: + rendered.append(f"... +{omitted} {omitted_label}") + return rendered + + def display(self, path: str | Path) -> str: + path = Path(path) + if self.project_root is None: + return str(path) + try: + return str( + path.resolve(strict=False).relative_to( + self.project_root.resolve(strict=False) + ) + ) + except ValueError: + return str(path) + + def is_test(self, path: str | Path) -> bool: + return is_test_path(self.display(path)) + + @staticmethod + def append_metric( + parts: list[str], + label: str, + value: int, + should_render: bool, + ) -> None: + if should_render: + parts.append(f"{label}={value}") + + @staticmethod + def namespace(namespace: Sequence[str]) -> str: + return ".".join(namespace) if namespace else "" diff --git a/src/python_lang_project_harness/_cli.py b/src/python_lang_project_harness/_cli.py index 51461aa..73d5649 100644 --- a/src/python_lang_project_harness/_cli.py +++ b/src/python_lang_project_harness/_cli.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import TextIO +from ._agent_snapshot import render_python_project_harness_agent_snapshot_report from ._model import PythonHarnessConfig from ._project_config import read_python_project_harness_config from ._render import render_python_lang_harness, render_python_lang_harness_json @@ -38,9 +39,10 @@ def run_cli( project_root = options.project_root(cwd) if not project_root.exists(): raise ValueError(f"project root does not exist: {project_root}") + config = options.harness_config(project_root) report = run_python_project_harness( project_root, - config=options.harness_config(project_root), + config=config, include_tests=options.include_tests, source_dir_names=options.source_dir_names, test_dir_names=options.test_dir_names, @@ -49,6 +51,17 @@ def run_cli( if options.json: selected_stdout.write(render_python_lang_harness_json(report)) selected_stdout.write("\n") + elif options.agent_snapshot: + selected_stdout.write( + render_python_project_harness_agent_snapshot_report( + report, + config=( + config + or read_python_project_harness_config(project_root) + or PythonHarnessConfig() + ), + ) + ) else: selected_stdout.write(render_python_lang_harness(report)) return 0 if report.is_clean else 1 @@ -60,6 +73,7 @@ def run_cli( @dataclass(slots=True) class _CliOptions: json: bool = False + agent_snapshot: bool = False help: bool = False include_tests: bool | None = None source_dir_values: list[str] = field(default_factory=list) @@ -85,6 +99,8 @@ def parse(cls, args: list[str] | tuple[str, ...]) -> _CliOptions: positional_only = True case "--json": options.json = True + case "--agent-snapshot": + options.agent_snapshot = True case "--no-tests": options.include_tests = False case "--source-dir": @@ -120,6 +136,8 @@ def parse(cls, args: list[str] | tuple[str, ...]) -> _CliOptions: options.paths.append(Path(value)) if len(options.paths) > 1: raise ValueError("expected at most one PROJECT_ROOT argument") + if options.json and options.agent_snapshot: + raise ValueError("--json and --agent-snapshot are mutually exclusive") return options def project_root(self, cwd: Path | None) -> Path: @@ -190,12 +208,13 @@ def _option_value( def _help_text() -> str: return ( - "python-project-harness [--json] [--no-tests] " + "python-project-harness [--json | --agent-snapshot] [--no-tests] " "[--source-dir DIR] [--test-dir DIR] [--extra-path PATH] " "[--disable-rule RULE_ID] [--block-rule RULE_ID] [PROJECT_ROOT]\n\n" "Runs the default package-level Python harness.\n\n" "Compact text is the default output for humans and repair-oriented agents.\n" "Use --json to emit the structured PythonHarnessReport JSON shape.\n" + "Use --agent-snapshot to emit parser facts for project repair agents.\n" "Repeat --source-dir or --test-dir to customize policy root classification.\n" "Repeat --extra-path to include external project paths.\n" "Repeat --disable-rule or --block-rule to customize policy by rule id.\n" diff --git a/src/python_lang_project_harness/_model.py b/src/python_lang_project_harness/_model.py index 5aa059a..a2b55ab 100644 --- a/src/python_lang_project_harness/_model.py +++ b/src/python_lang_project_harness/_model.py @@ -2,10 +2,21 @@ from __future__ import annotations -from dataclasses import asdict, dataclass, field +from dataclasses import asdict, dataclass, field, replace from typing import TYPE_CHECKING, Protocol from ._constants import DEFAULT_BLOCKING_SEVERITIES, IGNORED_DIR_NAMES +from .verification.model import ( + PythonVerificationDependencySignal, + PythonVerificationPolicy, + PythonVerificationProfileHint, + PythonVerificationReceipt, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationWaiver, +) if TYPE_CHECKING: from collections.abc import Iterable @@ -164,8 +175,106 @@ class PythonHarnessConfig: extra_path_names: tuple[str, ...] = () disabled_rule_ids: frozenset[str] = frozenset() blocking_rule_ids: frozenset[str] = frozenset() + verification_policy: PythonVerificationPolicy = field( + default_factory=PythonVerificationPolicy + ) rule_packs: tuple[PythonLangRulePack, ...] | None = None + def with_verification_policy( + self, + policy: PythonVerificationPolicy, + ) -> PythonHarnessConfig: + """Return a config with an explicit verification policy.""" + + return replace(self, verification_policy=policy) + + def with_verification_profile_hint( + self, + hint: PythonVerificationProfileHint, + ) -> PythonHarnessConfig: + """Return a config with one verification profile hint appended.""" + + return replace( + self, + verification_policy=self.verification_policy.with_profile_hint(hint), + ) + + def with_verification_dependency_signal( + self, + signal: PythonVerificationDependencySignal, + ) -> PythonHarnessConfig: + """Return a config with one dependency-to-responsibility signal.""" + + return replace( + self, + verification_policy=self.verification_policy.with_dependency_signal(signal), + ) + + def with_verification_receipt( + self, + receipt: PythonVerificationReceipt, + ) -> PythonHarnessConfig: + """Return a config with one verification receipt appended.""" + + return replace( + self, + verification_policy=self.verification_policy.with_receipt(receipt), + ) + + def with_verification_waiver( + self, + waiver: PythonVerificationWaiver, + ) -> PythonHarnessConfig: + """Return a config with one verification waiver appended.""" + + return replace( + self, + verification_policy=self.verification_policy.with_waiver(waiver), + ) + + def with_verification_task_contract( + self, + kind: PythonVerificationTaskKind, + contract: PythonVerificationTaskContract, + ) -> PythonHarnessConfig: + """Return a config with one verification task contract override.""" + + return replace( + self, + verification_policy=self.verification_policy.with_task_contract( + kind, + contract, + ), + ) + + def with_verification_skill_binding( + self, + kind: PythonVerificationTaskKind, + binding: PythonVerificationSkillBinding, + ) -> PythonHarnessConfig: + """Return a config with one verification skill binding.""" + + return replace( + self, + verification_policy=self.verification_policy.with_skill_binding( + kind, + binding, + ), + ) + + def with_verification_skill_descriptor( + self, + descriptor: PythonVerificationSkillDescriptor, + ) -> PythonHarnessConfig: + """Return a config with one verification skill descriptor.""" + + return replace( + self, + verification_policy=self.verification_policy.with_skill_descriptor( + descriptor + ), + ) + @dataclass(frozen=True, slots=True) class PythonHarnessReport: diff --git a/src/python_lang_project_harness/_modularity.py b/src/python_lang_project_harness/_modularity.py index 1e86534..1001cb9 100644 --- a/src/python_lang_project_harness/_modularity.py +++ b/src/python_lang_project_harness/_modularity.py @@ -6,7 +6,12 @@ from pathlib import Path from typing import TYPE_CHECKING -from python_lang_parser import PythonDiagnosticSeverity, python_reasoning_tree_facts +from python_lang_parser import ( + PythonDiagnosticSeverity, + python_reasoning_tree_facts, + python_symbol_is_callable, + python_symbol_is_class, +) from ._model import ( PythonHarnessFinding, @@ -18,7 +23,7 @@ if TYPE_CHECKING: from collections.abc import Iterable, Sequence - from python_lang_parser import PythonModuleReport + from python_lang_parser import PythonModuleReport, PythonSymbol from ._model import PythonProjectHarnessScope @@ -101,6 +106,8 @@ def _file_modularity_findings( shape = report.shape if shape is None: return () + if _is_data_model_catalog(report): + return () if ( shape.effective_code_lines < _MAX_MODULE_EFFECTIVE_CODE_LINES @@ -186,5 +193,50 @@ def _reasoning_tree_import_roots(scope: PythonProjectHarnessScope) -> tuple[Path return scope.monitored_paths +def _is_data_model_catalog(report: PythonModuleReport) -> bool: + top_level_symbols = tuple(symbol for symbol in report.symbols if symbol.scope == "") + if not top_level_symbols: + return False + classes = tuple( + symbol for symbol in top_level_symbols if python_symbol_is_class(symbol) + ) + callables = tuple( + symbol for symbol in top_level_symbols if python_symbol_is_callable(symbol) + ) + if not classes: + return False + if any(not symbol.name.startswith("_") for symbol in callables): + return False + return all(_is_data_model_class(symbol) for symbol in classes) + + +def _is_data_model_class(symbol: PythonSymbol) -> bool: + if _has_dataclass_decorator(symbol.decorators): + return True + return any(_is_data_model_base(base_class) for base_class in symbol.base_classes) + + +def _has_dataclass_decorator(decorators: tuple[str, ...]) -> bool: + return any( + decorator == "dataclass" + or decorator.startswith("dataclass(") + or decorator.endswith(".dataclass") + or ".dataclass(" in decorator + for decorator in decorators + ) + + +def _is_data_model_base(base_class: str) -> bool: + base_name = base_class.rsplit(".", 1)[-1] + return base_name in { + "Enum", + "Flag", + "IntEnum", + "IntFlag", + "Protocol", + "StrEnum", + } + + def _rule(rule_id: str) -> PythonHarnessRule: return _RULE_BY_ID[rule_id] diff --git a/src/python_lang_project_harness/_project_config.py b/src/python_lang_project_harness/_project_config.py index d0e9e27..4002889 100644 --- a/src/python_lang_project_harness/_project_config.py +++ b/src/python_lang_project_harness/_project_config.py @@ -9,6 +9,20 @@ from python_lang_parser import PythonDiagnosticSeverity from ._model import PythonHarnessConfig +from .verification import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationPhase, + PythonVerificationPolicy, + PythonVerificationProfileHint, + PythonVerificationReceipt, + PythonVerificationRequirement, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationWaiver, +) _TOOL_TABLE_NAME = "python-lang-project-harness" @@ -41,6 +55,7 @@ def read_python_project_harness_config( _put_string_frozenset(kwargs, table, "disabled_rule_ids") _put_string_frozenset(kwargs, table, "blocking_rule_ids") _put_severity_frozenset(kwargs, table, "blocking_severities") + _put_verification_policy(kwargs, table) return PythonHarnessConfig(**kwargs) @@ -90,6 +105,269 @@ def _put_severity_frozenset( kwargs[key] = frozenset(_severity(value) for value in _string_tuple(value, key=key)) +def _put_verification_policy( + kwargs: dict[str, object], + table: dict[str, Any], +) -> None: + verification = _table(table.get("verification")) + if not verification: + return + kwargs["verification_policy"] = PythonVerificationPolicy( + profile_hints=_profile_hints(verification.get("profile_hints")), + dependency_signals=_dependency_signals(verification.get("dependency_signals")), + receipts=_receipts(verification.get("receipts")), + waivers=_waivers(verification.get("waivers")), + responsibility_task_kinds=_responsibility_task_kinds( + verification.get("responsibility_task_kinds") + ), + task_contracts=_task_contracts(verification.get("task_contracts")), + skill_bindings=_skill_bindings(verification.get("skill_bindings")), + skill_descriptors=_skill_descriptors(verification.get("skill_descriptors")), + ) + + +def _profile_hints(value: object) -> tuple[PythonVerificationProfileHint, ...]: + if not isinstance(value, list): + return () + hints: list[PythonVerificationProfileHint] = [] + for item in value: + table = _table(item) + owner_path = table.get("owner_path") + if not isinstance(owner_path, str): + continue + responsibilities = _responsibilities(table.get("responsibilities")) + if not responsibilities: + continue + task_kinds = _task_kinds(table.get("task_kinds")) + enabled = table.get("verification_tasks_enabled") + hints.append( + PythonVerificationProfileHint( + owner_path=owner_path, + responsibilities=responsibilities, + task_kinds=task_kinds, + verification_tasks_enabled=( + enabled if isinstance(enabled, bool) else True + ), + rationale=table.get("rationale") + if isinstance(table.get("rationale"), str) + else "", + task_contracts=_task_contracts(table.get("task_contracts")), + ) + ) + return tuple(hints) + + +def _dependency_signals( + value: object, +) -> tuple[PythonVerificationDependencySignal, ...]: + if not isinstance(value, list): + return () + signals: list[PythonVerificationDependencySignal] = [] + for item in value: + table = _table(item) + package_name = table.get("package_name", table.get("package")) + if not isinstance(package_name, str): + continue + responsibilities = _responsibilities(table.get("responsibilities")) + if not responsibilities: + continue + rationale = table.get("rationale") + signals.append( + PythonVerificationDependencySignal( + package_name=package_name, + responsibilities=responsibilities, + task_kinds=_task_kinds(table.get("task_kinds")), + rationale=rationale if isinstance(rationale, str) else "", + ) + ) + return tuple(signals) + + +def _receipts(value: object) -> tuple[PythonVerificationReceipt, ...]: + if not isinstance(value, list): + return () + receipts: list[PythonVerificationReceipt] = [] + for item in value: + table = _table(item) + fingerprint = table.get("task_fingerprint") + if not isinstance(fingerprint, str): + continue + summary = table.get("summary") + receipts.append( + PythonVerificationReceipt( + task_fingerprint=fingerprint, + summary=summary if isinstance(summary, str) else "", + ) + ) + return tuple(receipts) + + +def _waivers(value: object) -> tuple[PythonVerificationWaiver, ...]: + if not isinstance(value, list): + return () + waivers: list[PythonVerificationWaiver] = [] + for item in value: + table = _table(item) + fingerprint = table.get("task_fingerprint") + rationale = table.get("rationale") + if not isinstance(fingerprint, str) or not isinstance(rationale, str): + continue + waivers.append( + PythonVerificationWaiver( + task_fingerprint=fingerprint, + rationale=rationale, + ) + ) + return tuple(waivers) + + +def _responsibility_task_kinds( + value: object, +) -> dict[PythonOwnerResponsibility, tuple[PythonVerificationTaskKind, ...]]: + table = _table(value) + result: dict[PythonOwnerResponsibility, tuple[PythonVerificationTaskKind, ...]] = {} + for key, task_values in table.items(): + if not isinstance(key, str): + continue + result[_responsibility(key)] = _task_kinds(task_values) + return result + + +def _task_contracts( + value: object, +) -> dict[PythonVerificationTaskKind, PythonVerificationTaskContract]: + table = _table(value) + result: dict[PythonVerificationTaskKind, PythonVerificationTaskContract] = {} + for key, contract_value in table.items(): + if not isinstance(key, str): + continue + contract = _task_contract(contract_value) + if contract is None: + continue + result[_task_kind(key)] = contract + return result + + +def _task_contract(value: object) -> PythonVerificationTaskContract | None: + table = _table(value) + summary = table.get("summary") + if not isinstance(summary, str): + return None + phase_value = table.get("phase") + return PythonVerificationTaskContract( + phase=( + _phase(phase_value) + if isinstance(phase_value, str) + else PythonVerificationPhase.BEFORE_RELEASE + ), + summary=summary, + requirements=_requirements(table.get("requirements")), + ) + + +def _skill_bindings( + value: object, +) -> dict[PythonVerificationTaskKind, PythonVerificationSkillBinding]: + table = _table(value) + result: dict[PythonVerificationTaskKind, PythonVerificationSkillBinding] = {} + for key, binding_value in table.items(): + if not isinstance(key, str): + continue + if isinstance(binding_value, str): + result[_task_kind(key)] = PythonVerificationSkillBinding(binding_value) + continue + binding_table = _table(binding_value) + skill = binding_table.get("skill") + if not isinstance(skill, str): + continue + adapter = binding_table.get("adapter") + result[_task_kind(key)] = PythonVerificationSkillBinding( + skill=skill, + adapter=adapter if isinstance(adapter, str) else None, + ) + return result + + +def _skill_descriptors( + value: object, +) -> dict[str, PythonVerificationSkillDescriptor]: + table = _table(value) + result: dict[str, PythonVerificationSkillDescriptor] = {} + for key, descriptor_value in table.items(): + if not isinstance(key, str): + continue + descriptor_table = _table(descriptor_value) + task_kind_value = descriptor_table.get("task_kind") + summary = descriptor_table.get("summary") + if not isinstance(task_kind_value, str) or not isinstance(summary, str): + continue + adapter = descriptor_table.get("adapter") + descriptor = PythonVerificationSkillDescriptor( + key=key, + task_kind=_task_kind(task_kind_value), + summary=summary, + adapter=adapter if isinstance(adapter, str) else None, + requirements=_requirements(descriptor_table.get("requirements")), + ) + result[descriptor.compact_label] = descriptor + return result + + +def _requirements(value: object) -> tuple[PythonVerificationRequirement, ...]: + if not isinstance(value, list): + return () + requirements: list[PythonVerificationRequirement] = [] + for item in value: + table = _table(item) + label = table.get("label") + detail = table.get("detail") + if not isinstance(label, str) or not isinstance(detail, str): + continue + requirements.append(PythonVerificationRequirement(label, detail)) + return tuple(requirements) + + +def _responsibilities(value: object) -> tuple[PythonOwnerResponsibility, ...]: + if value is None: + return () + return tuple( + _responsibility(item) for item in _string_tuple(value, key="responsibilities") + ) + + +def _task_kinds(value: object) -> tuple[PythonVerificationTaskKind, ...]: + if value is None: + return () + return tuple(_task_kind(item) for item in _string_tuple(value, key="task_kinds")) + + +def _responsibility(value: str) -> PythonOwnerResponsibility: + try: + return PythonOwnerResponsibility(value) + except ValueError as error: + raise ValueError( + f"{_TOOL_TABLE_NAME}.verification has unknown responsibility: {value}" + ) from error + + +def _task_kind(value: str) -> PythonVerificationTaskKind: + try: + return PythonVerificationTaskKind(value) + except ValueError as error: + raise ValueError( + f"{_TOOL_TABLE_NAME}.verification has unknown task kind: {value}" + ) from error + + +def _phase(value: str) -> PythonVerificationPhase: + try: + return PythonVerificationPhase(value) + except ValueError as error: + raise ValueError( + f"{_TOOL_TABLE_NAME}.verification has unknown phase: {value}" + ) from error + + def _table(value: object) -> dict[str, Any]: if isinstance(value, dict): return value diff --git a/src/python_lang_project_harness/_project_policy.py b/src/python_lang_project_harness/_project_policy.py index 27376b6..dbb2173 100644 --- a/src/python_lang_project_harness/_project_policy.py +++ b/src/python_lang_project_harness/_project_policy.py @@ -11,6 +11,7 @@ from ._project_policy_imports import project_import_name_findings from ._project_policy_layout import project_layout_findings from ._project_policy_metadata import project_metadata_findings +from ._project_policy_pytest_gate import project_pytest_gate_findings from ._project_policy_typed import typed_package_findings if TYPE_CHECKING: @@ -61,5 +62,6 @@ def evaluate_project_modules( findings.extend( project_import_name_findings(scope, metadata, modules, self.pack_id) ) + findings.extend(project_pytest_gate_findings(metadata, modules, self.pack_id)) findings.extend(typed_package_findings(metadata, modules, self.pack_id)) return tuple(findings) diff --git a/src/python_lang_project_harness/_project_policy_catalog.py b/src/python_lang_project_harness/_project_policy_catalog.py index cde5006..d95cabc 100644 --- a/src/python_lang_project_harness/_project_policy_catalog.py +++ b/src/python_lang_project_harness/_project_policy_catalog.py @@ -18,6 +18,7 @@ PY_PROJ_R007 = "PY-PROJ-R007" PY_PROJ_R008 = "PY-PROJ-R008" PY_PROJ_R009 = "PY-PROJ-R009" +PY_PROJ_R010 = "PY-PROJ-R010" _RULE_LABELS = { "language": "python", @@ -96,6 +97,14 @@ requirement="Keep console scripts, GUI scripts, and entry points pointed at parser-visible project modules so agent entry maps and packaging metadata agree.", labels=dict(_RULE_LABELS), ), + PythonHarnessRule( + rule_id=PY_PROJ_R010, + pack_id=PROJECT_POLICY_PACK_ID, + severity=PythonDiagnosticSeverity.WARNING, + title="Harness dev dependency should mount a pytest gate", + requirement="Enable `--python-project-harness` in pytest addopts or expose `python_project_harness_test()` so the dev dependency actually gates project policy.", + labels=dict(_RULE_LABELS), + ), ) _RULE_BY_ID = {rule.rule_id: rule for rule in _RULES} diff --git a/src/python_lang_project_harness/_project_policy_pytest_gate.py b/src/python_lang_project_harness/_project_policy_pytest_gate.py new file mode 100644 index 0000000..3e6420d --- /dev/null +++ b/src/python_lang_project_harness/_project_policy_pytest_gate.py @@ -0,0 +1,81 @@ +"""Project policy for parser-visible pytest harness gates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ._model import PythonHarnessFinding +from ._project_policy_catalog import PY_PROJ_R010, project_policy_rule +from ._source import path_location, source_line +from ._version import DISTRIBUTION_NAME + +if TYPE_CHECKING: + from collections.abc import Sequence + + from python_lang_parser import PythonModuleReport, PythonProjectMetadata + + +def project_pytest_gate_findings( + metadata: PythonProjectMetadata, + modules: Sequence[PythonModuleReport], + pack_id: str, +) -> tuple[PythonHarnessFinding, ...]: + """Return findings when a harness dependency is not wired into pytest.""" + + if not _declares_harness_surface(metadata): + return () + if metadata.pytest_options.enables_python_project_harness: + return () + if _has_explicit_harness_helper(modules): + return () + + rule = project_policy_rule(PY_PROJ_R010) + return ( + PythonHarnessFinding( + rule_id=rule.rule_id, + pack_id=pack_id, + severity=rule.severity, + title=rule.title, + summary=( + f"{metadata.pyproject_path.name} declares the Python project harness " + "surface without a parser-visible pytest gate." + ), + location=path_location(metadata.pyproject_path), + requirement=rule.requirement, + source_line=source_line(str(metadata.pyproject_path), 1), + label="mount the parser-backed harness in pytest", + labels=dict(rule.labels), + ), + ) + + +def _declares_harness_surface(metadata: PythonProjectMetadata) -> bool: + distribution_name = _canonical_distribution_name(DISTRIBUTION_NAME) + if _canonical_distribution_name(metadata.project_name or "") == distribution_name: + return True + if any( + _canonical_distribution_name(dependency.name) == distribution_name + for dependency in metadata.dependencies + ): + return True + return any( + entry_point.group == "pytest11" + and entry_point.target_namespace[:1] == ("python_lang_project_harness",) + for entry_point in metadata.entry_points + ) + + +def _has_explicit_harness_helper( + modules: Sequence[PythonModuleReport], +) -> bool: + for module in modules: + for call in module.calls: + if call.function == "python_project_harness_test": + return True + if call.function.endswith(".python_project_harness_test"): + return True + return False + + +def _canonical_distribution_name(value: str) -> str: + return value.replace("_", "-").lower() diff --git a/src/python_lang_project_harness/harness.py b/src/python_lang_project_harness/harness.py index 8966c36..237491d 100644 --- a/src/python_lang_project_harness/harness.py +++ b/src/python_lang_project_harness/harness.py @@ -4,6 +4,10 @@ from ._agent_policy import PythonAgentPolicyRulePack from ._agent_policy_catalog import python_agent_policy_rules +from ._agent_snapshot import ( + render_python_project_harness_agent_snapshot, + render_python_project_harness_agent_snapshot_with_config, +) from ._cli import run_cli, run_cli_from_env from ._discovery import ( discover_python_files, @@ -47,6 +51,49 @@ from ._syntax_catalog import python_syntax_rules from ._test_layout import PythonTestLayoutRulePack from ._test_layout_catalog import python_test_layout_rules +from .verification import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationEvidence, + PythonVerificationPhase, + PythonVerificationPlan, + PythonVerificationPolicy, + PythonVerificationProfileCandidate, + PythonVerificationProfileHint, + PythonVerificationProfileIndex, + PythonVerificationReceipt, + PythonVerificationReportArtifact, + PythonVerificationReportBundle, + PythonVerificationReportObligation, + PythonVerificationReportPersistence, + PythonVerificationReportWriteConfig, + PythonVerificationReportWriteReceipt, + PythonVerificationRequirement, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTask, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationTaskState, + PythonVerificationWaiver, + build_python_verification_performance_index, + build_python_verification_profile_index, + build_python_verification_profile_index_with_config, + build_python_verification_report_bundle, + build_python_verification_task_index, + plan_python_project_verification, + plan_python_project_verification_with_config, + render_python_verification_performance_index_json, + render_python_verification_plan, + render_python_verification_plan_json, + render_python_verification_profile_index, + render_python_verification_profile_index_json, + render_python_verification_report_artifact_json, + render_python_verification_report_bundle_json, + render_python_verification_skill_contracts, + render_python_verification_task_index_json, + write_python_verification_reports, +) __all__ = [ "PythonAgentPolicyRulePack", @@ -62,6 +109,30 @@ "PythonRulePackDescriptor", "PythonSyntaxRulePack", "PythonTestLayoutRulePack", + "PythonOwnerResponsibility", + "PythonVerificationDependencySignal", + "PythonVerificationEvidence", + "PythonVerificationPhase", + "PythonVerificationPlan", + "PythonVerificationPolicy", + "PythonVerificationProfileCandidate", + "PythonVerificationProfileHint", + "PythonVerificationProfileIndex", + "PythonVerificationReceipt", + "PythonVerificationReportArtifact", + "PythonVerificationReportBundle", + "PythonVerificationReportObligation", + "PythonVerificationReportPersistence", + "PythonVerificationReportWriteConfig", + "PythonVerificationReportWriteReceipt", + "PythonVerificationRequirement", + "PythonVerificationSkillBinding", + "PythonVerificationSkillDescriptor", + "PythonVerificationTask", + "PythonVerificationTaskContract", + "PythonVerificationTaskKind", + "PythonVerificationTaskState", + "PythonVerificationWaiver", "assert_python_lang_harness_clean", "assert_python_project_harness_clean", "default_python_harness_config", @@ -78,10 +149,29 @@ "python_rule_pack_descriptors", "python_syntax_rules", "python_test_layout_rules", + "build_python_verification_performance_index", + "build_python_verification_profile_index", + "build_python_verification_profile_index_with_config", + "build_python_verification_report_bundle", + "build_python_verification_task_index", + "plan_python_project_verification", + "plan_python_project_verification_with_config", "render_python_lang_harness", "render_python_lang_harness_advice", "render_python_lang_harness_json", "render_python_reasoning_tree", + "render_python_project_harness_agent_snapshot", + "render_python_project_harness_agent_snapshot_with_config", + "render_python_verification_performance_index_json", + "render_python_verification_plan", + "render_python_verification_plan_json", + "render_python_verification_profile_index", + "render_python_verification_profile_index_json", + "render_python_verification_report_artifact_json", + "render_python_verification_report_bundle_json", + "render_python_verification_skill_contracts", + "render_python_verification_task_index_json", + "write_python_verification_reports", "run_cli", "run_cli_from_env", "run_python_lang_harness", diff --git a/src/python_lang_project_harness/verification/__init__.py b/src/python_lang_project_harness/verification/__init__.py new file mode 100644 index 0000000..762ab3a --- /dev/null +++ b/src/python_lang_project_harness/verification/__init__.py @@ -0,0 +1,115 @@ +"""Public verification planning surface for Python project harnesses.""" + +from __future__ import annotations + +from typing import Any + +from .model import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationEvidence, + PythonVerificationPhase, + PythonVerificationPlan, + PythonVerificationPolicy, + PythonVerificationProfileCandidate, + PythonVerificationProfileHint, + PythonVerificationProfileIndex, + PythonVerificationReceipt, + PythonVerificationReportObligation, + PythonVerificationReportPersistence, + PythonVerificationRequirement, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTask, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationTaskState, + PythonVerificationWaiver, +) + +_LAZY_EXPORTS = { + "build_python_verification_performance_index": ".indices", + "build_python_verification_profile_index": ".profile_index", + "build_python_verification_profile_index_report": ".profile_index", + "build_python_verification_profile_index_with_config": ".profile_index", + "build_python_verification_task_index": ".indices", + "plan_python_project_verification": ".planner", + "plan_python_project_verification_report": ".planner", + "plan_python_project_verification_with_config": ".planner", + "render_python_verification_performance_index_json": ".render", + "render_python_verification_plan": ".render", + "render_python_verification_plan_json": ".render", + "render_python_verification_profile_index": ".render", + "render_python_verification_profile_index_json": ".render", + "render_python_verification_skill_contracts": ".render", + "render_python_verification_task_index_json": ".render", + "PythonVerificationReportArtifact": ".report", + "PythonVerificationReportBundle": ".report", + "PythonVerificationReportWriteConfig": ".report", + "PythonVerificationReportWriteReceipt": ".report", + "build_python_verification_report_bundle": ".report", + "render_python_verification_report_artifact_json": ".report", + "render_python_verification_report_bundle_json": ".report", + "write_python_verification_reports": ".report", +} + + +def __getattr__(name: str) -> Any: + """Load planner/render/report exports lazily to keep model imports acyclic.""" + + module_name = _LAZY_EXPORTS.get(name) + if module_name is None: + raise AttributeError(name) + from importlib import import_module + + module = import_module(module_name, __name__) + value = getattr(module, name) + globals()[name] = value + return value + + +__all__ = [ + "PythonOwnerResponsibility", + "PythonVerificationDependencySignal", + "PythonVerificationEvidence", + "PythonVerificationPhase", + "PythonVerificationPlan", + "PythonVerificationPolicy", + "PythonVerificationProfileCandidate", + "PythonVerificationProfileHint", + "PythonVerificationProfileIndex", + "PythonVerificationReceipt", + "PythonVerificationReportArtifact", + "PythonVerificationReportBundle", + "PythonVerificationReportObligation", + "PythonVerificationReportPersistence", + "PythonVerificationReportWriteConfig", + "PythonVerificationReportWriteReceipt", + "PythonVerificationRequirement", + "PythonVerificationSkillBinding", + "PythonVerificationSkillDescriptor", + "PythonVerificationTask", + "PythonVerificationTaskContract", + "PythonVerificationTaskKind", + "PythonVerificationTaskState", + "PythonVerificationWaiver", + "build_python_verification_performance_index", + "build_python_verification_profile_index", + "build_python_verification_profile_index_report", + "build_python_verification_profile_index_with_config", + "build_python_verification_report_bundle", + "build_python_verification_task_index", + "plan_python_project_verification", + "plan_python_project_verification_report", + "plan_python_project_verification_with_config", + "render_python_verification_performance_index_json", + "render_python_verification_plan", + "render_python_verification_plan_json", + "render_python_verification_profile_index", + "render_python_verification_profile_index_json", + "render_python_verification_report_artifact_json", + "render_python_verification_report_bundle_json", + "render_python_verification_skill_contracts", + "render_python_verification_task_index_json", + "write_python_verification_reports", +] diff --git a/src/python_lang_project_harness/verification/facts.py b/src/python_lang_project_harness/verification/facts.py new file mode 100644 index 0000000..7167bee --- /dev/null +++ b/src/python_lang_project_harness/verification/facts.py @@ -0,0 +1,247 @@ +"""Shared parser-fact helpers for Python verification planning.""" + +from __future__ import annotations + +import hashlib +import os +import re +from collections.abc import Iterable +from pathlib import Path +from typing import TYPE_CHECKING + +from python_lang_parser import python_reasoning_tree_facts + +from .._render import _render_display_path +from .model import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationEvidence, + PythonVerificationTaskKind, +) + +if TYPE_CHECKING: + from python_lang_parser import ( + PythonProjectDependency, + PythonReasoningTreeFacts, + PythonReasoningTreeNode, + ) + + from .._model import PythonHarnessReport + + +def verification_reasoning_tree_facts( + report: PythonHarnessReport, +) -> PythonReasoningTreeFacts: + """Return parser-owned reasoning-tree facts for one harness report.""" + + scope = report.project_scope + return python_reasoning_tree_facts( + report.modules, + import_roots=_reasoning_tree_import_roots(report), + project_root=None if scope is None else scope.project_root, + project_metadata=None if scope is None else scope.project_metadata, + ) + + +def verification_project_root(report: PythonHarnessReport) -> Path: + """Return the project root represented by a harness report.""" + + if report.project_scope is not None: + return report.project_scope.project_root + if report.root_paths: + return Path(report.root_paths[0]) + return Path(".") + + +def node_responsibilities( + node: PythonReasoningTreeNode, +) -> tuple[PythonOwnerResponsibility, ...]: + """Return verification responsibilities implied by one parser tree node.""" + + responsibilities: list[PythonOwnerResponsibility] = [] + if node.has_public_surface: + responsibilities.append(PythonOwnerResponsibility.PUBLIC_API) + if "performance" in node.path or "benchmark" in node.path: + responsibilities.append(PythonOwnerResponsibility.PERFORMANCE) + return tuple(responsibilities) + + +def node_evidence( + node: PythonReasoningTreeNode, +) -> tuple[PythonVerificationEvidence, ...]: + """Return compact evidence for one parser tree node.""" + + evidence = [ + PythonVerificationEvidence("kind", node.kind), + PythonVerificationEvidence("effective-lines", str(node.effective_code_lines)), + ] + if node.public_names: + evidence.append( + PythonVerificationEvidence("public", ",".join(node.public_names[:4])) + ) + return tuple(evidence) + + +def entry_point_owner_paths( + facts: PythonReasoningTreeFacts, + *, + project_root: Path, +) -> tuple[tuple[str, tuple[str, ...]], ...]: + """Return parser-visible owners targeted by project entry points.""" + + metadata = facts.project_metadata + if metadata is None: + return () + namespace_paths = {node.namespace: node.path for node in facts.nodes} + owners: list[tuple[str, tuple[str, ...]]] = [] + for entry in (*metadata.scripts, *metadata.gui_scripts, *metadata.entry_points): + path = namespace_paths.get(entry.target_namespace) + if path is None: + continue + owners.append( + ( + _render_display_path(path, project_root=project_root), + entry.target_namespace, + ) + ) + return tuple(owners) + + +def project_metadata_owner_path( + facts: PythonReasoningTreeFacts, + *, + project_root: Path, +) -> str | None: + """Return the parser-owned display path for project metadata.""" + + metadata = facts.project_metadata + if metadata is None: + return None + return _render_display_path(metadata.pyproject_path, project_root=project_root) + + +def parser_visible_owner_responsibilities( + facts: PythonReasoningTreeFacts, + *, + project_root: Path, + dependency_signals: Iterable[PythonVerificationDependencySignal] = (), +) -> dict[str, tuple[PythonOwnerResponsibility, ...]]: + """Return parser-visible owner responsibilities by display path.""" + + owner_responsibilities: dict[str, list[PythonOwnerResponsibility]] = {} + for node in facts.nodes: + _append_owner_responsibilities( + owner_responsibilities, + _render_display_path(node.path, project_root=project_root), + node_responsibilities(node), + ) + for path, _namespace in entry_point_owner_paths(facts, project_root=project_root): + _append_owner_responsibilities( + owner_responsibilities, + path, + (PythonOwnerResponsibility.CLI,), + ) + metadata = facts.project_metadata + metadata_path = project_metadata_owner_path(facts, project_root=project_root) + if metadata is not None and metadata_path is not None: + if metadata.pytest_options.enables_python_project_harness or any( + entry.group == "pytest11" for entry in metadata.entry_points + ): + _append_owner_responsibilities( + owner_responsibilities, + metadata_path, + (PythonOwnerResponsibility.PYTEST_GATE,), + ) + for _dependency, signal in matched_dependency_signals( + metadata.dependencies, + dependency_signals, + ): + _append_owner_responsibilities( + owner_responsibilities, + metadata_path, + signal.responsibilities, + ) + return { + owner_path: tuple(responsibilities) + for owner_path, responsibilities in owner_responsibilities.items() + } + + +def matched_dependency_signals( + dependencies: Iterable[PythonProjectDependency], + signals: Iterable[PythonVerificationDependencySignal], +) -> tuple[tuple[PythonProjectDependency, PythonVerificationDependencySignal], ...]: + """Return dependency facts matched to configured verification signals.""" + + signal_by_name = { + canonical_distribution_name(signal.package_name): signal for signal in signals + } + matches: list[ + tuple[PythonProjectDependency, PythonVerificationDependencySignal] + ] = [] + for dependency in dependencies: + signal = signal_by_name.get(canonical_distribution_name(dependency.name)) + if signal is None: + continue + matches.append((dependency, signal)) + return tuple(matches) + + +def require_dependency_name(dependency: PythonProjectDependency) -> str: + """Return the normalized dependency name from parser-owned metadata.""" + + return dependency.name + + +def is_test_path(path: str) -> bool: + """Return whether a relative path belongs to a test tree.""" + + return ( + path == "tests" + or path.startswith("tests" + os.sep) + or path.startswith("tests/") + ) + + +def verification_fingerprint( + owner_path: str, + kind: PythonVerificationTaskKind, + why: str, + *, + binding_label: str = "", + descriptor_label: str = "", +) -> str: + """Return the deterministic fingerprint for one verification task.""" + + digest = hashlib.sha256( + f"{owner_path}\0{kind.value}\0{why}\0{binding_label}\0{descriptor_label}".encode() + ).hexdigest() + return f"pyv:{digest[:16]}" + + +def canonical_distribution_name(value: str) -> str: + """Return normalized Python distribution identity.""" + + return re.sub(r"[-_.]+", "-", value).lower() + + +def _append_owner_responsibilities( + owner_responsibilities: dict[str, list[PythonOwnerResponsibility]], + owner_path: str, + responsibilities: Iterable[PythonOwnerResponsibility], +) -> None: + owner_values = owner_responsibilities.setdefault(owner_path, []) + for responsibility in responsibilities: + if responsibility in owner_values: + continue + owner_values.append(responsibility) + + +def _reasoning_tree_import_roots( + report: PythonHarnessReport, +) -> tuple[Path | str, ...]: + if report.project_scope is None: + return report.root_paths + if report.project_scope.source_paths: + return report.project_scope.source_paths + return report.project_scope.monitored_paths diff --git a/src/python_lang_project_harness/verification/indices.py b/src/python_lang_project_harness/verification/indices.py new file mode 100644 index 0000000..f71bc91 --- /dev/null +++ b/src/python_lang_project_harness/verification/indices.py @@ -0,0 +1,44 @@ +"""Compact indexes derived from Python verification plans.""" + +from __future__ import annotations + +from .model import PythonVerificationPlan, PythonVerificationTaskKind + + +def build_python_verification_task_index( + plan: PythonVerificationPlan, +) -> dict[str, object]: + """Return a compact task-index JSON shape for active tasks.""" + + return { + "project_root": str(plan.project_root), + "tasks": [ + { + "fingerprint": task.fingerprint, + "owner_path": task.owner_path, + "kind": task.kind.value, + "state": task.state.value, + "phase": task.phase.value, + } + for task in plan.active_tasks + ], + } + + +def build_python_verification_performance_index( + plan: PythonVerificationPlan, +) -> dict[str, object]: + """Return a compact index of active performance verification tasks.""" + + return { + "project_root": str(plan.project_root), + "tasks": [ + { + "fingerprint": task.fingerprint, + "owner_path": task.owner_path, + "why": task.why, + } + for task in plan.active_tasks + if task.kind == PythonVerificationTaskKind.PERFORMANCE + ], + } diff --git a/src/python_lang_project_harness/verification/model.py b/src/python_lang_project_harness/verification/model.py new file mode 100644 index 0000000..79e43b0 --- /dev/null +++ b/src/python_lang_project_harness/verification/model.py @@ -0,0 +1,712 @@ +"""Library-first verification planning model for Python project harnesses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field, replace +from enum import StrEnum +from pathlib import Path + + +class PythonOwnerResponsibility(StrEnum): + """Parser-backed responsibilities that may require external verification.""" + + PUBLIC_API = "public_api" + CLI = "cli" + PYTEST_GATE = "pytest_gate" + NETWORK = "network" + PERSISTENCE = "persistence" + PERFORMANCE = "performance" + + +class PythonVerificationTaskKind(StrEnum): + """External task classes planned by the harness but executed by skills.""" + + PERFORMANCE = "performance" + SECURITY = "security" + STRESS = "stress" + CHAOS = "chaos" + REGRESSION = "regression" + RESPONSIBILITY_REVIEW = "responsibility_review" + + +class PythonVerificationPhase(StrEnum): + """Lifecycle phase for one verification obligation.""" + + BEFORE_VERIFICATION = "before_verification" + BEFORE_RELEASE = "before_release" + + +class PythonVerificationTaskState(StrEnum): + """Current state of one verification task.""" + + PENDING = "pending" + SATISFIED = "satisfied" + WAIVED = "waived" + + +class PythonVerificationReportPersistence(StrEnum): + """Persistence class for generated verification report artifacts.""" + + SOURCE_BASELINE = "source_baseline" + RUNTIME_CACHE = "runtime_cache" + + +@dataclass(frozen=True, slots=True) +class PythonVerificationEvidence: + """One compact evidence key/value attached to a task or profile candidate.""" + + label: str + value: str + + def to_dict(self) -> dict[str, str]: + """Return a JSON-compatible representation.""" + + return {"label": self.label, "value": self.value} + + +@dataclass(frozen=True, slots=True) +class PythonVerificationRequirement: + """One requirement line for an external verification skill contract.""" + + label: str + detail: str + + def to_dict(self) -> dict[str, str]: + """Return a JSON-compatible representation.""" + + return {"label": self.label, "detail": self.detail} + + +@dataclass(frozen=True, slots=True) +class PythonVerificationSkillBinding: + """Low-token bridge from a verification task kind to an Agent skill.""" + + skill: str + adapter: str | None = None + + def with_adapter(self, adapter: str) -> PythonVerificationSkillBinding: + """Return a binding with a concrete execution adapter.""" + + return replace(self, adapter=adapter) + + @property + def dispatch_hint(self) -> str: + """Return a compact `skill@adapter` dispatch hint.""" + + if self.adapter is None: + return self.skill + return f"{self.skill}@{self.adapter}" + + def to_dict(self) -> dict[str, str | None]: + """Return a JSON-compatible representation.""" + + return { + "skill": self.skill, + "adapter": self.adapter, + "dispatch_hint": self.dispatch_hint, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationSkillDescriptor: + """Typed execution contract for a verification skill binding.""" + + key: str + task_kind: PythonVerificationTaskKind + summary: str + requirements: tuple[PythonVerificationRequirement, ...] = () + adapter: str | None = None + + @classmethod + def pytest_regression(cls) -> PythonVerificationSkillDescriptor: + """Return a standard pytest regression skill descriptor.""" + + return cls( + key="python-verification-regression", + task_kind=PythonVerificationTaskKind.REGRESSION, + adapter="pytest", + summary="run the parser-owned pytest regression contract", + requirements=( + PythonVerificationRequirement( + "pytest", + "capture command, exit status, and failing node ids", + ), + ), + ) + + @classmethod + def performance(cls) -> PythonVerificationSkillDescriptor: + """Return a standard Python performance skill descriptor.""" + + return cls( + key="python-verification-performance", + task_kind=PythonVerificationTaskKind.PERFORMANCE, + adapter="pytest-benchmark", + summary="measure performance-sensitive Python owner behavior", + requirements=( + PythonVerificationRequirement( + "benchmark", + "record command, sample size, and baseline comparison", + ), + ), + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "key": self.key, + "compact_label": self.compact_label, + "task_kind": self.task_kind.value, + "summary": self.summary, + "adapter": self.adapter, + "requirements": [item.to_dict() for item in self.requirements], + } + + @property + def compact_label(self) -> str: + """Return a compact descriptor label for task contract references.""" + + if self.adapter is None: + return self.key + return f"{self.key}@{self.adapter}" + + +@dataclass(frozen=True, slots=True) +class PythonVerificationTaskContract: + """Contract text attached to one task kind.""" + + phase: PythonVerificationPhase + summary: str + requirements: tuple[PythonVerificationRequirement, ...] = () + + @classmethod + def default_for( + cls, + kind: PythonVerificationTaskKind, + ) -> PythonVerificationTaskContract: + """Return the default task contract for one task kind.""" + + if kind == PythonVerificationTaskKind.RESPONSIBILITY_REVIEW: + return cls( + phase=PythonVerificationPhase.BEFORE_VERIFICATION, + summary="update the verification profile to match parser facts, or attach a complete waiver", + ) + return cls( + phase=PythonVerificationPhase.BEFORE_RELEASE, + summary=f"run {kind.value} verification for this parser-owned owner", + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "phase": self.phase.value, + "summary": self.summary, + "requirements": [item.to_dict() for item in self.requirements], + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationProfileHint: + """Config hint that maps one owner path to verification responsibilities.""" + + owner_path: str + responsibilities: tuple[PythonOwnerResponsibility, ...] + task_kinds: tuple[PythonVerificationTaskKind, ...] = () + verification_tasks_enabled: bool = True + rationale: str = "" + task_contracts: dict[ + PythonVerificationTaskKind, + PythonVerificationTaskContract, + ] = field(default_factory=dict) + + def with_task_kinds( + self, + task_kinds: tuple[PythonVerificationTaskKind, ...], + ) -> PythonVerificationProfileHint: + """Return a hint with owner-local task kinds.""" + + return replace(self, task_kinds=tuple(task_kinds)) + + def without_verification_tasks(self) -> PythonVerificationProfileHint: + """Return a hint that intentionally suppresses owner-local tasks.""" + + return replace(self, verification_tasks_enabled=False, task_kinds=()) + + def with_rationale(self, rationale: str) -> PythonVerificationProfileHint: + """Return a hint with compact rationale text.""" + + return replace(self, rationale=rationale) + + def with_task_contract( + self, + kind: PythonVerificationTaskKind, + contract: PythonVerificationTaskContract, + ) -> PythonVerificationProfileHint: + """Return a hint with an owner-local task contract override.""" + + return replace( + self, + task_contracts={**self.task_contracts, kind: contract}, + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "owner_path": self.owner_path, + "responsibilities": [item.value for item in self.responsibilities], + "task_kinds": [item.value for item in self.task_kinds], + "verification_tasks_enabled": self.verification_tasks_enabled, + "rationale": self.rationale, + "task_contracts": { + key.value: value.to_dict() + for key, value in sorted( + self.task_contracts.items(), + key=lambda item: item[0].value, + ) + }, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationDependencySignal: + """Config mapping from dependency facts to owner responsibilities.""" + + package_name: str + responsibilities: tuple[PythonOwnerResponsibility, ...] + task_kinds: tuple[PythonVerificationTaskKind, ...] = () + rationale: str = "" + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "package_name": self.package_name, + "responsibilities": [item.value for item in self.responsibilities], + "task_kinds": [item.value for item in self.task_kinds], + "rationale": self.rationale, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReceipt: + """Evidence receipt that satisfies one planned verification task.""" + + task_fingerprint: str + summary: str = "" + evidence: tuple[PythonVerificationEvidence, ...] = () + + def with_evidence( + self, + evidence: tuple[PythonVerificationEvidence, ...], + ) -> PythonVerificationReceipt: + """Return a receipt with evidence attached.""" + + return replace(self, evidence=tuple(evidence)) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "task_fingerprint": self.task_fingerprint, + "summary": self.summary, + "evidence": [item.to_dict() for item in self.evidence], + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationWaiver: + """Explicit waiver for one planned verification task.""" + + task_fingerprint: str + rationale: str + + @property + def is_complete(self) -> bool: + """Return whether the waiver has enough rationale to suppress a task.""" + + return bool(self.rationale.strip()) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "task_fingerprint": self.task_fingerprint, + "rationale": self.rationale, + "is_complete": self.is_complete, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationTask: + """One external verification obligation planned by the harness.""" + + owner_path: str + owner_namespace: tuple[str, ...] + responsibilities: tuple[PythonOwnerResponsibility, ...] + kind: PythonVerificationTaskKind + state: PythonVerificationTaskState + phase: PythonVerificationPhase + fingerprint: str + why: str + contract: PythonVerificationTaskContract + evidence: tuple[PythonVerificationEvidence, ...] = () + receipt: PythonVerificationReceipt | None = None + waiver: PythonVerificationWaiver | None = None + skill_binding: PythonVerificationSkillBinding | None = None + skill_descriptor: PythonVerificationSkillDescriptor | None = None + + @property + def is_active(self) -> bool: + """Return whether the task still needs external action.""" + + return self.state == PythonVerificationTaskState.PENDING + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "owner_path": self.owner_path, + "owner_namespace": list(self.owner_namespace), + "responsibilities": [item.value for item in self.responsibilities], + "kind": self.kind.value, + "state": self.state.value, + "phase": self.phase.value, + "fingerprint": self.fingerprint, + "why": self.why, + "contract": self.contract.to_dict(), + "evidence": [item.to_dict() for item in self.evidence], + "receipt": None if self.receipt is None else self.receipt.to_dict(), + "waiver": None if self.waiver is None else self.waiver.to_dict(), + "skill_binding": ( + None if self.skill_binding is None else self.skill_binding.to_dict() + ), + "skill_descriptor": ( + None + if self.skill_descriptor is None + else self.skill_descriptor.to_dict() + ), + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReportObligation: + """One report artifact required by an active verification plan.""" + + key: str + renderer: str + artifact: str + task_kinds: tuple[PythonVerificationTaskKind, ...] + persistence: PythonVerificationReportPersistence + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "key": self.key, + "renderer": self.renderer, + "artifact": self.artifact, + "task_kinds": [item.value for item in self.task_kinds], + "persistence": self.persistence.value, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationPolicy: + """Configurable verification policy attached to a harness config.""" + + profile_hints: tuple[PythonVerificationProfileHint, ...] = () + dependency_signals: tuple[PythonVerificationDependencySignal, ...] = () + receipts: tuple[PythonVerificationReceipt, ...] = () + waivers: tuple[PythonVerificationWaiver, ...] = () + responsibility_task_kinds: dict[ + PythonOwnerResponsibility, + tuple[PythonVerificationTaskKind, ...], + ] = field(default_factory=dict) + task_contracts: dict[ + PythonVerificationTaskKind, + PythonVerificationTaskContract, + ] = field(default_factory=dict) + skill_bindings: dict[ + PythonVerificationTaskKind, + PythonVerificationSkillBinding, + ] = field(default_factory=dict) + skill_descriptors: dict[ + str, + PythonVerificationSkillDescriptor, + ] = field(default_factory=dict) + + def is_empty(self) -> bool: + """Return whether the policy has no configured verification behavior.""" + + return not ( + self.profile_hints + or self.dependency_signals + or self.receipts + or self.waivers + or self.responsibility_task_kinds + or self.task_contracts + or self.skill_bindings + or self.skill_descriptors + ) + + def task_kinds_for_responsibilities( + self, + responsibilities: tuple[PythonOwnerResponsibility, ...], + ) -> tuple[PythonVerificationTaskKind, ...]: + """Return configured task kinds for owner responsibilities.""" + + task_kinds: list[PythonVerificationTaskKind] = [] + seen: set[PythonVerificationTaskKind] = set() + for responsibility in responsibilities: + for kind in self.responsibility_task_kinds.get( + responsibility, + _default_task_kinds_for_responsibility(responsibility), + ): + if kind in seen: + continue + seen.add(kind) + task_kinds.append(kind) + return tuple(task_kinds) + + def with_profile_hint( + self, + hint: PythonVerificationProfileHint, + ) -> PythonVerificationPolicy: + """Return a policy with one profile hint appended.""" + + return replace(self, profile_hints=(*self.profile_hints, hint)) + + def with_dependency_signal( + self, + signal: PythonVerificationDependencySignal, + ) -> PythonVerificationPolicy: + """Return a policy with one dependency signal appended.""" + + return replace(self, dependency_signals=(*self.dependency_signals, signal)) + + def with_receipt( + self, + receipt: PythonVerificationReceipt, + ) -> PythonVerificationPolicy: + """Return a policy with one verification receipt appended.""" + + return replace(self, receipts=(*self.receipts, receipt)) + + def with_waiver( + self, + waiver: PythonVerificationWaiver, + ) -> PythonVerificationPolicy: + """Return a policy with one verification waiver appended.""" + + return replace(self, waivers=(*self.waivers, waiver)) + + def with_task_contract( + self, + kind: PythonVerificationTaskKind, + contract: PythonVerificationTaskContract, + ) -> PythonVerificationPolicy: + """Return a policy with one task-kind contract override.""" + + return replace( + self, + task_contracts={**self.task_contracts, kind: contract}, + ) + + def with_skill_binding( + self, + kind: PythonVerificationTaskKind, + binding: PythonVerificationSkillBinding, + ) -> PythonVerificationPolicy: + """Return a policy with one task-kind skill binding.""" + + return replace( + self, + skill_bindings={**self.skill_bindings, kind: binding}, + ) + + def with_skill_descriptor( + self, + descriptor: PythonVerificationSkillDescriptor, + ) -> PythonVerificationPolicy: + """Return a policy with one compact skill descriptor.""" + + return replace( + self, + skill_descriptors={ + **self.skill_descriptors, + descriptor.compact_label: descriptor, + }, + ) + + def with_responsibility_task_kinds( + self, + responsibility: PythonOwnerResponsibility, + task_kinds: tuple[PythonVerificationTaskKind, ...], + ) -> PythonVerificationPolicy: + """Return a policy with one responsibility task-kind mapping.""" + + return replace( + self, + responsibility_task_kinds={ + **self.responsibility_task_kinds, + responsibility: tuple(task_kinds), + }, + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "profile_hints": [item.to_dict() for item in self.profile_hints], + "dependency_signals": [item.to_dict() for item in self.dependency_signals], + "receipts": [item.to_dict() for item in self.receipts], + "waivers": [item.to_dict() for item in self.waivers], + "responsibility_task_kinds": { + key.value: [item.value for item in value] + for key, value in sorted( + self.responsibility_task_kinds.items(), + key=lambda item: item[0].value, + ) + }, + "task_contracts": { + key.value: value.to_dict() + for key, value in sorted( + self.task_contracts.items(), + key=lambda item: item[0].value, + ) + }, + "skill_bindings": { + key.value: value.to_dict() + for key, value in sorted( + self.skill_bindings.items(), + key=lambda item: item[0].value, + ) + }, + "skill_descriptors": { + key: value.to_dict() + for key, value in sorted(self.skill_descriptors.items()) + }, + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationPlan: + """Complete parser-backed verification plan for one project.""" + + project_root: Path + tasks: tuple[PythonVerificationTask, ...] + report_obligations: tuple[PythonVerificationReportObligation, ...] + + @property + def active_tasks(self) -> tuple[PythonVerificationTask, ...]: + """Return pending tasks that still require external action.""" + + return tuple(task for task in self.tasks if task.is_active) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "project_root": str(self.project_root), + "tasks": [item.to_dict() for item in self.tasks], + "active_tasks": [item.fingerprint for item in self.active_tasks], + "report_obligations": [item.to_dict() for item in self.report_obligations], + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationProfileCandidate: + """One parser-suggested verification profile candidate.""" + + owner_path: str + owner_namespace: tuple[str, ...] + responsibilities: tuple[PythonOwnerResponsibility, ...] + state: str + configured_responsibilities: tuple[PythonOwnerResponsibility, ...] = () + evidence: tuple[PythonVerificationEvidence, ...] = () + task_kinds: tuple[PythonVerificationTaskKind, ...] = () + + def to_profile_hint(self) -> PythonVerificationProfileHint: + """Return the config hint represented by this parser candidate.""" + + return PythonVerificationProfileHint( + owner_path=self.owner_path, + responsibilities=self.responsibilities, + task_kinds=self.task_kinds, + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "owner_path": self.owner_path, + "owner_namespace": list(self.owner_namespace), + "responsibilities": [item.value for item in self.responsibilities], + "state": self.state, + "configured_responsibilities": [ + item.value for item in self.configured_responsibilities + ], + "evidence": [item.to_dict() for item in self.evidence], + "task_kinds": [item.value for item in self.task_kinds], + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationProfileIndex: + """Low-token profile advice built from parser project facts.""" + + project_root: Path + candidates: tuple[PythonVerificationProfileCandidate, ...] + + def active_candidates(self) -> tuple[PythonVerificationProfileCandidate, ...]: + """Return candidates that still need Agent configuration attention.""" + + return tuple( + candidate + for candidate in self.candidates + if candidate.state != "configured" + ) + + def is_clear(self) -> bool: + """Return whether all parser-suggested profile candidates are configured.""" + + return not self.active_candidates() + + def active_profile_hints(self) -> tuple[PythonVerificationProfileHint, ...]: + """Return config-ready hints for candidates still needing attention.""" + + return tuple( + candidate.to_profile_hint() for candidate in self.active_candidates() + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "project_root": str(self.project_root), + "candidates": [item.to_dict() for item in self.candidates], + "active_profile_hints": [ + item.to_dict() for item in self.active_profile_hints() + ], + } + + +def _default_task_kinds_for_responsibility( + responsibility: PythonOwnerResponsibility, +) -> tuple[PythonVerificationTaskKind, ...]: + match responsibility: + case PythonOwnerResponsibility.PUBLIC_API: + return (PythonVerificationTaskKind.REGRESSION,) + case PythonOwnerResponsibility.CLI: + return (PythonVerificationTaskKind.REGRESSION,) + case PythonOwnerResponsibility.PYTEST_GATE: + return (PythonVerificationTaskKind.REGRESSION,) + case PythonOwnerResponsibility.NETWORK: + return (PythonVerificationTaskKind.STRESS,) + case PythonOwnerResponsibility.PERSISTENCE: + return (PythonVerificationTaskKind.REGRESSION,) + case PythonOwnerResponsibility.PERFORMANCE: + return (PythonVerificationTaskKind.PERFORMANCE,) diff --git a/src/python_lang_project_harness/verification/obligations.py b/src/python_lang_project_harness/verification/obligations.py new file mode 100644 index 0000000..e80fe6f --- /dev/null +++ b/src/python_lang_project_harness/verification/obligations.py @@ -0,0 +1,57 @@ +"""Report obligations for active Python verification tasks.""" + +from __future__ import annotations + +from .model import ( + PythonVerificationReportObligation, + PythonVerificationReportPersistence, + PythonVerificationTask, + PythonVerificationTaskKind, +) + + +def verification_report_obligations( + active_tasks: tuple[PythonVerificationTask, ...], +) -> tuple[PythonVerificationReportObligation, ...]: + """Return report artifacts required by active verification work.""" + + if not active_tasks: + return () + task_kinds = tuple( + sorted({task.kind for task in active_tasks}, key=lambda item: item.value) + ) + obligations = [ + PythonVerificationReportObligation( + key="verification_plan_json", + renderer="render_python_verification_plan_json", + artifact="verification_plan.json", + task_kinds=task_kinds, + persistence=PythonVerificationReportPersistence.RUNTIME_CACHE, + ), + PythonVerificationReportObligation( + key="task_index_json", + renderer=( + "build_python_verification_task_index + " + "render_python_verification_task_index_json" + ), + artifact="verification_task_index.json", + task_kinds=task_kinds, + persistence=PythonVerificationReportPersistence.SOURCE_BASELINE, + ), + ] + if any( + task.kind == PythonVerificationTaskKind.PERFORMANCE for task in active_tasks + ): + obligations.append( + PythonVerificationReportObligation( + key="performance_index_json", + renderer=( + "build_python_verification_performance_index + " + "render_python_verification_performance_index_json" + ), + artifact="performance_index.json", + task_kinds=(PythonVerificationTaskKind.PERFORMANCE,), + persistence=PythonVerificationReportPersistence.SOURCE_BASELINE, + ) + ) + return tuple(obligations) diff --git a/src/python_lang_project_harness/verification/planner.py b/src/python_lang_project_harness/verification/planner.py new file mode 100644 index 0000000..6661146 --- /dev/null +++ b/src/python_lang_project_harness/verification/planner.py @@ -0,0 +1,353 @@ +"""Parser-backed verification planner for Python project harnesses.""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +from typing import TYPE_CHECKING + +from .._model import PythonHarnessConfig, PythonHarnessReport +from .._render import _render_display_path +from .._rule_packs import resolve_project_harness_config +from .._runner import run_python_project_harness +from .facts import ( + matched_dependency_signals, + node_evidence, + parser_visible_owner_responsibilities, + project_metadata_owner_path, + require_dependency_name, + verification_fingerprint, + verification_project_root, + verification_reasoning_tree_facts, +) +from .model import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationEvidence, + PythonVerificationPlan, + PythonVerificationPolicy, + PythonVerificationProfileHint, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTask, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + PythonVerificationTaskState, +) +from .obligations import verification_report_obligations + +if TYPE_CHECKING: + from python_lang_parser import PythonReasoningTreeNode + + +def plan_python_project_verification( + project_root: str | Path, +) -> PythonVerificationPlan: + """Plan external verification obligations for one Python project.""" + + return plan_python_project_verification_with_config(project_root, None) + + +def plan_python_project_verification_with_config( + project_root: str | Path, + config: PythonHarnessConfig | None, +) -> PythonVerificationPlan: + """Plan verification obligations with an explicit harness config.""" + + root = Path(project_root) + selected_config = resolve_project_harness_config(root, config, rule_packs=None) + report = run_python_project_harness(root, config=selected_config) + return plan_python_project_verification_report(report, selected_config) + + +def plan_python_project_verification_report( + report: PythonHarnessReport, + config: PythonHarnessConfig, +) -> PythonVerificationPlan: + """Plan verification obligations from an already-built harness report.""" + + project_root = verification_project_root(report) + policy = config.verification_policy + facts = verification_reasoning_tree_facts(report) + node_by_path = { + _render_display_path(node.path, project_root=project_root): node + for node in facts.nodes + } + owner_responsibilities_by_path = parser_visible_owner_responsibilities( + facts, + project_root=project_root, + dependency_signals=policy.dependency_signals, + ) + tasks: list[PythonVerificationTask] = [] + seen: set[tuple[str, PythonVerificationTaskKind, str]] = set() + for hint in policy.profile_hints: + tasks.extend( + _tasks_for_profile_hint( + hint, + node_by_path=node_by_path, + parser_responsibilities=owner_responsibilities_by_path.get( + hint.owner_path, + (), + ), + policy=policy, + seen=seen, + ) + ) + metadata = facts.project_metadata + if metadata is not None: + metadata_owner_path = project_metadata_owner_path( + facts, + project_root=project_root, + ) + for dependency, signal in matched_dependency_signals( + metadata.dependencies, + policy.dependency_signals, + ): + if metadata_owner_path is None: + continue + tasks.extend( + _tasks_for_dependency_signal( + owner_path=metadata_owner_path, + dependency=require_dependency_name(dependency), + signal=signal, + policy=policy, + seen=seen, + ) + ) + tasks = [_apply_receipts_and_waivers(task, policy=policy) for task in tasks] + active_tasks = tuple(task for task in tasks if task.is_active) + return PythonVerificationPlan( + project_root=project_root, + tasks=tuple(tasks), + report_obligations=verification_report_obligations(active_tasks), + ) + + +def _tasks_for_profile_hint( + hint: PythonVerificationProfileHint, + *, + node_by_path: dict[str, PythonReasoningTreeNode], + parser_responsibilities: tuple[PythonOwnerResponsibility, ...], + policy: PythonVerificationPolicy, + seen: set[tuple[str, PythonVerificationTaskKind, str]], +) -> tuple[PythonVerificationTask, ...]: + node = node_by_path.get(hint.owner_path) + review_reason = _profile_hint_review_reason( + hint, + node, + parser_responsibilities=parser_responsibilities, + policy=policy, + ) + if review_reason is not None: + task = _task( + owner_path=hint.owner_path, + owner_namespace=() if node is None else node.namespace, + responsibilities=hint.responsibilities, + kind=PythonVerificationTaskKind.RESPONSIBILITY_REVIEW, + why=review_reason, + evidence=(), + policy=policy, + seen=seen, + ) + return () if task is None else (task,) + if not hint.verification_tasks_enabled: + return () + task_kinds = hint.task_kinds or policy.task_kinds_for_responsibilities( + hint.responsibilities + ) + tasks: list[PythonVerificationTask] = [] + for kind in task_kinds: + task = _task( + owner_path=hint.owner_path, + owner_namespace=() if node is None else node.namespace, + responsibilities=hint.responsibilities, + kind=kind, + why=f"{kind.value}=owner profile requests verification", + evidence=() if node is None else node_evidence(node), + contract=hint.task_contracts.get(kind), + policy=policy, + seen=seen, + ) + if task is not None: + tasks.append(task) + return tuple(tasks) + + +def _tasks_for_dependency_signal( + *, + owner_path: str, + dependency: str, + signal: PythonVerificationDependencySignal, + policy: PythonVerificationPolicy, + seen: set[tuple[str, PythonVerificationTaskKind, str]], +) -> tuple[PythonVerificationTask, ...]: + task_kinds = signal.task_kinds or policy.task_kinds_for_responsibilities( + signal.responsibilities + ) + tasks: list[PythonVerificationTask] = [] + for kind in task_kinds: + task = _task( + owner_path=owner_path, + owner_namespace=(), + responsibilities=signal.responsibilities, + kind=kind, + why=f"{kind.value}=dependency {dependency} maps to verification responsibility", + evidence=(PythonVerificationEvidence("dependency", dependency),), + policy=policy, + seen=seen, + ) + if task is not None: + tasks.append(task) + return tuple(tasks) + + +def _task( + *, + owner_path: str, + owner_namespace: tuple[str, ...], + responsibilities: tuple[PythonOwnerResponsibility, ...], + kind: PythonVerificationTaskKind, + why: str, + evidence: tuple[PythonVerificationEvidence, ...], + contract: PythonVerificationTaskContract | None = None, + policy: PythonVerificationPolicy, + seen: set[tuple[str, PythonVerificationTaskKind, str]], +) -> PythonVerificationTask | None: + key = ( + owner_path, + kind, + ",".join(sorted(responsibility.value for responsibility in responsibilities)), + ) + if key in seen: + return None + seen.add(key) + skill_binding = policy.skill_bindings.get(kind) + skill_descriptor = _skill_descriptor_for( + kind, + skill_binding=skill_binding, + policy=policy, + ) + binding_label = "" if skill_binding is None else skill_binding.dispatch_hint + descriptor_label = ( + "" if skill_descriptor is None else skill_descriptor.compact_label + ) + fingerprint = verification_fingerprint( + owner_path, + kind, + why, + binding_label=binding_label, + descriptor_label=descriptor_label, + ) + selected_contract = ( + contract + or policy.task_contracts.get(kind) + or PythonVerificationTaskContract.default_for(kind) + ) + return PythonVerificationTask( + owner_path=owner_path, + owner_namespace=owner_namespace, + responsibilities=responsibilities, + kind=kind, + state=PythonVerificationTaskState.PENDING, + phase=selected_contract.phase, + fingerprint=fingerprint, + why=why, + contract=selected_contract, + evidence=evidence, + skill_binding=skill_binding, + skill_descriptor=skill_descriptor, + ) + + +def _apply_receipts_and_waivers( + task: PythonVerificationTask, + *, + policy: PythonVerificationPolicy, +) -> PythonVerificationTask: + receipt = next( + ( + receipt + for receipt in policy.receipts + if receipt.task_fingerprint == task.fingerprint + ), + None, + ) + if receipt is not None: + return replace( + task, + state=PythonVerificationTaskState.SATISFIED, + receipt=receipt, + ) + waiver = next( + ( + waiver + for waiver in policy.waivers + if waiver.task_fingerprint == task.fingerprint and waiver.is_complete + ), + None, + ) + if waiver is not None: + return replace(task, state=PythonVerificationTaskState.WAIVED, waiver=waiver) + return task + + +def _profile_hint_review_reason( + hint: PythonVerificationProfileHint, + node: PythonReasoningTreeNode | None, + *, + parser_responsibilities: tuple[PythonOwnerResponsibility, ...], + policy: PythonVerificationPolicy, +) -> str | None: + if node is None and not parser_responsibilities: + return "responsibility_review=profile owner path is not parser-visible" + if not hint.verification_tasks_enabled and not hint.rationale.strip(): + return "responsibility_review=owner-local verification suppression needs compact rationale" + if ( + (hint.task_kinds or hint.task_contracts) + and not hint.rationale.strip() + and ( + set(hint.task_kinds) + != set(policy.task_kinds_for_responsibilities(hint.responsibilities)) + or bool(hint.task_contracts) + ) + ): + return "responsibility_review=owner-local verification override needs compact rationale" + owner_responsibilities = set(parser_responsibilities) + missing = tuple( + responsibility + for responsibility in hint.responsibilities + if responsibility not in owner_responsibilities + ) + if missing: + return ( + "responsibility_review=profile responsibility does not match parser facts" + ) + return None + + +def _skill_descriptor_for( + kind: PythonVerificationTaskKind, + *, + skill_binding: PythonVerificationSkillBinding | None, + policy: PythonVerificationPolicy, +) -> PythonVerificationSkillDescriptor | None: + if skill_binding is not None: + descriptor = policy.skill_descriptors.get(skill_binding.dispatch_hint) + if descriptor is not None: + return descriptor + descriptor = policy.skill_descriptors.get(skill_binding.skill) + if descriptor is not None: + return descriptor + return next( + ( + descriptor + for descriptor in policy.skill_descriptors.values() + if descriptor.task_kind == kind + and ( + skill_binding is None + or descriptor.adapter is None + or descriptor.adapter == skill_binding.adapter + ) + ), + None, + ) diff --git a/src/python_lang_project_harness/verification/profile_index.py b/src/python_lang_project_harness/verification/profile_index.py new file mode 100644 index 0000000..c4895ea --- /dev/null +++ b/src/python_lang_project_harness/verification/profile_index.py @@ -0,0 +1,275 @@ +"""Verification profile index built from parser project facts.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from .._render import _render_display_path +from .._rule_packs import resolve_project_harness_config +from .._runner import run_python_project_harness +from .facts import ( + entry_point_owner_paths, + is_test_path, + matched_dependency_signals, + node_evidence, + node_responsibilities, + project_metadata_owner_path, + require_dependency_name, + verification_project_root, + verification_reasoning_tree_facts, +) +from .model import ( + PythonOwnerResponsibility, + PythonVerificationEvidence, + PythonVerificationPolicy, + PythonVerificationProfileCandidate, + PythonVerificationProfileHint, + PythonVerificationProfileIndex, + PythonVerificationTaskKind, +) + +if TYPE_CHECKING: + from .._model import PythonHarnessConfig, PythonHarnessReport + + +def build_python_verification_profile_index( + project_root: str | Path, +) -> PythonVerificationProfileIndex: + """Build parser-suggested verification profile candidates for one project.""" + + return build_python_verification_profile_index_with_config(project_root, None) + + +def build_python_verification_profile_index_with_config( + project_root: str | Path, + config: PythonHarnessConfig | None, +) -> PythonVerificationProfileIndex: + """Build profile candidates with an explicit harness config.""" + + root = Path(project_root) + selected_config = resolve_project_harness_config(root, config, rule_packs=None) + report = run_python_project_harness(root, config=selected_config) + return build_python_verification_profile_index_report(report, selected_config) + + +def build_python_verification_profile_index_report( + report: PythonHarnessReport, + config: PythonHarnessConfig, +) -> PythonVerificationProfileIndex: + """Build profile candidates from an already-built harness report.""" + + project_root = verification_project_root(report) + policy = config.verification_policy + facts = verification_reasoning_tree_facts(report) + candidates: list[PythonVerificationProfileCandidate] = [] + candidate_index_by_path: dict[str, int] = {} + aggregate_public_namespaces = frozenset( + branch.namespace for branch in facts.branches if branch.has_public_surface + ) + for node in facts.nodes: + path = _render_display_path(node.path, project_root=project_root) + if is_test_path(path): + continue + responsibilities = _responsibilities_for_node( + node_responsibilities(node), + namespace=node.namespace, + aggregate_public_namespaces=aggregate_public_namespaces, + ) + if responsibilities: + _append_candidate( + candidates, + candidate_index_by_path, + owner_path=path, + owner_namespace=node.namespace, + responsibilities=responsibilities, + evidence=node_evidence(node), + policy=policy, + ) + metadata = facts.project_metadata + if metadata is not None: + metadata_owner_path = project_metadata_owner_path( + facts, + project_root=project_root, + ) + for path, namespace in entry_point_owner_paths( + facts, + project_root=project_root, + ): + _append_candidate( + candidates, + candidate_index_by_path, + owner_path=path, + owner_namespace=namespace, + responsibilities=(PythonOwnerResponsibility.CLI,), + evidence=(PythonVerificationEvidence("entry-point", "true"),), + policy=policy, + ) + if metadata_owner_path is not None and ( + metadata.pytest_options.enables_python_project_harness + or any(entry.group == "pytest11" for entry in metadata.entry_points) + ): + _append_candidate( + candidates, + candidate_index_by_path, + owner_path=metadata_owner_path, + owner_namespace=(), + responsibilities=(PythonOwnerResponsibility.PYTEST_GATE,), + evidence=(PythonVerificationEvidence("pytest-gate", "true"),), + policy=policy, + ) + for dependency, signal in matched_dependency_signals( + metadata.dependencies, + policy.dependency_signals, + ): + if metadata_owner_path is None: + continue + _append_candidate( + candidates, + candidate_index_by_path, + owner_path=metadata_owner_path, + owner_namespace=(), + responsibilities=signal.responsibilities, + evidence=( + PythonVerificationEvidence( + "dependency", + require_dependency_name(dependency), + ), + ), + task_kinds=signal.task_kinds, + policy=policy, + ) + return PythonVerificationProfileIndex( + project_root=project_root, + candidates=tuple(sorted(candidates, key=lambda item: item.owner_path)), + ) + + +def _responsibilities_for_node( + responsibilities: tuple[PythonOwnerResponsibility, ...], + *, + namespace: tuple[str, ...], + aggregate_public_namespaces: frozenset[tuple[str, ...]], +) -> tuple[PythonOwnerResponsibility, ...]: + if not responsibilities: + return () + if not any( + responsibility == PythonOwnerResponsibility.PUBLIC_API + for responsibility in responsibilities + ): + return responsibilities + if not _is_covered_by_public_branch( + namespace, + aggregate_public_namespaces=aggregate_public_namespaces, + ): + return responsibilities + return tuple( + responsibility + for responsibility in responsibilities + if responsibility != PythonOwnerResponsibility.PUBLIC_API + ) + + +def _is_covered_by_public_branch( + namespace: tuple[str, ...], + *, + aggregate_public_namespaces: frozenset[tuple[str, ...]], +) -> bool: + return any( + namespace != branch_namespace + and len(namespace) > len(branch_namespace) + and namespace[: len(branch_namespace)] == branch_namespace + for branch_namespace in aggregate_public_namespaces + ) + + +def _append_candidate( + candidates: list[PythonVerificationProfileCandidate], + candidate_index_by_path: dict[str, int], + *, + owner_path: str, + owner_namespace: tuple[str, ...], + responsibilities: tuple[PythonOwnerResponsibility, ...], + evidence: tuple[PythonVerificationEvidence, ...], + policy: PythonVerificationPolicy, + task_kinds: tuple[PythonVerificationTaskKind, ...] = (), +) -> None: + existing_index = candidate_index_by_path.get(owner_path) + effective_task_kinds = task_kinds or policy.task_kinds_for_responsibilities( + responsibilities + ) + if existing_index is not None: + existing = candidates[existing_index] + combined_responsibilities = _merge_tuple( + existing.responsibilities, + responsibilities, + ) + combined_task_kinds = _merge_tuple(existing.task_kinds, effective_task_kinds) + combined_evidence = _merge_tuple(existing.evidence, evidence) + candidates[existing_index] = PythonVerificationProfileCandidate( + owner_path=owner_path, + owner_namespace=existing.owner_namespace or owner_namespace, + responsibilities=combined_responsibilities, + state=_candidate_state(owner_path, combined_responsibilities, policy), + configured_responsibilities=_configured_responsibilities( + owner_path, + policy, + ), + evidence=combined_evidence, + task_kinds=combined_task_kinds, + ) + return + candidate_index_by_path[owner_path] = len(candidates) + candidates.append( + PythonVerificationProfileCandidate( + owner_path=owner_path, + owner_namespace=owner_namespace, + responsibilities=responsibilities, + state=_candidate_state(owner_path, responsibilities, policy), + configured_responsibilities=_configured_responsibilities( + owner_path, + policy, + ), + evidence=evidence, + task_kinds=effective_task_kinds, + ) + ) + + +def _candidate_state( + owner_path: str, + responsibilities: tuple[PythonOwnerResponsibility, ...], + policy: PythonVerificationPolicy, +) -> str: + hint = _matching_hint(owner_path, policy.profile_hints) + if hint is None: + return "missing_profile" + if set(responsibilities).issubset(set(hint.responsibilities)): + return "configured" + return "profile_drift" + + +def _configured_responsibilities( + owner_path: str, + policy: PythonVerificationPolicy, +) -> tuple[PythonOwnerResponsibility, ...]: + hint = _matching_hint(owner_path, policy.profile_hints) + if hint is None: + return () + return hint.responsibilities + + +def _merge_tuple[T](left: tuple[T, ...], right: tuple[T, ...]) -> tuple[T, ...]: + merged = list(left) + for item in right: + if item in merged: + continue + merged.append(item) + return tuple(merged) + + +def _matching_hint( + owner_path: str, + hints: tuple[PythonVerificationProfileHint, ...], +) -> PythonVerificationProfileHint | None: + return next((hint for hint in hints if hint.owner_path == owner_path), None) diff --git a/src/python_lang_project_harness/verification/render.py b/src/python_lang_project_harness/verification/render.py new file mode 100644 index 0000000..17fda67 --- /dev/null +++ b/src/python_lang_project_harness/verification/render.py @@ -0,0 +1,182 @@ +"""Compact and JSON renderers for Python verification plans.""" + +from __future__ import annotations + +import json + +from .indices import ( + build_python_verification_performance_index, + build_python_verification_task_index, +) +from .model import ( + PythonVerificationPlan, + PythonVerificationProfileCandidate, + PythonVerificationProfileIndex, + PythonVerificationTask, +) + + +def render_python_verification_plan(plan: PythonVerificationPlan) -> str: + """Render active verification tasks as compact Agent-facing text.""" + + if not plan.active_tasks: + return "" + lines = ["[verify]"] + for task in plan.active_tasks: + lines.extend(_render_task(task)) + if plan.report_obligations: + lines.append("[verify-report]") + for obligation in plan.report_obligations: + kinds = ",".join(kind.value for kind in obligation.task_kinds) + lines.append( + "- required: " + f"{obligation.key} renderer={obligation.renderer} " + f"artifact={obligation.artifact} tasks={len(plan.active_tasks)} " + f"kinds={kinds} persistence={obligation.persistence.value}" + ) + return "\n".join(lines) + "\n" + + +def render_python_verification_plan_json(plan: PythonVerificationPlan) -> str: + """Render a structured verification plan JSON payload.""" + + return json.dumps(plan.to_dict(), separators=(",", ":"), sort_keys=True) + + +def render_python_verification_profile_index( + index: PythonVerificationProfileIndex, + *, + max_candidates: int | None = 24, +) -> str: + """Render parser-suggested verification profile reminders.""" + + candidates = tuple( + candidate for candidate in index.candidates if candidate.state != "configured" + ) + if not candidates: + return "" + visible_candidates = ( + candidates if max_candidates is None else candidates[:max_candidates] + ) + sections = [ + _render_profile_candidate(candidate).rstrip("\n") + for candidate in visible_candidates + ] + omitted = len(candidates) - len(visible_candidates) + if omitted > 0: + sections.append(f"... +{omitted} verification profile candidates") + return "\n".join(sections) + "\n" + + +def _render_profile_candidate( + candidate: PythonVerificationProfileCandidate, +) -> str: + lines = [f"[verify-profile] {candidate.owner_path}"] + if candidate.owner_namespace: + lines.append(f" |owner: {'.'.join(candidate.owner_namespace)}") + lines.append(f" |state: {candidate.state}") + if candidate.configured_responsibilities: + lines.append( + " |configured: " + + ",".join( + responsibility.value + for responsibility in candidate.configured_responsibilities + ) + ) + lines.append( + " |suggest: " + + ",".join( + responsibility.value for responsibility in candidate.responsibilities + ) + ) + lines.append(" |tasks: " + ",".join(kind.value for kind in candidate.task_kinds)) + for evidence in candidate.evidence: + lines.append(f" |fact: {evidence.label}={evidence.value}") + return "\n".join(lines) + "\n" + + +def render_python_verification_profile_index_json( + index: PythonVerificationProfileIndex, +) -> str: + """Render profile-index JSON.""" + + return json.dumps(index.to_dict(), separators=(",", ":"), sort_keys=True) + + +def render_python_verification_task_index_json(plan: PythonVerificationPlan) -> str: + """Render active verification task-index JSON.""" + + return json.dumps( + build_python_verification_task_index(plan), + separators=(",", ":"), + sort_keys=True, + ) + + +def render_python_verification_performance_index_json( + plan: PythonVerificationPlan, +) -> str: + """Render active performance verification index JSON.""" + + return json.dumps( + build_python_verification_performance_index(plan), + separators=(",", ":"), + sort_keys=True, + ) + + +def render_python_verification_skill_contracts(plan: PythonVerificationPlan) -> str: + """Render task contracts as a compact skill-dispatch tree.""" + + if not plan.active_tasks: + return "" + lines = ["[verify-contracts]"] + for task in plan.active_tasks: + binding = ( + "" + if task.skill_binding is None + else f" skill={task.skill_binding.dispatch_hint}" + ) + contract_ref = ( + "" + if task.skill_descriptor is None + else f" contract_ref={task.skill_descriptor.compact_label}" + ) + lines.append( + f"- {task.fingerprint}: kind={task.kind.value} " + f"phase={task.phase.value}{binding}{contract_ref}" + ) + lines.append(f" contract: {task.contract.summary}") + for requirement in task.contract.requirements: + lines.append(f" required: {requirement.label}={requirement.detail}") + if task.skill_descriptor is not None: + lines.append(f" descriptor: {task.skill_descriptor.summary}") + for requirement in task.skill_descriptor.requirements: + lines.append( + f" descriptor-required: {requirement.label}={requirement.detail}" + ) + return "\n".join(lines) + "\n" + + +def _render_task(task: PythonVerificationTask) -> list[str]: + responsibilities = ",".join( + responsibility.value for responsibility in task.responsibilities + ) + binding = ( + "" + if task.skill_binding is None + else f" skill={task.skill_binding.dispatch_hint}" + ) + contract_ref = ( + "" + if task.skill_descriptor is None + else f" contract_ref={task.skill_descriptor.compact_label}" + ) + evidence = " ".join(f"{item.label}={item.value}" for item in task.evidence) + suffix = "" if not evidence else f" evidence={evidence}" + return [ + f"- {task.owner_path}: {task.kind.value} {task.state.value} " + f"phase={task.phase.value} fingerprint={task.fingerprint}{binding}{contract_ref}", + f" why: {task.why} responsibilities={responsibilities}{suffix}", + f" contract: {task.contract.summary}", + ] diff --git a/src/python_lang_project_harness/verification/report.py b/src/python_lang_project_harness/verification/report.py new file mode 100644 index 0000000..c9355e5 --- /dev/null +++ b/src/python_lang_project_harness/verification/report.py @@ -0,0 +1,230 @@ +"""Report bundle writer for Python verification plans.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + +from .model import ( + PythonVerificationPlan, + PythonVerificationReportObligation, + PythonVerificationReportPersistence, +) +from .render import ( + render_python_verification_performance_index_json, + render_python_verification_plan_json, + render_python_verification_task_index_json, +) + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReportArtifact: + """Manifest entry for one persistable verification artifact.""" + + key: str + renderer: str + artifact: str + persistence: PythonVerificationReportPersistence + task_kinds: tuple[str, ...] + + @classmethod + def from_obligation( + cls, + obligation: PythonVerificationReportObligation, + ) -> PythonVerificationReportArtifact: + """Build one artifact manifest entry from a plan obligation.""" + + return cls( + key=obligation.key, + renderer=obligation.renderer, + artifact=obligation.artifact, + persistence=obligation.persistence, + task_kinds=tuple(kind.value for kind in obligation.task_kinds), + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "key": self.key, + "renderer": self.renderer, + "artifact": self.artifact, + "persistence": self.persistence.value, + "task_kinds": list(self.task_kinds), + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReportBundle: + """Small manifest for modular verification report artifacts.""" + + project_root: Path + artifacts: tuple[PythonVerificationReportArtifact, ...] + + def artifact(self, key: str) -> PythonVerificationReportArtifact | None: + """Return one artifact by key.""" + + return next( + (artifact for artifact in self.artifacts if artifact.key == key), None + ) + + def source_baseline_artifacts( + self, + ) -> tuple[PythonVerificationReportArtifact, ...]: + """Return artifacts intended for source-controlled baselines.""" + + return tuple( + artifact + for artifact in self.artifacts + if artifact.persistence + == PythonVerificationReportPersistence.SOURCE_BASELINE + ) + + def runtime_cache_artifacts( + self, + ) -> tuple[PythonVerificationReportArtifact, ...]: + """Return artifacts intended for runtime cache storage.""" + + return tuple( + artifact + for artifact in self.artifacts + if artifact.persistence == PythonVerificationReportPersistence.RUNTIME_CACHE + ) + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "project_root": str(self.project_root), + "artifacts": [item.to_dict() for item in self.artifacts], + } + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReportWriteConfig: + """Filesystem roots for modular verification report persistence.""" + + source_baseline_dir: Path + runtime_cache_dir: Path + + +@dataclass(frozen=True, slots=True) +class PythonVerificationReportWriteReceipt: + """Paths written by `write_python_verification_reports`.""" + + manifest_paths: tuple[Path, ...] + artifact_paths: tuple[Path, ...] + + def to_dict(self) -> dict[str, object]: + """Return a JSON-compatible representation.""" + + return { + "manifest_paths": [str(path) for path in self.manifest_paths], + "artifact_paths": [str(path) for path in self.artifact_paths], + } + + +def build_python_verification_report_bundle( + plan: PythonVerificationPlan, +) -> PythonVerificationReportBundle: + """Build a report artifact manifest for active verification tasks.""" + + return PythonVerificationReportBundle( + project_root=plan.project_root, + artifacts=tuple( + PythonVerificationReportArtifact.from_obligation(obligation) + for obligation in plan.report_obligations + ), + ) + + +def render_python_verification_report_bundle_json( + plan: PythonVerificationPlan, +) -> str: + """Render the report bundle manifest as JSON.""" + + return json.dumps( + build_python_verification_report_bundle(plan).to_dict(), + separators=(",", ":"), + sort_keys=True, + ) + + +def render_python_verification_report_artifact_json( + plan: PythonVerificationPlan, + key: str, +) -> str | None: + """Render one report artifact payload by key.""" + + match key: + case "verification_plan_json": + return render_python_verification_plan_json(plan) + case "task_index_json": + return render_python_verification_task_index_json(plan) + case "performance_index_json": + return render_python_verification_performance_index_json(plan) + case _: + return None + + +def write_python_verification_reports( + plan: PythonVerificationPlan, + config: PythonVerificationReportWriteConfig, +) -> PythonVerificationReportWriteReceipt: + """Write modular verification artifacts and manifests.""" + + bundle = build_python_verification_report_bundle(plan) + source_manifest_path = ( + config.source_baseline_dir / "verification_report_manifest.json" + ) + runtime_manifest_path = ( + config.runtime_cache_dir / "verification_report_manifest.json" + ) + artifact_paths: list[Path] = [] + _write_manifest( + source_manifest_path, + project_root=bundle.project_root, + artifacts=bundle.source_baseline_artifacts(), + ) + _write_manifest( + runtime_manifest_path, + project_root=bundle.project_root, + artifacts=bundle.artifacts, + ) + for artifact in bundle.artifacts: + payload = render_python_verification_report_artifact_json(plan, artifact.key) + if payload is None: + continue + target_dir = ( + config.source_baseline_dir + if artifact.persistence + == PythonVerificationReportPersistence.SOURCE_BASELINE + else config.runtime_cache_dir + ) + target_path = target_dir / artifact.artifact + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(payload + "\n", encoding="utf-8") + artifact_paths.append(target_path) + return PythonVerificationReportWriteReceipt( + manifest_paths=(source_manifest_path, runtime_manifest_path), + artifact_paths=tuple(artifact_paths), + ) + + +def _write_manifest( + path: Path, + *, + project_root: Path, + artifacts: tuple[PythonVerificationReportArtifact, ...], +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps( + { + "project_root": str(project_root), + "artifacts": [artifact.to_dict() for artifact in artifacts], + }, + separators=(",", ":"), + sort_keys=True, + ) + path.write_text(payload + "\n", encoding="utf-8") diff --git a/tests/unit/harness/project_policy/test_catalog.py b/tests/unit/harness/project_policy/test_catalog.py index 3f8c0a9..1ea1007 100644 --- a/tests/unit/harness/project_policy/test_catalog.py +++ b/tests/unit/harness/project_policy/test_catalog.py @@ -26,4 +26,5 @@ def test_project_policy_rule_pack_descriptor_and_catalog_are_stable() -> None: "PY-PROJ-R007", "PY-PROJ-R008", "PY-PROJ-R009", + "PY-PROJ-R010", ] diff --git a/tests/unit/harness/project_policy/test_metadata_policy.py b/tests/unit/harness/project_policy/test_metadata_policy.py index 65a54ef..1215164 100644 --- a/tests/unit/harness/project_policy/test_metadata_policy.py +++ b/tests/unit/harness/project_policy/test_metadata_policy.py @@ -94,6 +94,82 @@ def test_project_policy_allows_tool_only_pyproject(tmp_path: Path) -> None: ) +def test_project_policy_requires_pytest_gate_for_harness_dev_dependency( + tmp_path: Path, +) -> None: + _write_pyproject( + tmp_path, + """ +[project] +name = "example-pkg" +requires-python = ">=3.12" + +[dependency-groups] +test = [ + "python-lang-project-harness[pytest]>=0.1.0", +] +""", + ) + + report = run_python_project_harness(tmp_path) + + assert [ + (finding.rule_id, finding.location.path) for finding in report.findings + ] == [ + ("PY-PROJ-R010", str(tmp_path / "pyproject.toml")), + ] + + +def test_project_policy_accepts_pytest_addopts_gate(tmp_path: Path) -> None: + _write_pyproject( + tmp_path, + """ +[project] +name = "example-pkg" +requires-python = ">=3.12" + +[dependency-groups] +test = [ + "python-lang-project-harness[pytest]>=0.1.0", +] + +[tool.pytest.ini_options] +addopts = ["--python-project-harness"] +""", + ) + + report = run_python_project_harness(tmp_path) + + assert not any(finding.rule_id == "PY-PROJ-R010" for finding in report.findings) + + +def test_project_policy_accepts_explicit_pytest_helper_gate(tmp_path: Path) -> None: + _write_pyproject( + tmp_path, + """ +[project] +name = "example-pkg" +requires-python = ">=3.12" + +[dependency-groups] +test = [ + "python-lang-project-harness[pytest]>=0.1.0", +] +""", + ) + tests = tmp_path / "tests" / "unit" + tests.mkdir(parents=True) + (tests / "test_harness_policy.py").write_text( + "from python_lang_project_harness.pytest import python_project_harness_test\n" + "test_python_project_harness_policy = python_project_harness_test()\n", + encoding="utf-8", + ) + + report = run_python_project_harness(tmp_path) + + assert not any(finding.rule_id == "PY-PROJ-R010" for finding in report.findings) + + def _write_pyproject(project_root: Path, content: str) -> None: (project_root / "src").mkdir() (project_root / "pyproject.toml").write_text( diff --git a/tests/unit/harness/test_cli.py b/tests/unit/harness/test_cli.py index 63e4b7c..dc1df74 100644 --- a/tests/unit/harness/test_cli.py +++ b/tests/unit/harness/test_cli.py @@ -42,6 +42,26 @@ def test_cli_json_flag_renders_structured_report(tmp_path: Path) -> None: assert payload["project_scope"]["project_root"] == str(tmp_path) +def test_cli_agent_snapshot_renders_parser_backed_project_shape( + tmp_path: Path, +) -> None: + package = tmp_path / "src" / "pkg" + package.mkdir(parents=True) + (package / "__init__.py").write_text('"""Package docs."""\n', encoding="utf-8") + stdout = io.StringIO() + + exit_code = run_cli(["--agent-snapshot", str(tmp_path)], stdout=stdout) + + assert exit_code == 0 + rendered = stdout.getvalue() + assert rendered.startswith("[agent-snapshot] . python\n") + assert "[tree] . python" in rendered + assert "Modules: source=1" in rendered + assert "[nodes]" not in rendered + assert "[ok]" not in rendered + assert str(tmp_path) not in rendered + + def test_cli_keeps_agent_advice_non_blocking(tmp_path: Path) -> None: src = tmp_path / "src" src.mkdir() @@ -136,10 +156,22 @@ def test_cli_help_and_argument_errors_are_stable(tmp_path: Path) -> None: error_stderr = io.StringIO() assert run_cli(["--help"], stdout=help_stdout) == 0 - assert "python-project-harness [--json] [--no-tests]" in help_stdout.getvalue() + assert ( + "python-project-harness [--json | --agent-snapshot] [--no-tests]" + in help_stdout.getvalue() + ) assert run_cli(["--bogus"], stderr=error_stderr) == 2 assert "unknown option: --bogus" in error_stderr.getvalue() assert run_cli([str(tmp_path), str(tmp_path)], stderr=io.StringIO()) == 2 + mutually_exclusive_stderr = io.StringIO() + assert ( + run_cli( + ["--json", "--agent-snapshot", str(tmp_path)], + stderr=mutually_exclusive_stderr, + ) + == 2 + ) + assert "mutually exclusive" in mutually_exclusive_stderr.getvalue() def test_cli_scope_flags_customize_project_paths(tmp_path: Path) -> None: diff --git a/tests/unit/harness/test_modularity_catalog.py b/tests/unit/harness/test_modularity_catalog.py new file mode 100644 index 0000000..25cb635 --- /dev/null +++ b/tests/unit/harness/test_modularity_catalog.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_lang_project_harness import run_python_project_harness + +if TYPE_CHECKING: + from pathlib import Path + + +def test_modularity_rule_pack_blocks_large_class_only_service_module( + tmp_path: Path, +) -> None: + src = tmp_path / "src" + src.mkdir() + source = src / "services.py" + source.write_text(_large_class_only_service_module_source(), encoding="utf-8") + + report = run_python_project_harness(tmp_path) + + assert [ + (finding.rule_id, finding.location.path) for finding in report.findings + ] == [ + ("PY-MOD-R006", str(source)), + ] + + +def _large_class_only_service_module_source() -> str: + parts = ['"""Large class-only service module for tests."""\n\n'] + for index in range(12): + parts.append( + f"class Service{index}:\n" + " def __init__(self, value: int) -> None:\n" + " self.value = value\n\n" + ) + for method_index in range(6): + parts.append( + f" def handle_{method_index}(self, input_value: int) -> int:\n" + ) + for line in range(3): + parts.append(f" value_{line} = input_value + {line}\n") + parts.append(" return value_0 + self.value\n\n") + return "".join(parts) diff --git a/tests/unit/harness/test_parser_boundary_contract.py b/tests/unit/harness/test_parser_boundary_contract.py index 2f1511a..94ce09c 100644 --- a/tests/unit/harness/test_parser_boundary_contract.py +++ b/tests/unit/harness/test_parser_boundary_contract.py @@ -11,7 +11,7 @@ def test_harness_policy_does_not_parse_python_source_directly() -> None: harness_sources = sorted( - (_PROJECT_ROOT / "src" / "python_lang_project_harness").glob("*.py") + (_PROJECT_ROOT / "src" / "python_lang_project_harness").rglob("*.py") ) for path in harness_sources: @@ -24,7 +24,7 @@ def test_harness_policy_does_not_parse_python_source_directly() -> None: def test_harness_semantic_roles_use_parser_symbol_helpers() -> None: harness_sources = sorted( - (_PROJECT_ROOT / "src" / "python_lang_project_harness").glob("*.py") + (_PROJECT_ROOT / "src" / "python_lang_project_harness").rglob("*.py") ) for path in harness_sources: if path.name == "__init__.py": @@ -110,3 +110,16 @@ def test_test_layout_python_source_lines_use_parser_reports() -> None: assert "tests_root_entry_findings(tests_dir, pack_id, modules)" in layout assert 'if path.suffix == ".py":' in entries assert "return report.source_line(line)" in entries + + +def test_harness_pyproject_metadata_comes_from_parser_boundary() -> None: + harness_sources = sorted( + (_PROJECT_ROOT / "src" / "python_lang_project_harness").rglob("*.py") + ) + + for path in harness_sources: + if path.name in {"_project_config.py", "_test_layout_config.py"}: + continue + source = path.read_text(encoding="utf-8") + assert "import tomllib" not in source, path + assert "tomllib." not in source, path diff --git a/tests/unit/harness/test_policy_contract.py b/tests/unit/harness/test_policy_contract.py index da5cf6e..cfb70cc 100644 --- a/tests/unit/harness/test_policy_contract.py +++ b/tests/unit/harness/test_policy_contract.py @@ -57,6 +57,7 @@ "unit_test__policy_snapshot__py_proj_r007_build_requires.snap", "unit_test__policy_snapshot__py_proj_r008_import_names.snap", "unit_test__policy_snapshot__py_proj_r009_entry_point_target.snap", + "unit_test__policy_snapshot__py_proj_r010_pytest_gate.snap", "unit_test__policy_snapshot__py_test_r001_root_pytest.snap", "unit_test__policy_snapshot__py_test_r002_unexpected_root.snap", "unit_test__policy_snapshot__py_test_r003_unit_bloat.snap", @@ -118,6 +119,7 @@ def test_rule_catalogs_expose_stable_rule_ids() -> None: "PY-PROJ-R007", "PY-PROJ-R008", "PY-PROJ-R009", + "PY-PROJ-R010", ] assert [rule.rule_id for rule in python_modern_design_rules()] == [ "PY-MOD-R001", diff --git a/tests/unit/harness/test_policy_snapshots.py b/tests/unit/harness/test_policy_snapshots.py index 9b084d2..e75f8eb 100644 --- a/tests/unit/harness/test_policy_snapshots.py +++ b/tests/unit/harness/test_policy_snapshots.py @@ -237,6 +237,25 @@ def test_py_proj_r009_entry_point_target_snapshot(tmp_path: Path) -> None: ) +def test_py_proj_r010_pytest_gate_snapshot(tmp_path: Path) -> None: + _src(tmp_path) + (tmp_path / "pyproject.toml").write_text( + """ +[project] +name = "snapshot-package" +requires-python = ">=3.12" + +[dependency-groups] +test = [ + "python-lang-project-harness[pytest]>=0.1.0", +] +""".lstrip(), + encoding="utf-8", + ) + + _assert_project_snapshot(tmp_path, "PY-PROJ-R010", "py_proj_r010_pytest_gate") + + def test_py_test_r001_root_pytest_snapshot(tmp_path: Path) -> None: tests = tmp_path / "tests" tests.mkdir() diff --git a/tests/unit/harness/test_verification.py b/tests/unit/harness/test_verification.py new file mode 100644 index 0000000..e693be7 --- /dev/null +++ b/tests/unit/harness/test_verification.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from python_lang_project_harness import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationPhase, + PythonVerificationProfileHint, + PythonVerificationReceipt, + PythonVerificationReportWriteConfig, + PythonVerificationRequirement, + PythonVerificationSkillBinding, + PythonVerificationSkillDescriptor, + PythonVerificationTaskContract, + PythonVerificationTaskKind, + build_python_verification_profile_index_with_config, + build_python_verification_report_bundle, + default_python_harness_config, + plan_python_project_verification_with_config, + read_python_project_harness_config, + render_python_project_harness_agent_snapshot_with_config, + render_python_verification_plan, + render_python_verification_profile_index, + render_python_verification_profile_index_json, + render_python_verification_report_artifact_json, + render_python_verification_report_bundle_json, + render_python_verification_skill_contracts, + write_python_verification_reports, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_verification_profile_hint_plans_external_task( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + .with_rationale("this public API needs a security review") + ) + + plan = plan_python_project_verification_with_config(tmp_path, config) + rendered = render_python_verification_plan(plan) + + assert len(plan.active_tasks) == 1 + assert plan.active_tasks[0].kind == PythonVerificationTaskKind.SECURITY + assert "[verify]" in rendered + assert "src/pkg/api.py: security pending" in rendered + assert "[verify-report]" in rendered + + +def test_verification_receipt_satisfies_matching_task( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + base_config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + ) + pending_plan = plan_python_project_verification_with_config(tmp_path, base_config) + receipt = PythonVerificationReceipt( + task_fingerprint=pending_plan.active_tasks[0].fingerprint, + summary="pytest regression passed", + ) + + satisfied_plan = plan_python_project_verification_with_config( + tmp_path, + base_config.with_verification_receipt(receipt), + ) + + assert satisfied_plan.active_tasks == () + assert render_python_verification_plan(satisfied_plan) == "" + assert satisfied_plan.tasks[0].receipt == receipt + + +def test_verification_profile_index_uses_parser_and_dependency_facts( + tmp_path: Path, +) -> None: + _write_public_api_project( + tmp_path, + dependencies='dependencies = ["httpx>=0.28"]', + ) + config = default_python_harness_config().with_verification_dependency_signal( + PythonVerificationDependencySignal( + "httpx", + (PythonOwnerResponsibility.NETWORK,), + task_kinds=(PythonVerificationTaskKind.STRESS,), + ) + ) + + index = build_python_verification_profile_index_with_config(tmp_path, config) + rendered = render_python_verification_profile_index(index) + profile_payload = json.loads(render_python_verification_profile_index_json(index)) + metadata_hint = next( + hint + for hint in index.active_profile_hints() + if hint.owner_path == "pyproject.toml" + ) + plan_from_hint = plan_python_project_verification_with_config( + tmp_path, + config.with_verification_profile_hint(metadata_hint), + ) + + assert "[verify-profile] src/pkg/api.py" in rendered + assert " |state: missing_profile" in rendered + assert " |suggest: public_api" in rendered + assert "[verify-profile] pyproject.toml" in rendered + assert " |suggest: network" in rendered + assert " |fact: dependency=httpx" in rendered + assert profile_payload["active_profile_hints"] + assert len(plan_from_hint.active_tasks) == 1 + assert plan_from_hint.active_tasks[0].owner_path == "pyproject.toml" + assert plan_from_hint.active_tasks[0].kind == PythonVerificationTaskKind.STRESS + + +def test_verification_report_bundle_and_writer_emit_modular_artifacts( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.PERFORMANCE,)) + .with_rationale("this public API is latency-sensitive") + ) + plan = plan_python_project_verification_with_config(tmp_path, config) + + bundle = json.loads(render_python_verification_report_bundle_json(plan)) + bundle_object = build_python_verification_report_bundle(plan) + plan_payload = render_python_verification_report_artifact_json( + plan, + "verification_plan_json", + ) + receipt = write_python_verification_reports( + plan, + PythonVerificationReportWriteConfig( + source_baseline_dir=tmp_path / ".harness" / "baseline", + runtime_cache_dir=tmp_path / ".harness" / "cache", + ), + ) + + assert [item["artifact"] for item in bundle["artifacts"]] == [ + "verification_plan.json", + "verification_task_index.json", + "performance_index.json", + ] + assert bundle["project_root"] == str(tmp_path) + assert [item.artifact for item in bundle_object.source_baseline_artifacts()] == [ + "verification_task_index.json", + "performance_index.json", + ] + assert [item.artifact for item in bundle_object.runtime_cache_artifacts()] == [ + "verification_plan.json", + ] + assert bundle_object.artifact("task_index_json") is not None + assert plan_payload is not None + assert "performance" in plan_payload + assert len(receipt.artifact_paths) == 3 + assert all(path.exists() for path in receipt.artifact_paths) + source_manifest = json.loads(receipt.manifest_paths[0].read_text(encoding="utf-8")) + runtime_manifest = json.loads(receipt.manifest_paths[1].read_text(encoding="utf-8")) + assert source_manifest["project_root"] == str(tmp_path) + assert runtime_manifest["project_root"] == str(tmp_path) + assert [item["artifact"] for item in source_manifest["artifacts"]] == [ + "verification_task_index.json", + "performance_index.json", + ] + assert [item["artifact"] for item in runtime_manifest["artifacts"]] == [ + "verification_plan.json", + "verification_task_index.json", + "performance_index.json", + ] + + +def test_verification_policy_can_be_loaded_from_pyproject_config( + tmp_path: Path, +) -> None: + _write_public_api_project( + tmp_path, + harness_config=""" +[tool.python-lang-project-harness.verification] +profile_hints = [ + { owner_path = "src/pkg/api.py", responsibilities = ["public_api"], task_kinds = ["security"], rationale = "authz-sensitive public API" }, +] +dependency_signals = [ + { package_name = "httpx", responsibilities = ["network"], task_kinds = ["stress"] }, +] + +[tool.python-lang-project-harness.verification.task_contracts] +security = { phase = "before_release", summary = "security skill must report authz evidence", requirements = [{ label = "authz", detail = "tenant authorization result" }] } + +[tool.python-lang-project-harness.verification.skill_bindings] +security = { skill = "python-security-review", adapter = "bandit" } + +[tool.python-lang-project-harness.verification.skill_descriptors] +python-security-review = { task_kind = "security", adapter = "bandit", summary = "run bandit plus tenant authz probes", requirements = [{ label = "bandit", detail = "bandit report artifact" }] } +""", + ) + + config = read_python_project_harness_config(tmp_path) + + assert config is not None + assert config.verification_policy.profile_hints[0].owner_path == "src/pkg/api.py" + assert config.verification_policy.dependency_signals[0].package_name == "httpx" + assert ( + config.verification_policy.skill_bindings[ + PythonVerificationTaskKind.SECURITY + ].dispatch_hint + == "python-security-review@bandit" + ) + assert config.verification_policy.task_contracts[ + PythonVerificationTaskKind.SECURITY + ].requirements[0] == PythonVerificationRequirement( + "authz", "tenant authorization result" + ) + assert config.verification_policy.skill_descriptors[ + "python-security-review@bandit" + ].requirements[0] == PythonVerificationRequirement( + "bandit", "bandit report artifact" + ) + + +def test_verification_skill_binding_renders_contract_reference( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + hint = ( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + .with_task_contract( + PythonVerificationTaskKind.SECURITY, + PythonVerificationTaskContract( + PythonVerificationPhase.BEFORE_RELEASE, + "security review must include tenant authz evidence", + ( + PythonVerificationRequirement( + "tenant-authz", + "tenant authorization probe result", + ), + ), + ), + ) + .with_rationale("this public API changes tenant authorization") + ) + config = ( + default_python_harness_config() + .with_verification_profile_hint(hint) + .with_verification_skill_binding( + PythonVerificationTaskKind.SECURITY, + PythonVerificationSkillBinding( + "python-security-review", + adapter="bandit", + ), + ) + .with_verification_skill_descriptor( + PythonVerificationSkillDescriptor( + "python-security-review", + PythonVerificationTaskKind.SECURITY, + "run bandit and tenant authz probes", + requirements=( + PythonVerificationRequirement( + "bandit", + "bandit report artifact", + ), + ), + adapter="bandit", + ) + ) + ) + + plan = plan_python_project_verification_with_config(tmp_path, config) + compact = render_python_verification_plan(plan) + contracts = render_python_verification_skill_contracts(plan) + + assert "skill=python-security-review@bandit" in compact + assert "contract_ref=python-security-review@bandit" in compact + assert "contract: security review must include tenant authz evidence" in contracts + assert "descriptor: run bandit and tenant authz probes" in contracts + assert "descriptor-required: bandit=bandit report artifact" in contracts + + +def test_agent_snapshot_includes_active_verification_tasks( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + .with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + .with_rationale("this public API needs a security review") + ) + + rendered = render_python_project_harness_agent_snapshot_with_config( + tmp_path, + config, + ) + + assert "[agent-snapshot] . python" in rendered + assert "[tree] . python" in rendered + assert "[verify-profile]" in rendered + assert "[verify]" in rendered + assert "src/pkg/api.py: security pending" in rendered + + +def _write_public_api_project( + project_root: Path, + *, + dependencies: str = "", + harness_config: str = "", +) -> None: + package = project_root / "src" / "pkg" + package.mkdir(parents=True) + (package / "__init__.py").write_text( + '"""Package docs."""\n__all__ = ["build"]\nfrom .api import build\n', + encoding="utf-8", + ) + (package / "api.py").write_text( + '"""Public API."""\n\n\ndef build(value: int) -> int:\n return value\n', + encoding="utf-8", + ) + (package / "py.typed").write_text("", encoding="utf-8") + dependency_line = "" if not dependencies else f"{dependencies}\n" + (project_root / "pyproject.toml").write_text( + f""" +[project] +name = "pkg" +requires-python = ">=3.12" +import-names = ["pkg"] +{dependency_line} +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pkg"] + +{harness_config} +""".lstrip(), + encoding="utf-8", + ) diff --git a/tests/unit/harness/verification/test_policy_regressions.py b/tests/unit/harness/verification/test_policy_regressions.py new file mode 100644 index 0000000..9ea7cd1 --- /dev/null +++ b/tests/unit/harness/verification/test_policy_regressions.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_lang_project_harness import ( + PythonOwnerResponsibility, + PythonVerificationDependencySignal, + PythonVerificationProfileHint, + PythonVerificationTaskKind, + default_python_harness_config, + plan_python_project_verification_with_config, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_dependency_signal_uses_pep503_distribution_names( + tmp_path: Path, +) -> None: + _write_public_api_project( + tmp_path, + dependencies='dependencies = ["zope.interface>=6"]', + ) + config = default_python_harness_config().with_verification_dependency_signal( + PythonVerificationDependencySignal( + "zope-interface", + (PythonOwnerResponsibility.NETWORK,), + task_kinds=(PythonVerificationTaskKind.STRESS,), + ) + ) + + plan = plan_python_project_verification_with_config(tmp_path, config) + + assert len(plan.active_tasks) == 1 + assert plan.active_tasks[0].owner_path == "pyproject.toml" + assert plan.active_tasks[0].kind == PythonVerificationTaskKind.STRESS + assert "zope.interface" in { + evidence.value for evidence in plan.active_tasks[0].evidence + } + + +def test_profile_hint_uses_configured_responsibility_task_mapping( + tmp_path: Path, +) -> None: + _write_public_api_project(tmp_path) + base_config = default_python_harness_config() + policy = base_config.verification_policy.with_responsibility_task_kinds( + PythonOwnerResponsibility.PUBLIC_API, + (PythonVerificationTaskKind.SECURITY,), + ) + config = base_config.with_verification_policy( + policy + ).with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/api.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ).with_task_kinds((PythonVerificationTaskKind.SECURITY,)) + ) + + plan = plan_python_project_verification_with_config(tmp_path, config) + + assert [task.kind for task in plan.active_tasks] == [ + PythonVerificationTaskKind.SECURITY, + ] + + +def _write_public_api_project( + project_root: Path, + *, + dependencies: str = "", +) -> None: + package = project_root / "src" / "pkg" + package.mkdir(parents=True) + (package / "__init__.py").write_text( + '"""Package docs."""\n__all__ = ["build"]\nfrom .api import build\n', + encoding="utf-8", + ) + (package / "api.py").write_text( + '"""Public API."""\n\n\ndef build(value: int) -> int:\n return value\n', + encoding="utf-8", + ) + (package / "py.typed").write_text("", encoding="utf-8") + dependency_line = "" if not dependencies else f"{dependencies}\n" + (project_root / "pyproject.toml").write_text( + f""" +[project] +name = "pkg" +requires-python = ">=3.12" +import-names = ["pkg"] +{dependency_line} +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pkg"] +""".lstrip(), + encoding="utf-8", + ) diff --git a/tests/unit/harness/verification/test_profile_index.py b/tests/unit/harness/verification/test_profile_index.py new file mode 100644 index 0000000..29adf25 --- /dev/null +++ b/tests/unit/harness/verification/test_profile_index.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_lang_project_harness import ( + PythonOwnerResponsibility, + PythonVerificationProfileHint, + build_python_verification_profile_index_with_config, + default_python_harness_config, + render_python_verification_profile_index, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def test_profile_index_aggregates_public_branch_owners(tmp_path: Path) -> None: + _write_public_branch_project(tmp_path) + + index = build_python_verification_profile_index_with_config( + tmp_path, + default_python_harness_config(), + ) + rendered = render_python_verification_profile_index(index) + + assert "[verify-profile] src/pkg/__init__.py" in rendered + assert "[verify-profile] src/pkg/alpha.py" not in rendered + assert "[verify-profile] src/pkg/beta.py" not in rendered + + +def test_profile_index_renders_configured_responsibilities_for_drift( + tmp_path: Path, +) -> None: + _write_public_branch_project(tmp_path) + config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/__init__.py", + (PythonOwnerResponsibility.CLI,), + ) + ) + + index = build_python_verification_profile_index_with_config(tmp_path, config) + rendered = render_python_verification_profile_index(index) + candidate = index.active_candidates()[0] + + assert candidate.state == "profile_drift" + assert candidate.configured_responsibilities == (PythonOwnerResponsibility.CLI,) + assert " |configured: cli" in rendered + assert " |suggest: public_api" in rendered + assert not index.is_clear() + + +def test_profile_index_omits_configured_candidates(tmp_path: Path) -> None: + _write_public_branch_project(tmp_path) + config = default_python_harness_config().with_verification_profile_hint( + PythonVerificationProfileHint( + "src/pkg/__init__.py", + (PythonOwnerResponsibility.PUBLIC_API,), + ) + ) + + index = build_python_verification_profile_index_with_config(tmp_path, config) + + assert index.is_clear() + assert index.active_candidates() == () + assert render_python_verification_profile_index(index) == "" + + +def _write_public_branch_project(project_root: Path) -> None: + package = project_root / "src" / "pkg" + package.mkdir(parents=True) + (package / "__init__.py").write_text( + '"""Package docs."""\n__all__ = ["Alpha", "Beta"]\n' + "from .alpha import Alpha\nfrom .beta import Beta\n", + encoding="utf-8", + ) + (package / "alpha.py").write_text( + '"""Alpha API."""\n\nclass Alpha:\n pass\n', + encoding="utf-8", + ) + (package / "beta.py").write_text( + '"""Beta API."""\n\nclass Beta:\n pass\n', + encoding="utf-8", + ) + (project_root / "pyproject.toml").write_text( + """ +[project] +name = "pkg" +requires-python = ">=3.12" +import-names = ["pkg"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/pkg"] +""".lstrip(), + encoding="utf-8", + ) diff --git a/tests/unit/python_lang_parser/test_core_report.py b/tests/unit/python_lang_parser/test_core_report.py index d268bd1..20eb32d 100644 --- a/tests/unit/python_lang_parser/test_core_report.py +++ b/tests/unit/python_lang_parser/test_core_report.py @@ -13,7 +13,7 @@ def test_parse_python_source_collects_symbols_imports_and_scopes() -> None: @decorator("value") -class Runner: +class Runner(abc.ABC): """Runner docs.""" async def run(self) -> None: @@ -42,13 +42,30 @@ async def run(self) -> None: symbol.qualified_name, symbol.scope, symbol.decorators, + symbol.base_classes, symbol.is_public, symbol.is_top_level, ) for symbol in report.symbols ] == [ - (PythonSymbolKind.CLASS, "Runner", "", ("decorator('value')",), True, True), - (PythonSymbolKind.ASYNC_FUNCTION, "Runner.run", "Runner", (), True, False), + ( + PythonSymbolKind.CLASS, + "Runner", + "", + ("decorator('value')",), + ("abc.ABC",), + True, + True, + ), + ( + PythonSymbolKind.ASYNC_FUNCTION, + "Runner.run", + "Runner", + (), + (), + True, + False, + ), ] assert report.shape is not None assert report.shape.responsibility_groups == ("types",) diff --git a/tests/unit/python_lang_parser/test_pyproject_metadata.py b/tests/unit/python_lang_parser/test_pyproject_metadata.py index b41d9ed..67612f8 100644 --- a/tests/unit/python_lang_parser/test_pyproject_metadata.py +++ b/tests/unit/python_lang_parser/test_pyproject_metadata.py @@ -21,6 +21,10 @@ def test_parse_python_project_metadata_collects_modern_project_facts( requires-python = ">=3.12" import-names = ["example_pkg", "_example_private ; private"] import-namespaces = ["example_namespace"] +dependencies = ["httpx>=0.28"] + +[project.optional-dependencies] +pytest = ["pytest>=8"] [project.scripts] example = "example_pkg.cli:main" @@ -28,6 +32,18 @@ def test_parse_python_project_metadata_collects_modern_project_facts( [project.entry-points.pytest11] example_pkg = "example_pkg.pytest_plugin" +[dependency-groups] +docs = [ + { package = "mkdocs>=1.6" }, +] +test = [ + "python-lang-project-harness[pytest]>=0.1.0", + { dependency = "ruff>=0.13" }, +] + +[tool.pytest.ini_options] +addopts = ["--import-mode=importlib", "--python-project-harness"] + [build-system] requires = ["hatchling"] build-backend = "hatchling.build" @@ -45,6 +61,48 @@ def test_parse_python_project_metadata_collects_modern_project_facts( assert metadata.requires_python == ">=3.12" assert metadata.build_backend == "hatchling.build" assert metadata.build_requires == ("hatchling",) + assert [item.to_dict() for item in metadata.dependencies] == [ + { + "requirement": "httpx>=0.28", + "name": "httpx", + "source": "project.dependencies", + "group": None, + "extra": None, + }, + { + "requirement": "pytest>=8", + "name": "pytest", + "source": "project.optional-dependencies", + "group": None, + "extra": "pytest", + }, + { + "requirement": "mkdocs>=1.6", + "name": "mkdocs", + "source": "dependency-groups", + "group": "docs", + "extra": None, + }, + { + "requirement": "python-lang-project-harness[pytest]>=0.1.0", + "name": "python-lang-project-harness", + "source": "dependency-groups", + "group": "test", + "extra": None, + }, + { + "requirement": "ruff>=0.13", + "name": "ruff", + "source": "dependency-groups", + "group": "test", + "extra": None, + }, + ] + assert metadata.pytest_options.addopts == ( + "--import-mode=importlib", + "--python-project-harness", + ) + assert metadata.pytest_options.enables_python_project_harness is True assert metadata.wheel_packages == ("src/example_pkg",) assert metadata.package_roots == (package,) assert [item.to_dict() for item in metadata.import_names] == [ diff --git a/tests/unit/snapshots/unit_test__policy_snapshot__py_proj_r010_pytest_gate.snap b/tests/unit/snapshots/unit_test__policy_snapshot__py_proj_r010_pytest_gate.snap new file mode 100644 index 0000000..0f1f4f7 --- /dev/null +++ b/tests/unit/snapshots/unit_test__policy_snapshot__py_proj_r010_pytest_gate.snap @@ -0,0 +1,9 @@ +--- +source: tests/unit/harness/test_policy_snapshots.py +expression: rendered +--- +[PY-PROJ-R010] Warning: Harness dev dependency should mount a pytest gate + ,-[ pyproject.toml:1:1 ] + 1 | [project] + | `- mount the parser-backed harness in pytest + |Required: Enable `--python-project-harness` in pytest addopts or expose `python_project_harness_test()` so the dev dependency actually gates project policy. diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py index a92bf9f..0102d7b 100644 --- a/tests/unit/test_public_api.py +++ b/tests/unit/test_public_api.py @@ -10,10 +10,12 @@ def test_root_package_reexports_parser_fact_models() -> None: assert harness_api.PythonExportContract is parser_api.PythonExportContract assert harness_api.PythonExportContractKind is parser_api.PythonExportContractKind assert harness_api.PythonModuleShape is parser_api.PythonModuleShape + assert harness_api.PythonProjectDependency is parser_api.PythonProjectDependency assert harness_api.PythonProjectEntryPoint is parser_api.PythonProjectEntryPoint assert harness_api.PythonProjectImportName is parser_api.PythonProjectImportName assert harness_api.PythonProjectMetadata is parser_api.PythonProjectMetadata assert harness_api.PythonProjectScript is parser_api.PythonProjectScript + assert harness_api.PythonPytestOptions is parser_api.PythonPytestOptions assert harness_api.PythonReasoningTreeFacts is parser_api.PythonReasoningTreeFacts assert ( harness_api.PythonReasoningTreeImportEdge @@ -82,10 +84,14 @@ def test_root_package_reexports_parser_fact_models() -> None: assert "python_name_is_public" in harness_api.__all__ assert "PythonReasoningTreeFacts" in harness_api.__all__ assert "PythonProjectMetadata" in harness_api.__all__ + assert "PythonProjectDependency" in harness_api.__all__ + assert "PythonPytestOptions" in harness_api.__all__ assert "parse_python_project_metadata" in harness_api.__all__ assert "PythonReasoningTreeImportEdge" in harness_api.__all__ assert "PythonReasoningTreeNode" in harness_api.__all__ assert "PythonProjectMetadata" in parser_api.__all__ + assert "PythonProjectDependency" in parser_api.__all__ + assert "PythonPytestOptions" in parser_api.__all__ assert "PythonReasoningTreeImportEdge" in parser_api.__all__ assert "parse_python_project_metadata" in parser_api.__all__ assert "python_reasoning_tree_facts" in harness_api.__all__ @@ -105,6 +111,17 @@ def test_root_package_reexports_parser_fact_models() -> None: def test_root_package_reexports_embedding_harness_surface() -> None: assert harness_api.PythonHarnessConfig is harness_facade.PythonHarnessConfig assert harness_api.PythonHarnessReport is harness_facade.PythonHarnessReport + assert ( + harness_api.PythonVerificationPolicy is harness_facade.PythonVerificationPolicy + ) + assert ( + harness_api.PythonVerificationProfileHint + is harness_facade.PythonVerificationProfileHint + ) + assert ( + harness_api.PythonVerificationTaskKind + is harness_facade.PythonVerificationTaskKind + ) assert ( harness_api.PythonProjectPolicyRulePack is harness_facade.PythonProjectPolicyRulePack @@ -137,6 +154,14 @@ def test_root_package_reexports_embedding_harness_surface() -> None: harness_api.render_python_reasoning_tree is harness_facade.render_python_reasoning_tree ) + assert ( + harness_api.render_python_project_harness_agent_snapshot + is harness_facade.render_python_project_harness_agent_snapshot + ) + assert ( + harness_api.render_python_project_harness_agent_snapshot_with_config + is harness_facade.render_python_project_harness_agent_snapshot_with_config + ) assert ( harness_api.read_python_project_harness_config is harness_facade.read_python_project_harness_config @@ -148,9 +173,27 @@ def test_root_package_reexports_embedding_harness_surface() -> None: assert harness_api.python_syntax_rules is harness_facade.python_syntax_rules assert harness_api.run_cli is harness_facade.run_cli assert harness_api.run_cli_from_env is harness_facade.run_cli_from_env + assert ( + harness_api.plan_python_project_verification + is harness_facade.plan_python_project_verification + ) + assert ( + harness_api.render_python_verification_plan + is harness_facade.render_python_verification_plan + ) assert "render_python_lang_harness_advice" in harness_api.__all__ assert "render_python_lang_harness_json" in harness_api.__all__ + assert "render_python_project_harness_agent_snapshot" in harness_api.__all__ + assert ( + "render_python_project_harness_agent_snapshot_with_config" + in harness_api.__all__ + ) assert "render_python_reasoning_tree" in harness_api.__all__ assert "read_python_project_harness_config" in harness_api.__all__ assert "run_cli_from_env" in harness_api.__all__ assert "python_syntax_rules" in harness_api.__all__ + assert "PythonVerificationPolicy" in harness_api.__all__ + assert "PythonVerificationProfileHint" in harness_api.__all__ + assert "PythonVerificationTaskKind" in harness_api.__all__ + assert "plan_python_project_verification" in harness_api.__all__ + assert "render_python_verification_plan" in harness_api.__all__