patchground 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patchground/__init__.py +10 -0
- patchground/__main__.py +3 -0
- patchground/authoring/__init__.py +5 -0
- patchground/authoring/create.py +179 -0
- patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
- patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
- patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
- patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
- patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
- patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
- patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
- patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
- patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
- patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
- patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
- patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
- patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
- patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
- patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
- patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
- patchground/automation/__init__.py +5 -0
- patchground/automation/__main__.py +131 -0
- patchground/automation/workflow.py +214 -0
- patchground/catalog/README.md +10 -0
- patchground/catalog/cache-expiry/TICKET.md +49 -0
- patchground/catalog/cache-expiry/challenge.yaml +32 -0
- patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
- patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
- patchground/catalog/cache-expiry/debrief/questions.md +4 -0
- patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
- patchground/catalog/cache-expiry/solution/reference.patch +41 -0
- patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
- patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
- patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
- patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
- patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
- patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
- patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
- patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
- patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
- patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
- patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
- patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
- patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
- patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
- patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
- patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
- patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
- patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
- patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
- patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
- patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
- patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
- patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
- patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
- patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
- patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
- patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
- patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
- patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
- patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
- patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
- patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
- patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
- patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
- patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
- patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
- patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
- patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
- patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
- patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
- patchground/catalog/pagination-last-page/TICKET.md +44 -0
- patchground/catalog/pagination-last-page/challenge.yaml +31 -0
- patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
- patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
- patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
- patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
- patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
- patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
- patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
- patchground/catalog/release-version-ordering/TICKET.md +72 -0
- patchground/catalog/release-version-ordering/challenge.yaml +31 -0
- patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
- patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
- patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
- patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
- patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
- patchground/catalog/release-version-ordering/environment/package.json +13 -0
- patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
- patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
- patchground/catalog/release-version-ordering/workspace/README.md +7 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
- patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
- patchground/cli/__init__.py +1 -0
- patchground/cli/app.py +59 -0
- patchground/cli/authoring.py +41 -0
- patchground/cli/consent.py +77 -0
- patchground/cli/discovery.py +189 -0
- patchground/cli/doctor.py +249 -0
- patchground/cli/packs.py +142 -0
- patchground/cli/practice.py +141 -0
- patchground/cli/render.py +58 -0
- patchground/cli/reports.py +53 -0
- patchground/cli/sessions.py +195 -0
- patchground/cli/validate.py +117 -0
- patchground/packs/__init__.py +23 -0
- patchground/packs/builtin.py +53 -0
- patchground/packs/discovery.py +285 -0
- patchground/packs/execution.py +420 -0
- patchground/packs/models.py +98 -0
- patchground/packs/registry.py +318 -0
- patchground/packs/source.py +230 -0
- patchground/packs/store.py +420 -0
- patchground/py.typed +0 -0
- patchground/report/__init__.py +6 -0
- patchground/report/analysis.py +188 -0
- patchground/report/models.py +59 -0
- patchground/report/regression.py +597 -0
- patchground/report/render.py +92 -0
- patchground/report/workflow.py +84 -0
- patchground/runner_assets/Dockerfile +7 -0
- patchground/runner_assets/__init__.py +16 -0
- patchground/runner_assets/package-lock.json +1054 -0
- patchground/runner_assets/package.json +13 -0
- patchground/runner_assets/vitest-runner.mjs +124 -0
- patchground/safeio/__init__.py +115 -0
- patchground/sandbox/__init__.py +39 -0
- patchground/sandbox/_engine.py +23 -0
- patchground/sandbox/build.py +202 -0
- patchground/sandbox/errors.py +23 -0
- patchground/sandbox/junit.py +76 -0
- patchground/sandbox/probe.py +118 -0
- patchground/sandbox/run.py +535 -0
- patchground/sessions/__init__.py +44 -0
- patchground/sessions/diagnostics.py +145 -0
- patchground/sessions/files.py +113 -0
- patchground/sessions/git.py +70 -0
- patchground/sessions/history.py +386 -0
- patchground/sessions/history_ignore.py +279 -0
- patchground/sessions/models.py +126 -0
- patchground/sessions/practice.py +202 -0
- patchground/sessions/reset.py +121 -0
- patchground/sessions/store.py +203 -0
- patchground/sessions/submission.py +344 -0
- patchground/sessions/workflow.py +297 -0
- patchground/spec/__init__.py +35 -0
- patchground/spec/_schema.py +53 -0
- patchground/spec/loader.py +713 -0
- patchground/spec/models.py +394 -0
- patchground/spec/schema.py +50 -0
- patchground/verify/__init__.py +29 -0
- patchground/verify/errors.py +10 -0
- patchground/verify/pytest_adapter.py +95 -0
- patchground/verify/record.py +94 -0
- patchground/verify/reference.py +158 -0
- patchground/verify/result.py +199 -0
- patchground/verify/runners.py +47 -0
- patchground/verify/tampering.py +106 -0
- patchground/verify/validate.py +686 -0
- patchground/verify/vitest_adapter.py +252 -0
- patchground-0.1.0.dist-info/METADATA +258 -0
- patchground-0.1.0.dist-info/RECORD +194 -0
- patchground-0.1.0.dist-info/WHEEL +4 -0
- patchground-0.1.0.dist-info/entry_points.txt +3 -0
- patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,597 @@
|
|
|
1
|
+
"""Regression-test evidence: do the learner's new or changed tests fail before and pass after?
|
|
2
|
+
|
|
3
|
+
The probe supports direct ``pytest`` commands and the canonical frozen Vitest runner, using
|
|
4
|
+
static conventional test definitions. Candidates are exactly the test definitions
|
|
5
|
+
that are new or changed between the captured original and final trees. They are selected alone
|
|
6
|
+
twice inside the sandbox with identical test material and a fixed runner configuration: once
|
|
7
|
+
against the original code and once against the final code. Anything the probe cannot reason about
|
|
8
|
+
is reported as ``not_established`` rather than guessed, and nothing here ever runs on the host.
|
|
9
|
+
|
|
10
|
+
Evidence carries test identities and statuses only, never raw runner output.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import filecmp
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import stat
|
|
22
|
+
import tempfile
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
from typing import Literal, Protocol
|
|
28
|
+
|
|
29
|
+
from pydantic import BaseModel, ConfigDict
|
|
30
|
+
|
|
31
|
+
from patchground import sandbox as engine
|
|
32
|
+
from patchground.runner_assets import VITEST_COMMAND
|
|
33
|
+
from patchground.sandbox import RunOutcome, RunResult, TestResult, TestStatus
|
|
34
|
+
from patchground.sessions.models import CheckTest
|
|
35
|
+
from patchground.verify import vitest_adapter
|
|
36
|
+
from patchground.verify.pytest_adapter import (
|
|
37
|
+
TestNode,
|
|
38
|
+
UnsupportedPytest,
|
|
39
|
+
is_test_filename,
|
|
40
|
+
split_pytest_command,
|
|
41
|
+
test_definitions,
|
|
42
|
+
)
|
|
43
|
+
from patchground.verify.runners import (
|
|
44
|
+
VITEST_EXCLUDED_PARTS,
|
|
45
|
+
is_vitest_path,
|
|
46
|
+
runner_for,
|
|
47
|
+
vitest_identity,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
Status = Literal["verified", "not_verified", "not_established"]
|
|
51
|
+
|
|
52
|
+
MAX_CANDIDATES = 200
|
|
53
|
+
MAX_TEXT_BYTES = 1024 * 1024 # runner configuration files
|
|
54
|
+
JUNIT_TARGET = "--junitxml=$PG_RESULTS/junit.xml"
|
|
55
|
+
JUNIT_VALUES = ("$PG_RESULTS/junit.xml", "${PG_RESULTS}/junit.xml")
|
|
56
|
+
SKIPPED_PARTS = frozenset({".git", "__pycache__", ".pytest_cache"})
|
|
57
|
+
RUNNER_CONFIG = frozenset(
|
|
58
|
+
{"pytest.ini", ".pytest.ini", "pytest.toml", ".pytest.toml", "tox.ini", "setup.cfg"}
|
|
59
|
+
| {"pyproject.toml", "conftest.py"}
|
|
60
|
+
)
|
|
61
|
+
HARNESS_PARTS = frozenset({"tests", "test", "testing"})
|
|
62
|
+
SAFE_PATH = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*")
|
|
63
|
+
# Settings and hooks that change what pytest collects, selects or skips.
|
|
64
|
+
CONFIG_CONTROLS = re.compile(
|
|
65
|
+
r"^\s*(python_files|python_classes|python_functions|norecursedirs|addopts|"
|
|
66
|
+
r"confcutdir|consider_namespace_packages)\b",
|
|
67
|
+
re.MULTILINE,
|
|
68
|
+
)
|
|
69
|
+
CONFTEST_CONTROLS = re.compile(
|
|
70
|
+
r"\b(collect_ignore(_glob)?|pytest_(ignore_collect|collect_file|collection\w*|"
|
|
71
|
+
r"pycollect\w*|generate_tests|configure|runtest\w*|deselected))\b"
|
|
72
|
+
)
|
|
73
|
+
COLLECTION_OPTIONS = frozenset(
|
|
74
|
+
{"-k", "-m", "-x", "-c", "--deselect", "--ignore", "--ignore-glob", "--lf", "--ff", "--nf"}
|
|
75
|
+
| {"--sw", "--co", "--collect-only", "--maxfail", "--exitfirst", "--pyargs", "--rootdir"}
|
|
76
|
+
| {"--confcutdir", "--import-mode", "--last-failed", "--failed-first", "--new-first"}
|
|
77
|
+
| {"--stepwise", "--override-ini", "-o", "--junitxml", "--junit-xml"}
|
|
78
|
+
)
|
|
79
|
+
SIMPLE_OPTIONS = re.compile(
|
|
80
|
+
r"-[qvsl]+|-r[A-Za-z]+|--(quiet|verbose|no-header|no-summary|disable-warnings|"
|
|
81
|
+
r"strict-markers|strict-config|showlocals)|--color=(yes|no|auto)|"
|
|
82
|
+
r"--tb=(auto|long|short|line|native|no)|--durations=\d+"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RegressionSandbox(Protocol):
|
|
87
|
+
"""The one sandbox operation regression evidence needs (injectable for tests)."""
|
|
88
|
+
|
|
89
|
+
def run_check(self, image_id: str, layers: Sequence[Path], command: str) -> RunResult: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class RegressionEvidence(BaseModel):
|
|
93
|
+
"""Factual regression-test outcome; never a score or a submission gate."""
|
|
94
|
+
|
|
95
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
96
|
+
|
|
97
|
+
status: Status
|
|
98
|
+
reason: str
|
|
99
|
+
candidates: tuple[str, ...] = ()
|
|
100
|
+
verified_tests: tuple[str, ...] = ()
|
|
101
|
+
before_tests: tuple[CheckTest, ...] = ()
|
|
102
|
+
after_tests: tuple[CheckTest, ...] = ()
|
|
103
|
+
before_outcome: str | None = None
|
|
104
|
+
after_outcome: str | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _Unsupported(Exception):
|
|
108
|
+
"""The probe cannot establish evidence; the message becomes the reason."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class _Candidate:
|
|
113
|
+
module: str # relative POSIX path of the test module
|
|
114
|
+
qualname: str # Python qualified name or exact Vitest literal name
|
|
115
|
+
exact_identity: bool = False
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def id(self) -> str:
|
|
119
|
+
"""The runner-specific JUnit identity, preserving literal Vitest names."""
|
|
120
|
+
if self.exact_identity:
|
|
121
|
+
return vitest_identity(self.module, self.qualname)
|
|
122
|
+
*classes, name = self.qualname.split("::")
|
|
123
|
+
dotted = ".".join([self.module.removesuffix(".py").replace("/", "."), *classes])
|
|
124
|
+
return f"{dotted}::{name}"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def node_id(self) -> str:
|
|
128
|
+
return f"{self.module}::{self.qualname}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def evaluate_regression(
|
|
132
|
+
original: Path,
|
|
133
|
+
final: Path,
|
|
134
|
+
image_id: str,
|
|
135
|
+
command: str,
|
|
136
|
+
*,
|
|
137
|
+
sandbox: RegressionSandbox = engine,
|
|
138
|
+
) -> RegressionEvidence:
|
|
139
|
+
"""Probe whether the final tests fail on the original code and pass on the final code.
|
|
140
|
+
|
|
141
|
+
``original`` and ``final`` are captured private trees, never the live workspace. Unsupported
|
|
142
|
+
inputs return ``not_established``. Engine and storage faults raise
|
|
143
|
+
:class:`patchground.sandbox.SandboxError` (or ``OSError``) so the caller can retry.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
plan = _plan(Path(original), Path(final), command)
|
|
147
|
+
except _Unsupported as exc:
|
|
148
|
+
return RegressionEvidence(status="not_established", reason=str(exc))
|
|
149
|
+
if not plan.candidates:
|
|
150
|
+
return RegressionEvidence(
|
|
151
|
+
status="not_verified", reason="no new or changed test definitions were found"
|
|
152
|
+
)
|
|
153
|
+
ids = tuple(c.id for c in plan.candidates)
|
|
154
|
+
with tempfile.TemporaryDirectory(prefix="patchground-regression-") as scratch:
|
|
155
|
+
before_tree, after_tree = Path(scratch, "original"), Path(scratch, "final")
|
|
156
|
+
_copy(plan.original_files, before_tree)
|
|
157
|
+
_copy(plan.final_files, after_tree)
|
|
158
|
+
for module in plan.test_modules:
|
|
159
|
+
if plan.exact_identities and module not in plan.final_files:
|
|
160
|
+
(before_tree / module).unlink()
|
|
161
|
+
continue
|
|
162
|
+
material = plan.final_files[module]
|
|
163
|
+
_copy_file(material, before_tree / module)
|
|
164
|
+
if plan.exact_identities:
|
|
165
|
+
_copy_file(material, after_tree / module)
|
|
166
|
+
names = {c.qualname for c in plan.candidates if c.module == module}
|
|
167
|
+
for tree in (before_tree, after_tree):
|
|
168
|
+
vitest_adapter.select_tests(tree / module, names)
|
|
169
|
+
before = sandbox.run_check(image_id, [before_tree], plan.command)
|
|
170
|
+
before_tests = _sanitized(before.tests)
|
|
171
|
+
stop = _judge_before(before, ids, exact=plan.exact_identities)
|
|
172
|
+
if stop is not None:
|
|
173
|
+
return RegressionEvidence(
|
|
174
|
+
status=stop[0],
|
|
175
|
+
reason=stop[1],
|
|
176
|
+
candidates=ids,
|
|
177
|
+
before_tests=before_tests,
|
|
178
|
+
before_outcome=before.outcome.value,
|
|
179
|
+
)
|
|
180
|
+
after = sandbox.run_check(image_id, [after_tree], plan.command)
|
|
181
|
+
after_tests = _sanitized(after.tests)
|
|
182
|
+
outcomes = {"before_outcome": before.outcome.value, "after_outcome": after.outcome.value}
|
|
183
|
+
stop = _judge_after(after, ids, exact=plan.exact_identities)
|
|
184
|
+
if stop is None:
|
|
185
|
+
stop = _same_identities(before, after)
|
|
186
|
+
verified = _verified(before, after)
|
|
187
|
+
if stop is None and not verified:
|
|
188
|
+
stop = ("not_verified", "no selected test failed on the original and passed on the final")
|
|
189
|
+
status: Status = stop[0] if stop else "verified"
|
|
190
|
+
return RegressionEvidence(
|
|
191
|
+
status=status,
|
|
192
|
+
reason=stop[1]
|
|
193
|
+
if stop
|
|
194
|
+
else "selected tests fail on the original code and pass on the final",
|
|
195
|
+
candidates=ids,
|
|
196
|
+
verified_tests=() if stop else verified,
|
|
197
|
+
before_tests=before_tests,
|
|
198
|
+
after_tests=after_tests,
|
|
199
|
+
**outcomes,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# -------------------------------------------------------------------------------------- planning
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class _Plan:
|
|
208
|
+
candidates: tuple[_Candidate, ...]
|
|
209
|
+
test_modules: tuple[str, ...]
|
|
210
|
+
original_files: dict[str, Path]
|
|
211
|
+
final_files: dict[str, Path]
|
|
212
|
+
command: str
|
|
213
|
+
exact_identities: bool = False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _plan(original: Path, final: Path, command: str) -> _Plan:
|
|
217
|
+
split = split_pytest_command(command)
|
|
218
|
+
if split is None:
|
|
219
|
+
if runner_for(command) == "vitest":
|
|
220
|
+
return _vitest_plan(original, final, command)
|
|
221
|
+
raise _Unsupported("the test command is not a supported frozen runner invocation")
|
|
222
|
+
invocation, arguments = split
|
|
223
|
+
options, scope = _runner_options(arguments)
|
|
224
|
+
original_files, final_files = _files(original), _files(final)
|
|
225
|
+
changed = {
|
|
226
|
+
rel
|
|
227
|
+
for rel in original_files.keys() | final_files.keys()
|
|
228
|
+
if rel not in original_files
|
|
229
|
+
or rel not in final_files
|
|
230
|
+
or not filecmp.cmp(original_files[rel], final_files[rel], shallow=False)
|
|
231
|
+
}
|
|
232
|
+
modules, candidates = _candidates(original_files, final_files, changed)
|
|
233
|
+
if candidates:
|
|
234
|
+
_reject_unreliable(original_files, final_files, changed, candidates)
|
|
235
|
+
if len(candidates) > MAX_CANDIDATES:
|
|
236
|
+
raise _Unsupported("too many new or changed tests to select safely")
|
|
237
|
+
_check_scope(candidates, scope)
|
|
238
|
+
command = _selected_command(invocation, options, candidates)
|
|
239
|
+
return _Plan(tuple(candidates), tuple(modules), original_files, final_files, command)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _vitest_plan(original: Path, final: Path, command: str) -> _Plan:
|
|
243
|
+
original_files = _files(original, excluded=VITEST_EXCLUDED_PARTS)
|
|
244
|
+
final_files = _files(final, excluded=VITEST_EXCLUDED_PARTS)
|
|
245
|
+
changed = {
|
|
246
|
+
rel
|
|
247
|
+
for rel in original_files.keys() | final_files.keys()
|
|
248
|
+
if rel not in original_files
|
|
249
|
+
or rel not in final_files
|
|
250
|
+
or not filecmp.cmp(original_files[rel], final_files[rel], shallow=False)
|
|
251
|
+
}
|
|
252
|
+
parsed: list[dict[str, vitest_adapter.TestModule]] = []
|
|
253
|
+
try:
|
|
254
|
+
for files in (original_files, final_files):
|
|
255
|
+
parsed.append(
|
|
256
|
+
{
|
|
257
|
+
rel: vitest_adapter.parse_module(path)
|
|
258
|
+
for rel, path in files.items()
|
|
259
|
+
if is_vitest_path(PurePosixPath(rel))
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
except vitest_adapter.UnsupportedVitest as exc:
|
|
263
|
+
raise _Unsupported(f"a TypeScript test module cannot be analysed: {exc}") from exc
|
|
264
|
+
before, after = parsed
|
|
265
|
+
candidates: list[_Candidate] = []
|
|
266
|
+
for rel, module in sorted(after.items()):
|
|
267
|
+
previous = before.get(rel)
|
|
268
|
+
for name, definition in module.definitions.items():
|
|
269
|
+
old = previous.definitions.get(name) if previous is not None else None
|
|
270
|
+
if old is not None and old.fingerprint == definition.fingerprint:
|
|
271
|
+
continue
|
|
272
|
+
candidate = _Candidate(rel, name, exact_identity=True)
|
|
273
|
+
try:
|
|
274
|
+
vitest_identity(rel, name)
|
|
275
|
+
except ValueError as exc:
|
|
276
|
+
raise _Unsupported("a TypeScript test identity cannot be selected safely") from exc
|
|
277
|
+
candidates.append(candidate)
|
|
278
|
+
if len(candidates) > MAX_CANDIDATES:
|
|
279
|
+
raise _Unsupported("too many new or changed tests to select safely")
|
|
280
|
+
if candidates:
|
|
281
|
+
for rel in before.keys() & after.keys():
|
|
282
|
+
if before[rel].helper_fingerprint != after[rel].helper_fingerprint:
|
|
283
|
+
raise _Unsupported("TypeScript test imports or helpers changed")
|
|
284
|
+
homes = {rel.rpartition("/")[0] for rel in before.keys() | after.keys()}
|
|
285
|
+
for rel in changed:
|
|
286
|
+
name = rel.rsplit("/", 1)[-1]
|
|
287
|
+
if _vitest_harness_file(name):
|
|
288
|
+
raise _Unsupported("TypeScript runner, dependency or configuration files changed")
|
|
289
|
+
if (rel in before and before[rel].definitions) or (
|
|
290
|
+
rel in after and after[rel].definitions
|
|
291
|
+
):
|
|
292
|
+
continue
|
|
293
|
+
directory = rel.rpartition("/")[0]
|
|
294
|
+
near = any(
|
|
295
|
+
directory == home or (home and directory.startswith(home + "/")) for home in homes
|
|
296
|
+
)
|
|
297
|
+
if near or HARNESS_PARTS & set(directory.split("/")):
|
|
298
|
+
raise _Unsupported("helper or harness files near the tests changed")
|
|
299
|
+
if candidates:
|
|
300
|
+
selected_modules = sorted({c.module for c in candidates})
|
|
301
|
+
command = f"{VITEST_COMMAND} --files {shlex.join(selected_modules)}"
|
|
302
|
+
return _Plan(
|
|
303
|
+
tuple(candidates),
|
|
304
|
+
tuple(sorted(before.keys() | after.keys())),
|
|
305
|
+
original_files,
|
|
306
|
+
final_files,
|
|
307
|
+
command,
|
|
308
|
+
exact_identities=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _vitest_harness_file(name: str) -> bool:
|
|
313
|
+
return name in {
|
|
314
|
+
"package.json",
|
|
315
|
+
"package-lock.json",
|
|
316
|
+
"npm-shrinkwrap.json",
|
|
317
|
+
"pnpm-lock.yaml",
|
|
318
|
+
"yarn.lock",
|
|
319
|
+
"bun.lock",
|
|
320
|
+
"bun.lockb",
|
|
321
|
+
"pnpm-workspace.yaml",
|
|
322
|
+
".npmrc",
|
|
323
|
+
".yarnrc",
|
|
324
|
+
".yarnrc.yml",
|
|
325
|
+
".node-version",
|
|
326
|
+
".nvmrc",
|
|
327
|
+
"Dockerfile",
|
|
328
|
+
"vitest-runner.mjs",
|
|
329
|
+
} or name.startswith(("vitest.config.", "vite.config.", "tsconfig.", "Dockerfile."))
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _runner_options(arguments: list[str]) -> tuple[list[str], list[str]]:
|
|
333
|
+
"""Split the frozen pytest arguments into preserved options and positional test paths."""
|
|
334
|
+
options: list[str] = []
|
|
335
|
+
scope: list[str] = []
|
|
336
|
+
junit = False
|
|
337
|
+
index = 0
|
|
338
|
+
while index < len(arguments):
|
|
339
|
+
word = arguments[index]
|
|
340
|
+
index += 1
|
|
341
|
+
name = word.split("=", 1)[0]
|
|
342
|
+
if name in ("--junitxml", "--junit-xml"):
|
|
343
|
+
value = word.partition("=")[2]
|
|
344
|
+
if "=" not in word and index < len(arguments):
|
|
345
|
+
value, index = arguments[index], index + 1
|
|
346
|
+
if value not in JUNIT_VALUES:
|
|
347
|
+
raise _Unsupported("the JUnit target must be $PG_RESULTS/junit.xml")
|
|
348
|
+
junit = True
|
|
349
|
+
elif word == "-p" or word.startswith("-p"):
|
|
350
|
+
plugin = arguments[index] if word == "-p" and index < len(arguments) else word[2:]
|
|
351
|
+
index += word == "-p"
|
|
352
|
+
if plugin != "no:cacheprovider":
|
|
353
|
+
raise _Unsupported("only -p no:cacheprovider is supported")
|
|
354
|
+
options += ["-p", plugin]
|
|
355
|
+
elif word.startswith("-"):
|
|
356
|
+
if name in COLLECTION_OPTIONS or not SIMPLE_OPTIONS.fullmatch(word):
|
|
357
|
+
kind = "collection control" if name in COLLECTION_OPTIONS else "runner option"
|
|
358
|
+
raise _Unsupported(f"{kind} {_shown(name)} is not supported")
|
|
359
|
+
options.append(word)
|
|
360
|
+
elif "::" in word:
|
|
361
|
+
raise _Unsupported("test selection by node id in the test command is not supported")
|
|
362
|
+
else:
|
|
363
|
+
scope.append(_normalized(word))
|
|
364
|
+
if not junit:
|
|
365
|
+
raise _Unsupported("the test command must write --junitxml=$PG_RESULTS/junit.xml")
|
|
366
|
+
return options, scope
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _shown(text: str) -> str:
|
|
370
|
+
return repr("".join(c for c in text if c.isprintable())[:40])
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _normalized(path: str) -> str:
|
|
374
|
+
parts = [part for part in path.split("/") if part not in ("", ".")]
|
|
375
|
+
if path.startswith("/") or ".." in parts:
|
|
376
|
+
raise _Unsupported("test paths outside the workspace are not supported")
|
|
377
|
+
return "/".join(parts)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _files(root: Path, *, excluded: frozenset[str] = frozenset()) -> dict[str, Path]:
|
|
381
|
+
"""Map relative POSIX path -> ordinary file; anything unusual makes the tree unsupported."""
|
|
382
|
+
if not root.is_dir() or root.is_symlink():
|
|
383
|
+
raise _Unsupported("a captured tree is missing or not a directory")
|
|
384
|
+
files: dict[str, Path] = {}
|
|
385
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
386
|
+
base = Path(dirpath)
|
|
387
|
+
kept = []
|
|
388
|
+
for name in sorted(dirnames):
|
|
389
|
+
if name in SKIPPED_PARTS or name in excluded:
|
|
390
|
+
continue
|
|
391
|
+
if (base / name).is_symlink():
|
|
392
|
+
raise _Unsupported("a captured tree contains a symbolic link")
|
|
393
|
+
kept.append(name)
|
|
394
|
+
dirnames[:] = kept
|
|
395
|
+
for name in filenames:
|
|
396
|
+
path = base / name
|
|
397
|
+
mode = path.lstat().st_mode
|
|
398
|
+
if name.endswith(".pyc"):
|
|
399
|
+
continue
|
|
400
|
+
if not stat.S_ISREG(mode):
|
|
401
|
+
raise _Unsupported("a captured tree contains a link or special file")
|
|
402
|
+
files[path.relative_to(root).as_posix()] = path
|
|
403
|
+
return files
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _definitions(path: Path) -> dict[str, TestNode]:
|
|
407
|
+
try:
|
|
408
|
+
return test_definitions(path)
|
|
409
|
+
except UnsupportedPytest as exc:
|
|
410
|
+
raise _Unsupported(f"a test module cannot be analysed: {exc}") from exc
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _candidates(
|
|
414
|
+
original: dict[str, Path], final: dict[str, Path], changed: set[str]
|
|
415
|
+
) -> tuple[list[str], list[_Candidate]]:
|
|
416
|
+
"""Changed static test modules and the test definitions that are new or changed in them."""
|
|
417
|
+
modules: list[str] = []
|
|
418
|
+
candidates: list[_Candidate] = []
|
|
419
|
+
for rel in sorted(changed & final.keys()):
|
|
420
|
+
if not rel.endswith(".py") or not is_test_filename(rel.rsplit("/", 1)[-1]):
|
|
421
|
+
continue
|
|
422
|
+
after = _definitions(final[rel])
|
|
423
|
+
if not after:
|
|
424
|
+
continue # helper-only module with a test-like name; handled as harness
|
|
425
|
+
modules.append(rel)
|
|
426
|
+
before = _definitions(original[rel]) if rel in original else {}
|
|
427
|
+
for qualname, node in after.items():
|
|
428
|
+
old = before.get(qualname)
|
|
429
|
+
if old is None or ast.dump(old) != ast.dump(node):
|
|
430
|
+
if not SAFE_PATH.fullmatch(rel) or any(p.startswith("-") for p in rel.split("/")):
|
|
431
|
+
raise _Unsupported("a test path cannot be selected safely")
|
|
432
|
+
candidates.append(_Candidate(rel, qualname))
|
|
433
|
+
return modules, candidates
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _reject_unreliable(
|
|
437
|
+
original: dict[str, Path],
|
|
438
|
+
final: dict[str, Path],
|
|
439
|
+
changed: set[str],
|
|
440
|
+
candidates: Sequence[_Candidate],
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Refuse when runner configuration, collection controls or test harness files differ."""
|
|
443
|
+
for rel in sorted(changed):
|
|
444
|
+
if rel.rsplit("/", 1)[-1] in RUNNER_CONFIG:
|
|
445
|
+
raise _Unsupported("pytest configuration, pyproject or conftest files changed")
|
|
446
|
+
for files in (original, final):
|
|
447
|
+
for rel, path in files.items():
|
|
448
|
+
name = rel.rsplit("/", 1)[-1]
|
|
449
|
+
if name not in RUNNER_CONFIG:
|
|
450
|
+
continue
|
|
451
|
+
if path.stat().st_size > MAX_TEXT_BYTES:
|
|
452
|
+
raise _Unsupported("a runner configuration file is too large to analyse")
|
|
453
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
454
|
+
pattern = CONFTEST_CONTROLS if name == "conftest.py" else CONFIG_CONTROLS
|
|
455
|
+
if pattern.search(text):
|
|
456
|
+
raise _Unsupported("collection controls in pytest configuration or conftest")
|
|
457
|
+
homes = {c.module.rpartition("/")[0] for c in candidates}
|
|
458
|
+
for rel in sorted(changed):
|
|
459
|
+
if _is_test_file(final, original, rel):
|
|
460
|
+
continue
|
|
461
|
+
directory = rel.rpartition("/")[0]
|
|
462
|
+
near = any(
|
|
463
|
+
directory == home or (home != "" and directory.startswith(home + "/")) for home in homes
|
|
464
|
+
)
|
|
465
|
+
if near or HARNESS_PARTS & set(directory.split("/")):
|
|
466
|
+
raise _Unsupported("helper or harness files near the tests changed")
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _is_test_file(final: dict[str, Path], original: dict[str, Path], rel: str) -> bool:
|
|
470
|
+
"""A changed conventional module with static tests (compared test material, not harness)."""
|
|
471
|
+
path = final.get(rel)
|
|
472
|
+
if path is None:
|
|
473
|
+
return rel in original and rel.endswith(".py") and _static_test_module(original[rel])
|
|
474
|
+
return rel.endswith(".py") and _static_test_module(path)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _static_test_module(path: Path) -> bool:
|
|
478
|
+
name = path.name
|
|
479
|
+
return is_test_filename(name) and bool(_definitions(path))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _check_scope(candidates: Sequence[_Candidate], scope: Sequence[str]) -> None:
|
|
483
|
+
for candidate in candidates:
|
|
484
|
+
if scope and not any(
|
|
485
|
+
part == "" or candidate.module == part or candidate.module.startswith(part + "/")
|
|
486
|
+
for part in scope
|
|
487
|
+
):
|
|
488
|
+
raise _Unsupported("a selected test is outside the paths in the test command")
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _selected_command(
|
|
492
|
+
invocation: list[str], options: list[str], candidates: Sequence[_Candidate]
|
|
493
|
+
) -> str:
|
|
494
|
+
fixed = shlex.join([*invocation, *options, "--rootdir=."])
|
|
495
|
+
return f"{fixed} {JUNIT_TARGET} {shlex.join(c.node_id for c in candidates)}"
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------------------------- copying
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _copy_file(source: Path, target: Path) -> None:
|
|
502
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
target.unlink(missing_ok=True)
|
|
504
|
+
shutil.copy2(source, target)
|
|
505
|
+
# Captures are read-only; only these private disposable copies may be mutated.
|
|
506
|
+
target.chmod(stat.S_IMODE(target.stat().st_mode) | stat.S_IWUSR)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _copy(files: dict[str, Path], target: Path) -> None:
|
|
510
|
+
target.mkdir()
|
|
511
|
+
for rel, path in files.items():
|
|
512
|
+
_copy_file(path, target / rel)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ------------------------------------------------------------------------------------- judging
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _sanitized(tests: Sequence[TestResult]) -> tuple[CheckTest, ...]:
|
|
519
|
+
return tuple(
|
|
520
|
+
CheckTest(id="".join(c for c in t.id if c.isprintable())[:240], status=t.status)
|
|
521
|
+
for t in tests
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
Verdict = tuple[Status, str] | None
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _base(test: TestResult) -> str:
|
|
529
|
+
return f"{test.classname}::{test.name.split('[', 1)[0]}"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _inconclusive(label: str, run: RunResult) -> Verdict:
|
|
533
|
+
if run.outcome in (RunOutcome.MISSING_RESULTS, RunOutcome.LIMIT_EXCEEDED):
|
|
534
|
+
return "not_established", f"the {label} run was inconclusive ({run.outcome.value})"
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _collected(label: str, run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
|
|
539
|
+
if any(len(test.id) > 240 or not test.id.isprintable() for test in run.tests):
|
|
540
|
+
return "not_established", "test identities cannot be reported safely without alteration"
|
|
541
|
+
if exact and Counter(t.id for t in run.tests) != Counter(ids):
|
|
542
|
+
return "not_verified", f"selected tests were not collected exactly on the {label} code"
|
|
543
|
+
seen = {t.id if exact else _base(t) for t in run.tests}
|
|
544
|
+
if not set(ids) <= seen or not seen <= set(ids):
|
|
545
|
+
return (
|
|
546
|
+
"not_verified",
|
|
547
|
+
f"selected tests were not collected exactly on the {label} code",
|
|
548
|
+
)
|
|
549
|
+
return None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _judge_before(run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
|
|
553
|
+
stop = _inconclusive("original", run) or _collected("original", run, ids, exact=exact)
|
|
554
|
+
if stop is not None:
|
|
555
|
+
return stop
|
|
556
|
+
if any(t.status not in (TestStatus.PASSED, TestStatus.FAILED) for t in run.tests):
|
|
557
|
+
return "not_verified", "original run has errored or skipped tests"
|
|
558
|
+
if all(t.status == TestStatus.PASSED for t in run.tests):
|
|
559
|
+
return "not_verified", "selected tests already pass on the original code"
|
|
560
|
+
if run.outcome != RunOutcome.FAILED:
|
|
561
|
+
return "not_verified", "the original runner did not complete as a test failure"
|
|
562
|
+
return None
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _judge_after(run: RunResult, ids: Sequence[str], *, exact: bool = False) -> Verdict:
|
|
566
|
+
stop = _inconclusive("final", run) or _collected("final", run, ids, exact=exact)
|
|
567
|
+
if stop is not None:
|
|
568
|
+
return stop
|
|
569
|
+
if any(t.status != TestStatus.PASSED for t in run.tests):
|
|
570
|
+
return "not_verified", "a selected test does not pass on the final code"
|
|
571
|
+
if run.outcome != RunOutcome.PASSED:
|
|
572
|
+
return "not_verified", "the final runner did not complete successfully"
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _statuses(run: RunResult) -> dict[str, list[TestStatus]]:
|
|
577
|
+
grouped: dict[str, list[TestStatus]] = {}
|
|
578
|
+
for test in run.tests:
|
|
579
|
+
grouped.setdefault(test.id, []).append(test.status)
|
|
580
|
+
return grouped
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _same_identities(before: RunResult, after: RunResult) -> Verdict:
|
|
584
|
+
counts = Counter(t.id for t in before.tests)
|
|
585
|
+
if counts != Counter(t.id for t in after.tests):
|
|
586
|
+
return "not_verified", "selected test identities differ between original and final runs"
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _verified(before: RunResult, after: RunResult) -> tuple[str, ...]:
|
|
591
|
+
after_statuses = _statuses(after)
|
|
592
|
+
return tuple(
|
|
593
|
+
test_id
|
|
594
|
+
for test_id, statuses in _statuses(before).items()
|
|
595
|
+
if all(s == TestStatus.FAILED for s in statuses)
|
|
596
|
+
and after_statuses.get(test_id, []) == [TestStatus.PASSED] * len(statuses)
|
|
597
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Deterministic Markdown rendering of facts, never source code or raw test output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
|
|
7
|
+
from patchground.report.models import VerifiedReport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _printable(value: str) -> str:
|
|
11
|
+
return "".join(char if char.isprintable() else " " for char in value)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _escaped(value: str) -> str:
|
|
15
|
+
escaped = html.escape(_printable(value), quote=True)
|
|
16
|
+
for char in ("\\", "`", "*", "_", "[", "]", "#", "|", "!"):
|
|
17
|
+
escaped = escaped.replace(char, "\\" + char)
|
|
18
|
+
return escaped
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_markdown(report: VerifiedReport, *, terminal: bool = False) -> str:
|
|
22
|
+
"""The saved ``report.md``; ``terminal`` renders the same facts as plain text for a console,
|
|
23
|
+
without Markdown or HTML escaping (control characters are still replaced)."""
|
|
24
|
+
_text = _printable if terminal else _escaped
|
|
25
|
+
checks = report.checks
|
|
26
|
+
lines = [
|
|
27
|
+
f"# Verified outcomes: {_text(report.challenge_id)}",
|
|
28
|
+
"",
|
|
29
|
+
f"Challenge version: {_text(report.challenge_version)}",
|
|
30
|
+
f"Submitted: {report.submitted_at.isoformat()}",
|
|
31
|
+
"",
|
|
32
|
+
"## Checks",
|
|
33
|
+
"",
|
|
34
|
+
f"Outcome: {checks.outcome.value}; passing: {checks.passed}.",
|
|
35
|
+
", ".join(f"{name}: {count}" for name, count in sorted(checks.counts.items())),
|
|
36
|
+
]
|
|
37
|
+
if checks.limit:
|
|
38
|
+
lines.append(f"Resource limit: {checks.limit}.")
|
|
39
|
+
lines.extend(f"- {test.status.value}: {_text(test.id)}" for test in checks.tests)
|
|
40
|
+
lines += [
|
|
41
|
+
"",
|
|
42
|
+
"## Regression test",
|
|
43
|
+
"",
|
|
44
|
+
f"{report.regression.status}: {_text(report.regression.reason)}.",
|
|
45
|
+
]
|
|
46
|
+
lines.extend(f"- Verified: {_text(test)}" for test in report.regression.verified_tests)
|
|
47
|
+
lines += ["", "## Diff scope", ""]
|
|
48
|
+
if not report.diff.expected_paths:
|
|
49
|
+
lines.append("Expected paths were not declared.")
|
|
50
|
+
if not report.diff.files:
|
|
51
|
+
lines.append("No ordinary checked-file changes.")
|
|
52
|
+
for change in report.diff.files:
|
|
53
|
+
counts = (
|
|
54
|
+
f"+{change.added_lines} -{change.removed_lines}"
|
|
55
|
+
if change.added_lines is not None
|
|
56
|
+
else "line counts not established (binary, non-UTF-8 or over 1 MiB)"
|
|
57
|
+
)
|
|
58
|
+
scope = "; outside expected paths" if change.outside_expected else ""
|
|
59
|
+
lines.append(f"- {_text(change.path)}: {change.kind}, {counts}{scope}")
|
|
60
|
+
lines += [
|
|
61
|
+
"",
|
|
62
|
+
"## Test structure",
|
|
63
|
+
"",
|
|
64
|
+
f"{report.test_structure.status}: {_text(report.test_structure.reason)}.",
|
|
65
|
+
"Structural removals are not a judgment of semantic test strength.",
|
|
66
|
+
]
|
|
67
|
+
lines.extend(f"- Removed file: {_text(path)}" for path in report.test_structure.removed_files)
|
|
68
|
+
lines.extend(f"- Removed test: {_text(test)}" for test in report.test_structure.removed_tests)
|
|
69
|
+
lines.extend(
|
|
70
|
+
f"- Assertion count reduced: {_text(item.test)} ({item.before} → {item.after})"
|
|
71
|
+
for item in report.test_structure.assertion_reductions
|
|
72
|
+
)
|
|
73
|
+
lines.extend(
|
|
74
|
+
f"- Unsupported source: {_text(path)}" for path in report.test_structure.unsupported_files
|
|
75
|
+
)
|
|
76
|
+
effort = report.effort
|
|
77
|
+
lines += ["", "## Time and effort", ""]
|
|
78
|
+
if effort.elapsed_seconds is None:
|
|
79
|
+
lines.append("Timer off; elapsed time not established.")
|
|
80
|
+
else:
|
|
81
|
+
lines.append(
|
|
82
|
+
f"Elapsed at submission request: {int(effort.elapsed_seconds)}s / "
|
|
83
|
+
f"{effort.timebox_minutes} minutes."
|
|
84
|
+
)
|
|
85
|
+
lines += [
|
|
86
|
+
f"Practice checks: {effort.practice_checks}; hints revealed: {effort.hints_revealed}.",
|
|
87
|
+
"Submission and regression verification runs are separate from practice-check counts.",
|
|
88
|
+
"",
|
|
89
|
+
"No overall score. This report is local evidence, not tamper-proof attestation.",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
return "\n".join(lines)
|