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.
Files changed (194) hide show
  1. patchground/__init__.py +10 -0
  2. patchground/__main__.py +3 -0
  3. patchground/authoring/__init__.py +5 -0
  4. patchground/authoring/create.py +179 -0
  5. patchground/authoring/templates/python-debug/TICKET.md.tmpl +11 -0
  6. patchground/authoring/templates/python-debug/challenge.yaml.tmpl +21 -0
  7. patchground/authoring/templates/python-debug/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  8. patchground/authoring/templates/python-debug/checks/visible/tests/test_starter.py.tmpl +9 -0
  9. patchground/authoring/templates/python-debug/environment/Dockerfile.tmpl +3 -0
  10. patchground/authoring/templates/python-debug/solution/reference.patch.tmpl +8 -0
  11. patchground/authoring/templates/python-debug/workspace/pyproject.toml.tmpl +8 -0
  12. patchground/authoring/templates/python-debug/workspace/src/starter.py.tmpl +3 -0
  13. patchground/authoring/templates/python-feature/TICKET.md.tmpl +13 -0
  14. patchground/authoring/templates/python-feature/challenge.yaml.tmpl +22 -0
  15. patchground/authoring/templates/python-feature/checks/hidden/tests/test_starter_hidden.py.tmpl +13 -0
  16. patchground/authoring/templates/python-feature/checks/visible/tests/test_starter.py.tmpl +9 -0
  17. patchground/authoring/templates/python-feature/environment/Dockerfile.tmpl +3 -0
  18. patchground/authoring/templates/python-feature/solution/reference.patch.tmpl +9 -0
  19. patchground/authoring/templates/python-feature/workspace/pyproject.toml.tmpl +8 -0
  20. patchground/authoring/templates/python-feature/workspace/src/starter.py.tmpl +8 -0
  21. patchground/authoring/templates/typescript-debug/TICKET.md.tmpl +11 -0
  22. patchground/authoring/templates/typescript-debug/challenge.yaml.tmpl +21 -0
  23. patchground/authoring/templates/typescript-debug/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  24. patchground/authoring/templates/typescript-debug/checks/visible/tests/starter.test.ts.tmpl +10 -0
  25. patchground/authoring/templates/typescript-debug/solution/reference.patch.tmpl +9 -0
  26. patchground/authoring/templates/typescript-debug/workspace/package.json.tmpl +6 -0
  27. patchground/authoring/templates/typescript-debug/workspace/src/starter.ts.tmpl +4 -0
  28. patchground/authoring/templates/typescript-feature/TICKET.md.tmpl +13 -0
  29. patchground/authoring/templates/typescript-feature/challenge.yaml.tmpl +22 -0
  30. patchground/authoring/templates/typescript-feature/checks/hidden/tests/starter-hidden.test.ts.tmpl +14 -0
  31. patchground/authoring/templates/typescript-feature/checks/visible/tests/starter.test.ts.tmpl +10 -0
  32. patchground/authoring/templates/typescript-feature/solution/reference.patch.tmpl +10 -0
  33. patchground/authoring/templates/typescript-feature/workspace/package.json.tmpl +6 -0
  34. patchground/authoring/templates/typescript-feature/workspace/src/starter.ts.tmpl +9 -0
  35. patchground/automation/__init__.py +5 -0
  36. patchground/automation/__main__.py +131 -0
  37. patchground/automation/workflow.py +214 -0
  38. patchground/catalog/README.md +10 -0
  39. patchground/catalog/cache-expiry/TICKET.md +49 -0
  40. patchground/catalog/cache-expiry/challenge.yaml +32 -0
  41. patchground/catalog/cache-expiry/checks/hidden/tests/test_cache_hidden.py +164 -0
  42. patchground/catalog/cache-expiry/checks/visible/tests/test_cache.py +56 -0
  43. patchground/catalog/cache-expiry/debrief/questions.md +4 -0
  44. patchground/catalog/cache-expiry/environment/Dockerfile +3 -0
  45. patchground/catalog/cache-expiry/solution/reference.patch +41 -0
  46. patchground/catalog/cache-expiry/workspace/pyproject.toml +8 -0
  47. patchground/catalog/cache-expiry/workspace/src/profilecache/__init__.py +6 -0
  48. patchground/catalog/cache-expiry/workspace/src/profilecache/cache.py +67 -0
  49. patchground/catalog/cache-expiry/workspace/src/profilecache/client.py +38 -0
  50. patchground/catalog/csv-import-row-errors/TICKET.md +97 -0
  51. patchground/catalog/csv-import-row-errors/challenge.yaml +33 -0
  52. patchground/catalog/csv-import-row-errors/checks/hidden/tests/test_import_hidden.py +359 -0
  53. patchground/catalog/csv-import-row-errors/checks/visible/tests/test_import.py +34 -0
  54. patchground/catalog/csv-import-row-errors/debrief/questions.md +5 -0
  55. patchground/catalog/csv-import-row-errors/environment/Dockerfile +3 -0
  56. patchground/catalog/csv-import-row-errors/solution/reference.patch +74 -0
  57. patchground/catalog/csv-import-row-errors/workspace/pyproject.toml +8 -0
  58. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/__init__.py +4 -0
  59. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/importer.py +69 -0
  60. patchground/catalog/csv-import-row-errors/workspace/src/stockimport/models.py +32 -0
  61. patchground/catalog/duplicate-charges-on-retry/TICKET.md +28 -0
  62. patchground/catalog/duplicate-charges-on-retry/challenge.yaml +30 -0
  63. patchground/catalog/duplicate-charges-on-retry/checks/hidden/tests/test_payment_regressions.py +87 -0
  64. patchground/catalog/duplicate-charges-on-retry/checks/visible/tests/test_checkout.py +34 -0
  65. patchground/catalog/duplicate-charges-on-retry/debrief/questions.md +6 -0
  66. patchground/catalog/duplicate-charges-on-retry/environment/Dockerfile +3 -0
  67. patchground/catalog/duplicate-charges-on-retry/solution/reference.patch +11 -0
  68. patchground/catalog/duplicate-charges-on-retry/workspace/pyproject.toml +8 -0
  69. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/__init__.py +1 -0
  70. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/gateway.py +38 -0
  71. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/models.py +19 -0
  72. patchground/catalog/duplicate-charges-on-retry/workspace/src/payments/service.py +23 -0
  73. patchground/catalog/event-bus-subscriptions/TICKET.md +108 -0
  74. patchground/catalog/event-bus-subscriptions/challenge.yaml +33 -0
  75. patchground/catalog/event-bus-subscriptions/checks/hidden/tests/event-bus-contract.test.ts +566 -0
  76. patchground/catalog/event-bus-subscriptions/checks/visible/tests/event-bus.test.ts +70 -0
  77. patchground/catalog/event-bus-subscriptions/debrief/questions.md +5 -0
  78. patchground/catalog/event-bus-subscriptions/environment/Dockerfile +7 -0
  79. patchground/catalog/event-bus-subscriptions/environment/package-lock.json +1054 -0
  80. patchground/catalog/event-bus-subscriptions/environment/package.json +13 -0
  81. patchground/catalog/event-bus-subscriptions/environment/vitest-runner.mjs +124 -0
  82. patchground/catalog/event-bus-subscriptions/solution/reference.patch +82 -0
  83. patchground/catalog/event-bus-subscriptions/workspace/package.json +6 -0
  84. patchground/catalog/event-bus-subscriptions/workspace/src/bus.ts +32 -0
  85. patchground/catalog/order-notifications-boundary/TICKET.md +51 -0
  86. patchground/catalog/order-notifications-boundary/challenge.yaml +32 -0
  87. patchground/catalog/order-notifications-boundary/checks/hidden/tests/test_boundary_hidden.py +87 -0
  88. patchground/catalog/order-notifications-boundary/checks/visible/tests/test_confirmation.py +41 -0
  89. patchground/catalog/order-notifications-boundary/debrief/questions.md +5 -0
  90. patchground/catalog/order-notifications-boundary/environment/Dockerfile +3 -0
  91. patchground/catalog/order-notifications-boundary/solution/reference.patch +77 -0
  92. patchground/catalog/order-notifications-boundary/workspace/pyproject.toml +8 -0
  93. patchground/catalog/order-notifications-boundary/workspace/src/orders/__init__.py +6 -0
  94. patchground/catalog/order-notifications-boundary/workspace/src/orders/app.py +9 -0
  95. patchground/catalog/order-notifications-boundary/workspace/src/orders/models.py +25 -0
  96. patchground/catalog/order-notifications-boundary/workspace/src/orders/service.py +31 -0
  97. patchground/catalog/order-notifications-boundary/workspace/src/orders/smtp_gateway.py +19 -0
  98. patchground/catalog/order-notifications-boundary/workspace/src/orders/sqlite_store.py +44 -0
  99. patchground/catalog/pagination-last-page/TICKET.md +44 -0
  100. patchground/catalog/pagination-last-page/challenge.yaml +31 -0
  101. patchground/catalog/pagination-last-page/checks/hidden/tests/test_pagination_hidden.py +32 -0
  102. patchground/catalog/pagination-last-page/checks/visible/tests/test_pagination.py +37 -0
  103. patchground/catalog/pagination-last-page/debrief/questions.md +9 -0
  104. patchground/catalog/pagination-last-page/environment/Dockerfile +3 -0
  105. patchground/catalog/pagination-last-page/solution/reference.patch +22 -0
  106. patchground/catalog/pagination-last-page/workspace/pyproject.toml +8 -0
  107. patchground/catalog/pagination-last-page/workspace/src/catalog/__init__.py +1 -0
  108. patchground/catalog/pagination-last-page/workspace/src/catalog/pagination.py +52 -0
  109. patchground/catalog/release-version-ordering/TICKET.md +72 -0
  110. patchground/catalog/release-version-ordering/challenge.yaml +31 -0
  111. patchground/catalog/release-version-ordering/checks/hidden/tests/releasekit_contract.test.ts +92 -0
  112. patchground/catalog/release-version-ordering/checks/visible/tests/releasekit.test.ts +31 -0
  113. patchground/catalog/release-version-ordering/debrief/questions.md +9 -0
  114. patchground/catalog/release-version-ordering/environment/Dockerfile +7 -0
  115. patchground/catalog/release-version-ordering/environment/package-lock.json +1054 -0
  116. patchground/catalog/release-version-ordering/environment/package.json +13 -0
  117. patchground/catalog/release-version-ordering/environment/vitest-runner.mjs +124 -0
  118. patchground/catalog/release-version-ordering/solution/reference.patch +36 -0
  119. patchground/catalog/release-version-ordering/workspace/README.md +7 -0
  120. patchground/catalog/release-version-ordering/workspace/src/releasekit/index.ts +2 -0
  121. patchground/catalog/release-version-ordering/workspace/src/releasekit/ordering.ts +41 -0
  122. patchground/catalog/release-version-ordering/workspace/src/releasekit/parse.ts +24 -0
  123. patchground/cli/__init__.py +1 -0
  124. patchground/cli/app.py +59 -0
  125. patchground/cli/authoring.py +41 -0
  126. patchground/cli/consent.py +77 -0
  127. patchground/cli/discovery.py +189 -0
  128. patchground/cli/doctor.py +249 -0
  129. patchground/cli/packs.py +142 -0
  130. patchground/cli/practice.py +141 -0
  131. patchground/cli/render.py +58 -0
  132. patchground/cli/reports.py +53 -0
  133. patchground/cli/sessions.py +195 -0
  134. patchground/cli/validate.py +117 -0
  135. patchground/packs/__init__.py +23 -0
  136. patchground/packs/builtin.py +53 -0
  137. patchground/packs/discovery.py +285 -0
  138. patchground/packs/execution.py +420 -0
  139. patchground/packs/models.py +98 -0
  140. patchground/packs/registry.py +318 -0
  141. patchground/packs/source.py +230 -0
  142. patchground/packs/store.py +420 -0
  143. patchground/py.typed +0 -0
  144. patchground/report/__init__.py +6 -0
  145. patchground/report/analysis.py +188 -0
  146. patchground/report/models.py +59 -0
  147. patchground/report/regression.py +597 -0
  148. patchground/report/render.py +92 -0
  149. patchground/report/workflow.py +84 -0
  150. patchground/runner_assets/Dockerfile +7 -0
  151. patchground/runner_assets/__init__.py +16 -0
  152. patchground/runner_assets/package-lock.json +1054 -0
  153. patchground/runner_assets/package.json +13 -0
  154. patchground/runner_assets/vitest-runner.mjs +124 -0
  155. patchground/safeio/__init__.py +115 -0
  156. patchground/sandbox/__init__.py +39 -0
  157. patchground/sandbox/_engine.py +23 -0
  158. patchground/sandbox/build.py +202 -0
  159. patchground/sandbox/errors.py +23 -0
  160. patchground/sandbox/junit.py +76 -0
  161. patchground/sandbox/probe.py +118 -0
  162. patchground/sandbox/run.py +535 -0
  163. patchground/sessions/__init__.py +44 -0
  164. patchground/sessions/diagnostics.py +145 -0
  165. patchground/sessions/files.py +113 -0
  166. patchground/sessions/git.py +70 -0
  167. patchground/sessions/history.py +386 -0
  168. patchground/sessions/history_ignore.py +279 -0
  169. patchground/sessions/models.py +126 -0
  170. patchground/sessions/practice.py +202 -0
  171. patchground/sessions/reset.py +121 -0
  172. patchground/sessions/store.py +203 -0
  173. patchground/sessions/submission.py +344 -0
  174. patchground/sessions/workflow.py +297 -0
  175. patchground/spec/__init__.py +35 -0
  176. patchground/spec/_schema.py +53 -0
  177. patchground/spec/loader.py +713 -0
  178. patchground/spec/models.py +394 -0
  179. patchground/spec/schema.py +50 -0
  180. patchground/verify/__init__.py +29 -0
  181. patchground/verify/errors.py +10 -0
  182. patchground/verify/pytest_adapter.py +95 -0
  183. patchground/verify/record.py +94 -0
  184. patchground/verify/reference.py +158 -0
  185. patchground/verify/result.py +199 -0
  186. patchground/verify/runners.py +47 -0
  187. patchground/verify/tampering.py +106 -0
  188. patchground/verify/validate.py +686 -0
  189. patchground/verify/vitest_adapter.py +252 -0
  190. patchground-0.1.0.dist-info/METADATA +258 -0
  191. patchground-0.1.0.dist-info/RECORD +194 -0
  192. patchground-0.1.0.dist-info/WHEEL +4 -0
  193. patchground-0.1.0.dist-info/entry_points.txt +3 -0
  194. patchground-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,131 @@
1
+ """Command-line entry for the reusable validation Action.
2
+
3
+ Run as ``python -I -m patchground.automation``. Only fixed, path-free messages are emitted.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import contextlib
10
+ import json
11
+ import os
12
+ import signal
13
+ import sys
14
+ from collections.abc import Callable, Sequence
15
+ from pathlib import Path
16
+ from typing import Any, NoReturn
17
+
18
+ from patchground.safeio import SafetyError, open_directory
19
+
20
+ ERROR_FORMAT = "patchground-automation-error"
21
+ USAGE_EXIT = 2
22
+ CANCEL_EXIT = 130
23
+
24
+
25
+ class _UsageError(Exception):
26
+ pass
27
+
28
+
29
+ class _Parser(argparse.ArgumentParser):
30
+ def error(self, message: str) -> NoReturn:
31
+ raise _UsageError("invalid command-line arguments")
32
+
33
+
34
+ def _parser() -> argparse.ArgumentParser:
35
+ parser = _Parser(prog="python -m patchground.automation", add_help=False, exit_on_error=False)
36
+ parser.add_argument("--source", required=True)
37
+ parser.add_argument("--directory", default=".")
38
+ parser.add_argument("--base", default=None)
39
+ parser.add_argument("--consent", action="store_true")
40
+ parser.add_argument("--report", required=True)
41
+ return parser
42
+
43
+
44
+ def _error_document(message: str) -> dict[str, Any]:
45
+ return {"format": ERROR_FORMAT, "version": 1, "exit_code": USAGE_EXIT, "error": message}
46
+
47
+
48
+ def _reserve_report(report: str, source: Path) -> int:
49
+ """Exclusively create the report in a pinned, link-free parent outside the checkout."""
50
+ path = Path(report).absolute()
51
+ if ".." in path.parts or path.name in {"", "."}:
52
+ raise _UsageError("report path must name a file without '..' components")
53
+ parent = path.parent
54
+ if parent == source or source in parent.parents:
55
+ raise _UsageError("report path must be outside the source checkout")
56
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
57
+ try:
58
+ with open_directory(parent, label="report") as pinned:
59
+ return os.open(path.name, flags, 0o600, dir_fd=pinned)
60
+ except FileExistsError:
61
+ raise _UsageError("report file already exists") from None
62
+ except (SafetyError, OSError):
63
+ raise _UsageError("report location is unsafe or unavailable") from None
64
+
65
+
66
+ def _write(fd: int, document: dict[str, Any]) -> None:
67
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
68
+ json.dump(document, handle, indent=2, sort_keys=True)
69
+ handle.write("\n")
70
+
71
+
72
+ def _default_validate() -> Callable[..., Any]:
73
+ from patchground.automation import validate_changes
74
+
75
+ return validate_changes
76
+
77
+
78
+ def main(
79
+ argv: Sequence[str] | None = None,
80
+ *,
81
+ validate: Callable[..., Any] | None = None,
82
+ ) -> int:
83
+ fd: int | None = None
84
+ document: dict[str, Any] = _error_document("automation failed unexpectedly")
85
+ code = USAGE_EXIT
86
+ try:
87
+ try:
88
+ args = _parser().parse_args(argv)
89
+ except argparse.ArgumentError:
90
+ raise _UsageError("invalid command-line arguments") from None
91
+ source = Path(args.source).resolve()
92
+ if not source.is_dir():
93
+ raise _UsageError("source is not a directory")
94
+ fd = _reserve_report(args.report, source)
95
+ run = validate or _default_validate()
96
+ result = run(
97
+ source,
98
+ directory=args.directory,
99
+ base=args.base or None,
100
+ consent=args.consent,
101
+ )
102
+ document, code = result.to_dict(), int(result.exit_code)
103
+ reserved, fd = fd, None
104
+ _write(reserved, document)
105
+ print(f"patchground validation finished with exit code {code}; report written")
106
+ return code
107
+ except _UsageError as exc:
108
+ document, code = _error_document(str(exc)), USAGE_EXIT
109
+ except KeyboardInterrupt:
110
+ document, code = _error_document("cancelled"), CANCEL_EXIT
111
+ document["exit_code"] = code
112
+ except Exception as exc: # report type only: messages may carry paths or source text
113
+ document = _error_document(f"automation failed unexpectedly ({type(exc).__name__})")
114
+ code = USAGE_EXIT
115
+ finally:
116
+ # Leaves no open descriptor behind; a repeated interrupt here is safe to ignore.
117
+ pending, fd = fd, None
118
+ if pending is not None:
119
+ with contextlib.suppress(OSError, KeyboardInterrupt):
120
+ _write(pending, document)
121
+ print(json.dumps(document, sort_keys=True), file=sys.stderr)
122
+ return code
123
+
124
+
125
+ def _terminate(signum: int, frame: Any) -> NoReturn:
126
+ raise KeyboardInterrupt
127
+
128
+
129
+ if __name__ == "__main__":
130
+ signal.signal(signal.SIGTERM, _terminate)
131
+ sys.exit(main())
@@ -0,0 +1,214 @@
1
+ """Select committed challenges and aggregate the existing verifier's safe facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path, PurePosixPath
9
+ from tempfile import TemporaryDirectory
10
+ from typing import Any
11
+
12
+ from patchground import verify
13
+ from patchground.packs.discovery import discover_challenges
14
+ from patchground.packs.models import PackError, SourceSnapshot
15
+ from patchground.packs.source import fetch_pack
16
+ from patchground.packs.store import capture_tree
17
+ from patchground.spec import ManifestError, load_challenge, load_pack
18
+
19
+
20
+ @dataclass
21
+ class AutomationResult:
22
+ """One JSON-safe outcome, containing no verifier diagnostics or scratch paths."""
23
+
24
+ exit_code: int = 0
25
+ commit: str | None = None
26
+ base_commit: str | None = None
27
+ selection_mode: str = "all"
28
+ selected: list[str] = field(default_factory=list)
29
+ unchanged: list[str] = field(default_factory=list)
30
+ removed: list[str] = field(default_factory=list)
31
+ validations: list[dict[str, Any]] = field(default_factory=list)
32
+ errors: list[str] = field(default_factory=list)
33
+
34
+ def to_dict(self) -> dict[str, Any]:
35
+ return {
36
+ "format": "patchground-automation",
37
+ "format_version": 1,
38
+ "commit": self.commit,
39
+ "base_commit": self.base_commit,
40
+ "selection_mode": self.selection_mode,
41
+ "status": "failed" if self.exit_code else "validated" if self.selected else "skipped",
42
+ "exit_code": self.exit_code,
43
+ "selected": list(self.selected),
44
+ "unchanged": list(self.unchanged),
45
+ "removed": list(self.removed),
46
+ "validations": list(self.validations),
47
+ "errors": list(self.errors),
48
+ }
49
+
50
+
51
+ @contextmanager
52
+ def _snapshot(
53
+ source: Path, *, ref: str = "HEAD"
54
+ ) -> Iterator[tuple[SourceSnapshot, tuple[Path, ...]]]:
55
+ with fetch_pack(source, ref=ref) as snapshot:
56
+ if not (snapshot.path / "challenge.yaml").is_file():
57
+ yield snapshot, (snapshot.path,)
58
+ return
59
+ # The verifier requires the root folder name to match the manifest id. Git transport's
60
+ # scratch name is not the authored checkout name; preserve that identity in our own copy.
61
+ with TemporaryDirectory(prefix="patchground-automation-") as temporary:
62
+ target = Path(temporary).resolve() / Path(snapshot.source).name
63
+ target.mkdir()
64
+ capture_tree(snapshot.path.resolve(), target, seal=False)
65
+ yield snapshot.model_copy(update={"path": target}), (snapshot.path, target)
66
+
67
+
68
+ def _candidates(root: Path, directory: str) -> list[str]:
69
+ folder = root / directory
70
+ if not folder.is_dir():
71
+ raise ValueError("selected directory is unavailable")
72
+ if (folder / "pack.yaml").exists():
73
+ pack = load_pack(folder)
74
+ paths = [folder / name for name in pack.manifest.challenges]
75
+ elif (folder / "challenge.yaml").exists():
76
+ paths = [folder]
77
+ else:
78
+ discovered = discover_challenges(folder)
79
+ paths = [Path(item.path) for item in discovered.challenges]
80
+ paths.extend(Path(item.path) for item in discovered.invalid)
81
+ # Preflight every candidate, including unchanged ones, before any build.
82
+ for path in paths:
83
+ load_challenge(path)
84
+ return sorted(path.relative_to(root).as_posix() for path in paths)
85
+
86
+
87
+ def _pack_metadata(folder: Path) -> bytes | None:
88
+ manifest = folder / "pack.yaml"
89
+ return manifest.read_bytes() if manifest.is_file() else None
90
+
91
+
92
+ def _manifest_errors(exc: ManifestError, root: Path) -> list[str]:
93
+ return [
94
+ f"{issue.file.relative_to(root).as_posix()}:{issue.line or 1}: "
95
+ "invalid manifest or challenge layout"
96
+ for issue in exc.issues
97
+ ]
98
+
99
+
100
+ def validate_changes(
101
+ source: Path,
102
+ *,
103
+ directory: str = ".",
104
+ base: str | None = None,
105
+ consent: bool = False,
106
+ sandbox: verify.Sandbox | None = None,
107
+ ) -> AutomationResult:
108
+ """Validate selected raw committed inputs with explicit build consent and no writes."""
109
+ result = AutomationResult()
110
+ relative = PurePosixPath(directory)
111
+ if (
112
+ not directory
113
+ or relative.is_absolute()
114
+ or ".." in relative.parts
115
+ or "\\" in directory
116
+ or ":" in directory
117
+ or any(ord(char) < 32 for char in directory)
118
+ ):
119
+ result.exit_code = 2
120
+ result.errors.append("directory must be a relative path within the committed source")
121
+ return result
122
+ try:
123
+ with _snapshot(Path(source)) as (current, captured_roots):
124
+ result.commit = current.commit
125
+ try:
126
+ candidates = _candidates(current.path, directory)
127
+ except ManifestError as exc:
128
+ result.exit_code = 1
129
+ result.errors = _manifest_errors(exc, current.path)
130
+ return result
131
+ except ValueError:
132
+ result.exit_code = 1
133
+ result.errors.append("selected directory has invalid challenge candidates")
134
+ return result
135
+ if not candidates:
136
+ result.exit_code = 1
137
+ result.errors.append("selected directory has no discoverable challenges")
138
+ return result
139
+ result.selected = candidates
140
+ if base is not None:
141
+ result.selection_mode = "fallback_all"
142
+ try:
143
+ with _snapshot(Path(source), ref=base) as (previous, _):
144
+ old = _candidates(previous.path, directory)
145
+ metadata_changed = _pack_metadata(
146
+ current.path / directory
147
+ ) != _pack_metadata(previous.path / directory)
148
+ result.removed = sorted(set(old) - set(candidates))
149
+ result.unchanged = [
150
+ name
151
+ for name in candidates
152
+ if name in old
153
+ and not metadata_changed
154
+ and capture_tree(
155
+ (current.path / name).resolve(), None, seal=False
156
+ ).digest
157
+ == capture_tree(
158
+ (previous.path / name).resolve(), None, seal=False
159
+ ).digest
160
+ ]
161
+ result.selected = [
162
+ name for name in candidates if name not in result.unchanged
163
+ ]
164
+ result.base_commit = previous.commit
165
+ result.selection_mode = "changed"
166
+ except (PackError, ManifestError, ValueError, OSError):
167
+ # An unusable comparison must never turn into a silent skip.
168
+ result.selected = candidates
169
+ result.unchanged = []
170
+ result.removed = []
171
+ if result.selected and not consent:
172
+ result.exit_code = 2
173
+ result.errors.append("explicit execution consent is required before building")
174
+ return result
175
+ for name in result.selected:
176
+ try:
177
+ report = verify.validate_challenge(
178
+ current.path / name,
179
+ write=False,
180
+ sandbox=sandbox,
181
+ input_roots=(Path(current.source), *captured_roots),
182
+ )
183
+ except verify.VerifyEnvironmentError:
184
+ result.exit_code = 2
185
+ result.errors.append(f"{name}: validation infrastructure unavailable")
186
+ continue
187
+ result.validations.append(
188
+ {
189
+ "path": name,
190
+ "valid": report.valid,
191
+ "failed_step": report.failed_step.value if report.failed_step else None,
192
+ "steps": [
193
+ {
194
+ "name": step.name.value,
195
+ "status": step.status.value,
196
+ "runs": [
197
+ {
198
+ "label": run.label,
199
+ "outcome": run.outcome.value,
200
+ "counts": run.to_dict()["counts"],
201
+ }
202
+ for run in step.runs
203
+ ],
204
+ }
205
+ for step in report.steps
206
+ ],
207
+ }
208
+ )
209
+ if not report.valid and result.exit_code == 0:
210
+ result.exit_code = 1
211
+ except (PackError, OSError):
212
+ result.exit_code = 2
213
+ result.errors.append("committed source could not be read safely")
214
+ return result
@@ -0,0 +1,10 @@
1
+ # Examples
2
+
3
+ Each folder here is a complete, hand-made challenge in the v0.1 format
4
+ (`docs/contracts/challenge-format.md`): a manifest, a ticket, a starting workspace, an image
5
+ definition, visible and hidden checks, and a reference patch. They are small on purpose — they show
6
+ authors what a good challenge looks like and give Patchground a known-good input for its own tests.
7
+
8
+ Every example must pass `pgnd validate examples/<id>`: the manifest is valid, the image builds, the
9
+ starting workspace fails at least one hidden check, and the reference patch makes every check pass.
10
+ A change that breaks validation of an example is a regression.
@@ -0,0 +1,49 @@
1
+ # Profiles stay stale, and bad TTLs are accepted silently
2
+
3
+ **Reported by:** support and on-call (two tickets merged)
4
+ **Component:** `profilecache`
5
+
6
+ ## Symptom
7
+
8
+ 1. Users who keep a dashboard open see an old display name for as long as the dashboard keeps
9
+ polling. Profiles that nobody is looking at refresh fine. A profile cached with a 30 second
10
+ lifetime but polled every 10 seconds was still served unchanged after several minutes.
11
+ 2. In tests that drive the clock by hand, a profile cached for 30 seconds is still served at exactly
12
+ 30 seconds and only disappears a moment later. Operations tooling that expires entries on a
13
+ schedule shows the same one-tick delay.
14
+ 3. A deploy accidentally configured a cache lifetime of `0`. Nothing complained, and a negative value
15
+ from a config typo was accepted the same way.
16
+
17
+ ## Reproduction
18
+
19
+ ```python
20
+ from profilecache import TTLCache
21
+
22
+ now = [0.0]
23
+ cache = TTLCache(default_ttl=10, clock=lambda: now[0])
24
+
25
+ cache.set("a", 1)
26
+ now[0] = 6
27
+ cache.get("a") # a read at 6s
28
+ now[0] = 10
29
+ print(cache.get("a")) # expected: None, actual: 1
30
+
31
+ cache.set("b", 2, ttl=0) # accepted without error
32
+ ```
33
+
34
+ ## Expected
35
+
36
+ - An entry lives for exactly its TTL: at the instant the clock reaches its deadline it is expired.
37
+ This applies to `get`, `in` and `purge_expired` alike.
38
+ - Writing a key starts its lifetime again with the TTL given for that write. Reading a key never
39
+ changes how long it lives.
40
+ - A TTL of zero or less is invalid, both as the cache default and for an individual write. It must
41
+ raise `ValueError`, and a rejected write must leave the cache exactly as it was.
42
+
43
+ ## Notes
44
+
45
+ - Keep the injectable clock; do not sleep or use real time in tests.
46
+ - Run `pgnd check` from the session workspace to check your changes in Docker.
47
+ - Add regression tests for the reported behavior and keep existing behavior intact.
48
+ - You may use your own editor and AI tools. The goal is to explain the cause and verify your fix,
49
+ not merely make the reported cases pass.
@@ -0,0 +1,32 @@
1
+ spec_version: "0.1"
2
+ id: cache-expiry
3
+ version: 1.0.1
4
+ title: Cached profiles never expire while they are being read
5
+ type: debug
6
+ summary: >-
7
+ A TTL cache keeps entries alive when they are read, expires them one tick late and accepts
8
+ nonpositive TTLs. Find the causes and fix them without changing write behavior.
9
+ languages: [python]
10
+ tags: [caching, ttl, time]
11
+ difficulty: intermediate
12
+ timebox_minutes: 30
13
+ ai_policy: allowed
14
+ environment:
15
+ dockerfile: environment/Dockerfile
16
+ services: []
17
+ test_command: pytest -q -p no:cacheprovider --junitxml=$PG_RESULTS/junit.xml
18
+ checks:
19
+ visible: checks/visible
20
+ hidden: checks/hidden
21
+ solution: solution/reference.patch
22
+ expected_paths: [src/profilecache/, tests/]
23
+ hints:
24
+ - Write down, for one entry, the clock value at which it should expire, then step through what each method does to that deadline.
25
+ - Is the deadline moved by anything other than a write?
26
+ debrief:
27
+ questions: debrief/questions.md
28
+ focus: [debugging_method, test_quality, scope_judgment]
29
+ author: {name: Patchground contributors}
30
+ content_license: CC-BY-4.0
31
+ provenance: generated
32
+ generated_by: {model: "Claude Code configured default (model unverified)", prompt_version: launch-cache-v1}
@@ -0,0 +1,164 @@
1
+ import pytest
2
+
3
+ from profilecache import ProfileClient, TTLCache
4
+
5
+
6
+ class Clock:
7
+ def __init__(self, now: float = 500.0) -> None:
8
+ self.now = now
9
+
10
+ def __call__(self) -> float:
11
+ return self.now
12
+
13
+ def advance(self, seconds: float) -> None:
14
+ self.now += seconds
15
+
16
+
17
+ def test_entry_alive_just_before_deadline():
18
+ clock = Clock()
19
+ cache = TTLCache(default_ttl=10, clock=clock)
20
+ cache.set("a", 1)
21
+ clock.advance(9.5)
22
+ assert cache.get("a") == 1
23
+
24
+
25
+ def test_entry_expires_exactly_at_deadline():
26
+ clock = Clock()
27
+ cache = TTLCache(default_ttl=10, clock=clock)
28
+ cache.set("a", 1)
29
+ clock.advance(10)
30
+ assert cache.get("a", "gone") == "gone"
31
+
32
+
33
+ def test_contains_is_false_exactly_at_deadline():
34
+ clock = Clock()
35
+ cache = TTLCache(default_ttl=10, clock=clock)
36
+ cache.set("a", 1)
37
+ clock.advance(10)
38
+ assert "a" not in cache
39
+
40
+
41
+ def test_purge_removes_entry_exactly_at_deadline():
42
+ clock = Clock()
43
+ cache = TTLCache(default_ttl=10, clock=clock)
44
+ cache.set("a", 1)
45
+ cache.set("b", 2, ttl=20)
46
+ clock.advance(10)
47
+ assert cache.purge_expired() == 1
48
+ assert cache.get("b") == 2
49
+
50
+
51
+ def test_read_does_not_extend_lifetime():
52
+ clock = Clock()
53
+ cache = TTLCache(default_ttl=10, clock=clock)
54
+ cache.set("a", 1)
55
+ clock.advance(6)
56
+ assert cache.get("a") == 1
57
+ clock.advance(4)
58
+ assert cache.get("a", "gone") == "gone"
59
+
60
+
61
+ def test_repeated_reads_never_keep_entry_alive():
62
+ clock = Clock()
63
+ cache = TTLCache(default_ttl=10, clock=clock)
64
+ cache.set("a", 1)
65
+ for _ in range(4):
66
+ clock.advance(2.5)
67
+ cache.get("a")
68
+ assert "a" not in cache
69
+
70
+
71
+ def test_write_refreshes_lifetime():
72
+ clock = Clock()
73
+ cache = TTLCache(default_ttl=10, clock=clock)
74
+ cache.set("a", 1)
75
+ clock.advance(8)
76
+ cache.set("a", 2)
77
+ clock.advance(8)
78
+ assert cache.get("a") == 2
79
+ clock.advance(2)
80
+ assert cache.get("a", "gone") == "gone"
81
+
82
+
83
+ def test_write_can_shorten_lifetime():
84
+ clock = Clock()
85
+ cache = TTLCache(default_ttl=100, clock=clock)
86
+ cache.set("a", 1)
87
+ cache.set("a", 2, ttl=5)
88
+ clock.advance(5)
89
+ assert cache.get("a", "gone") == "gone"
90
+
91
+
92
+ def test_zero_ttl_write_is_rejected():
93
+ cache = TTLCache(clock=Clock())
94
+ with pytest.raises(ValueError):
95
+ cache.set("a", 1, ttl=0)
96
+
97
+
98
+ def test_negative_ttl_write_is_rejected():
99
+ cache = TTLCache(clock=Clock())
100
+ with pytest.raises(ValueError):
101
+ cache.set("a", 1, ttl=-3)
102
+
103
+
104
+ def test_nonpositive_default_ttl_is_rejected():
105
+ with pytest.raises(ValueError):
106
+ TTLCache(default_ttl=0, clock=Clock())
107
+ with pytest.raises(ValueError):
108
+ TTLCache(default_ttl=-1, clock=Clock())
109
+
110
+
111
+ def test_rejected_write_keeps_existing_entry():
112
+ clock = Clock()
113
+ cache = TTLCache(default_ttl=10, clock=clock)
114
+ cache.set("a", 1)
115
+ with pytest.raises(ValueError):
116
+ cache.set("a", 2, ttl=0)
117
+ assert cache.get("a") == 1
118
+
119
+
120
+ def test_rejected_write_does_not_store_new_key():
121
+ cache = TTLCache(clock=Clock())
122
+ with pytest.raises(ValueError):
123
+ cache.set("a", 1, ttl=-1)
124
+ assert "a" not in cache
125
+
126
+
127
+ def test_client_refetches_at_ttl_boundary():
128
+ clock = Clock()
129
+ calls = []
130
+
131
+ def fetch(user_id):
132
+ calls.append(user_id)
133
+ return {"id": user_id, "n": len(calls)}
134
+
135
+ client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
136
+ client.get("u1")
137
+ clock.advance(30)
138
+ assert client.get("u1")["n"] == 2
139
+ assert calls == ["u1", "u1"]
140
+
141
+
142
+ def test_client_sees_fresh_profile_despite_frequent_reads():
143
+ clock = Clock()
144
+ calls = []
145
+
146
+ def fetch(user_id):
147
+ calls.append(user_id)
148
+ return {"id": user_id, "n": len(calls)}
149
+
150
+ client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
151
+ for _ in range(7):
152
+ client.get("u1")
153
+ clock.advance(10)
154
+ assert len(calls) == 3
155
+
156
+
157
+ def test_client_update_restarts_lifetime():
158
+ clock = Clock()
159
+ client = ProfileClient(lambda uid: {"id": uid, "v": 1}, cache=TTLCache(clock=clock), ttl=30)
160
+ client.get("u1")
161
+ clock.advance(25)
162
+ client.update("u1", {"id": "u1", "v": 2})
163
+ clock.advance(25)
164
+ assert client.get("u1")["v"] == 2
@@ -0,0 +1,56 @@
1
+ from profilecache import ProfileClient, TTLCache
2
+
3
+
4
+ class FakeClock:
5
+ def __init__(self, now: float = 1000.0) -> None:
6
+ self.now = now
7
+
8
+ def __call__(self) -> float:
9
+ return self.now
10
+
11
+ def advance(self, seconds: float) -> None:
12
+ self.now += seconds
13
+
14
+
15
+ def test_get_returns_stored_value():
16
+ cache = TTLCache(default_ttl=10, clock=FakeClock())
17
+ cache.set("a", 1)
18
+ assert cache.get("a") == 1
19
+
20
+
21
+ def test_missing_key_returns_default():
22
+ cache = TTLCache(default_ttl=10, clock=FakeClock())
23
+ assert cache.get("nope") is None
24
+ assert cache.get("nope", "fallback") == "fallback"
25
+
26
+
27
+ def test_entry_is_gone_well_after_deadline():
28
+ clock = FakeClock()
29
+ cache = TTLCache(default_ttl=10, clock=clock)
30
+ cache.set("a", 1)
31
+ clock.advance(60)
32
+ assert cache.get("a") is None
33
+ assert "a" not in cache
34
+
35
+
36
+ def test_per_write_ttl_overrides_default():
37
+ clock = FakeClock()
38
+ cache = TTLCache(default_ttl=100, clock=clock)
39
+ cache.set("short", 1, ttl=5)
40
+ clock.advance(20)
41
+ assert cache.get("short") is None
42
+
43
+
44
+ def test_client_fetches_once_while_fresh():
45
+ clock = FakeClock()
46
+ calls = []
47
+
48
+ def fetch(user_id):
49
+ calls.append(user_id)
50
+ return {"id": user_id}
51
+
52
+ client = ProfileClient(fetch, cache=TTLCache(clock=clock), ttl=30)
53
+ assert client.get("u1") == {"id": "u1"}
54
+ clock.advance(5)
55
+ assert client.get("u1") == {"id": "u1"}
56
+ assert calls == ["u1"]
@@ -0,0 +1,4 @@
1
+ - Which of the three symptoms shared a root cause, and which did not? How did you decide?
2
+ - How did you check the expiry boundary without sleeping, and why does an injected clock matter here?
3
+ - Which behavior did you want to leave unchanged while fixing reads, and how did you protect it?
4
+ - If an AI assistant proposed a fix, what did you verify yourself before accepting it?
@@ -0,0 +1,3 @@
1
+ FROM python:3.12-slim
2
+ ENV PYTHONDONTWRITEBYTECODE=1
3
+ RUN pip install --no-cache-dir pytest==8.3.5