ref-agents 1.0.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 (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
@@ -0,0 +1,740 @@
1
+ """Full-suite test execution gate (STORY-TQ-002).
2
+
3
+ Server-side subprocess driver. Detects each project's test runner from
4
+ manifest files, executes them against the entire project tree, writes a
5
+ tamper-resistant signed JSON report under ``ref-reports/platform_engineer/``,
6
+ and powers the PHASE_4 → PHASE_5 ``full_suite_passed`` artifact.
7
+
8
+ The detector, driver, report builder, and marker signer are private to this
9
+ module. The public surface is one function: ``run_full_suite(directory)``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import secrets
18
+ import shutil
19
+ import subprocess
20
+ import threading
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import structlog
28
+
29
+ logger = structlog.get_logger(__name__)
30
+
31
+
32
+ # --- public surface -----------------------------------------------------------
33
+
34
+
35
+ REPORT_DIR_RELATIVE: str = "ref-reports/platform_engineer"
36
+ REPORT_FILENAME: str = "test_suite_report.json"
37
+ REPORT_VERSION: int = 1
38
+ DEFAULT_TIMEOUT_SECONDS: int = 600
39
+ OUTPUT_CAP_BYTES: int = 1 * 1024 * 1024 # 1 MiB
40
+ FAILURE_TAIL_LINES: int = 200
41
+ ENV_ALLOWLIST: frozenset[str] = frozenset(
42
+ {"PATH", "HOME", "LANG", "CI", "LC_ALL", "TZ"}
43
+ )
44
+ MANIFEST_WALK_DEPTH: int = 3
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class RunnerSpec:
49
+ """A detected runner ready for execution."""
50
+
51
+ id: str
52
+ language: str
53
+ command: tuple[str, ...]
54
+ cwd: Path
55
+ manifest_path: Path
56
+
57
+
58
+ @dataclass
59
+ class RunnerResult:
60
+ """Outcome of one runner invocation."""
61
+
62
+ id: str
63
+ exit_code: int
64
+ runtime_seconds: float
65
+ status: str # passed | failed | timeout | error | no_tests | skipped
66
+ failure_tail: str = ""
67
+ skipped_reason: str = ""
68
+
69
+
70
+ def run_full_suite(
71
+ directory: str | Path,
72
+ *,
73
+ timeout_seconds: int | None = None,
74
+ ) -> str:
75
+ """Detect runners, execute each, write the signed report, return summary.
76
+
77
+ Args:
78
+ directory: Project root.
79
+ timeout_seconds: Optional per-runner timeout override; ``None`` uses default.
80
+
81
+ Returns:
82
+ Markdown summary string for the LLM agent. The gate consults the
83
+ on-disk report — not this string.
84
+ """
85
+ project_root = Path(directory).resolve()
86
+ timeout = timeout_seconds or _resolve_timeout(project_root)
87
+
88
+ detector = _ManifestDetector(project_root)
89
+ raw_runners = detector.detect()
90
+ config = _load_project_config(project_root)
91
+ runners = _RunnerRegistry(raw_runners, config).build()
92
+
93
+ if not runners:
94
+ report_dict = _build_report(
95
+ runners=[],
96
+ results=[],
97
+ project_root=project_root,
98
+ status_override="no_runners",
99
+ )
100
+ _write_report(project_root, report_dict)
101
+ return _format_summary(report_dict)
102
+
103
+ driver = _SubprocessDriver(timeout=timeout)
104
+ results: list[RunnerResult] = []
105
+ for spec in runners:
106
+ if spec.id in config.get("disabled_runners", ()):
107
+ results.append(
108
+ RunnerResult(
109
+ id=spec.id,
110
+ exit_code=0,
111
+ runtime_seconds=0.0,
112
+ status="skipped",
113
+ skipped_reason="disabled_by_config",
114
+ )
115
+ )
116
+ continue
117
+ result = driver.execute(spec)
118
+ results.append(result)
119
+
120
+ report_dict = _build_report(
121
+ runners=runners,
122
+ results=results,
123
+ project_root=project_root,
124
+ )
125
+ _write_report(project_root, report_dict)
126
+ return _format_summary(report_dict)
127
+
128
+
129
+ def validate_report_marker(report_path: Path) -> bool:
130
+ """True iff the report's marker was issued by this MCP server process."""
131
+ try:
132
+ with report_path.open("r", encoding="utf-8") as fh:
133
+ data = json.load(fh)
134
+ except (OSError, json.JSONDecodeError):
135
+ return False
136
+ marker = data.get("marker")
137
+ if not isinstance(marker, str):
138
+ return False
139
+ return _marker_signer().validate(marker)
140
+
141
+
142
+ # --- manifest detection -------------------------------------------------------
143
+
144
+
145
+ _MANIFEST_PRIORITY: tuple[tuple[str, str], ...] = (
146
+ # (manifest filename, language)
147
+ ("pyproject.toml", "python"),
148
+ ("setup.py", "python"),
149
+ ("pytest.ini", "python"),
150
+ ("tox.ini", "python"),
151
+ ("package.json", "javascript"),
152
+ ("go.mod", "go"),
153
+ ("Cargo.toml", "rust"),
154
+ ("pom.xml", "java"),
155
+ ("build.gradle", "java"),
156
+ ("build.gradle.kts", "java"),
157
+ )
158
+ _LANGUAGE_ORDER: tuple[str, ...] = (
159
+ "python",
160
+ "javascript",
161
+ "typescript",
162
+ "go",
163
+ "rust",
164
+ "java",
165
+ )
166
+
167
+
168
+ class _ManifestDetector:
169
+ """Walks the project tree and yields candidate runner specs."""
170
+
171
+ def __init__(self, project_root: Path) -> None:
172
+ self.project_root = project_root
173
+
174
+ def detect(self) -> list[RunnerSpec]:
175
+ per_dir: dict[Path, list[tuple[str, str]]] = {}
176
+ for path in self._walk():
177
+ if path.name not in {name for name, _ in _MANIFEST_PRIORITY}:
178
+ continue
179
+ language = next(
180
+ (lang for name, lang in _MANIFEST_PRIORITY if name == path.name),
181
+ None,
182
+ )
183
+ if language is None:
184
+ continue
185
+ per_dir.setdefault(path.parent, []).append((path.name, language))
186
+
187
+ specs: list[RunnerSpec] = []
188
+ for cwd, hits in per_dir.items():
189
+ specs.extend(self._build_specs(cwd, hits))
190
+
191
+ # Sort by language priority order; within a language, by cwd depth.
192
+ order_map = {lang: idx for idx, lang in enumerate(_LANGUAGE_ORDER)}
193
+ specs.sort(key=lambda s: (order_map.get(s.language, 99), len(s.cwd.parts)))
194
+ return specs
195
+
196
+ def _walk(self):
197
+ for root, dirs, files in os.walk(self.project_root):
198
+ depth = len(Path(root).relative_to(self.project_root).parts)
199
+ if depth > MANIFEST_WALK_DEPTH:
200
+ dirs.clear()
201
+ continue
202
+ # Prune common noise directories to keep detection fast.
203
+ dirs[:] = [
204
+ d
205
+ for d in dirs
206
+ if d
207
+ not in {
208
+ "node_modules",
209
+ ".git",
210
+ "__pycache__",
211
+ ".venv",
212
+ "venv",
213
+ "dist",
214
+ "build",
215
+ "target",
216
+ ".tox",
217
+ ".mypy_cache",
218
+ ".pytest_cache",
219
+ }
220
+ ]
221
+ for filename in files:
222
+ yield Path(root) / filename
223
+
224
+ def _build_specs(
225
+ self,
226
+ cwd: Path,
227
+ hits: list[tuple[str, str]],
228
+ ) -> list[RunnerSpec]:
229
+ languages_seen: set[str] = set()
230
+ result: list[RunnerSpec] = []
231
+ # Honour priority: first match per language within the same directory.
232
+ for name, language in _MANIFEST_PRIORITY:
233
+ if (name, language) not in hits:
234
+ continue
235
+ if language in languages_seen:
236
+ continue
237
+ manifest_path = cwd / name
238
+ spec = _resolve_runner(name, language, cwd, manifest_path)
239
+ if spec is None:
240
+ continue
241
+ languages_seen.add(spec.language)
242
+ result.append(spec)
243
+ return result
244
+
245
+
246
+ def _resolve_runner(
247
+ manifest_name: str,
248
+ language: str,
249
+ cwd: Path,
250
+ manifest_path: Path,
251
+ ) -> RunnerSpec | None:
252
+ if manifest_name == "package.json":
253
+ return _resolve_javascript_runner(cwd, manifest_path)
254
+ if manifest_name in ("build.gradle", "build.gradle.kts"):
255
+ return RunnerSpec(
256
+ id="gradle_test",
257
+ language="java",
258
+ command=("./gradlew", "test", "-q"),
259
+ cwd=cwd,
260
+ manifest_path=manifest_path,
261
+ )
262
+ table = {
263
+ "pyproject.toml": ("pytest", "python", ("pytest", "--tb=short", "-q")),
264
+ "setup.py": ("pytest", "python", ("pytest", "--tb=short", "-q")),
265
+ "pytest.ini": ("pytest", "python", ("pytest", "--tb=short", "-q")),
266
+ "tox.ini": ("pytest", "python", ("pytest", "--tb=short", "-q")),
267
+ "go.mod": ("go_test", "go", ("go", "test", "./...")),
268
+ "Cargo.toml": ("cargo_test", "rust", ("cargo", "test")),
269
+ "pom.xml": ("mvn_test", "java", ("mvn", "test", "-q")),
270
+ }
271
+ entry = table.get(manifest_name)
272
+ if entry is None:
273
+ return None
274
+ runner_id, lang, command = entry
275
+ return RunnerSpec(
276
+ id=runner_id,
277
+ language=lang,
278
+ command=command,
279
+ cwd=cwd,
280
+ manifest_path=manifest_path,
281
+ )
282
+
283
+
284
+ def _resolve_javascript_runner(
285
+ cwd: Path,
286
+ manifest_path: Path,
287
+ ) -> RunnerSpec | None:
288
+ try:
289
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
290
+ except (OSError, json.JSONDecodeError):
291
+ return None
292
+ deps: dict[str, Any] = {}
293
+ for key in ("dependencies", "devDependencies"):
294
+ candidate = data.get(key)
295
+ if isinstance(candidate, dict):
296
+ deps.update(candidate)
297
+ if "vitest" in deps:
298
+ runner_id, language, command = "vitest", "typescript", ("npx", "vitest", "run")
299
+ elif "jest" in deps:
300
+ runner_id, language, command = "jest", "javascript", ("npx", "jest", "--ci")
301
+ else:
302
+ return None
303
+ scripts = data.get("scripts", {})
304
+ if isinstance(scripts, dict) and isinstance(scripts.get("test"), str):
305
+ command = ("npm", "test", "--silent")
306
+ return RunnerSpec(
307
+ id=runner_id,
308
+ language=language,
309
+ command=command,
310
+ cwd=cwd,
311
+ manifest_path=manifest_path,
312
+ )
313
+
314
+
315
+ # --- registry + config --------------------------------------------------------
316
+
317
+
318
+ class _RunnerRegistry:
319
+ """Filters detected specs by ref-config.yaml overrides."""
320
+
321
+ def __init__(self, runners: list[RunnerSpec], config: dict[str, Any]) -> None:
322
+ self.runners = runners
323
+ self.config = config
324
+
325
+ def build(self) -> list[RunnerSpec]:
326
+ extra_args = self.config.get("extra_args", {})
327
+ if not isinstance(extra_args, dict):
328
+ extra_args = {}
329
+ result: list[RunnerSpec] = []
330
+ for spec in self.runners:
331
+ extras = extra_args.get(spec.id, ())
332
+ if extras and isinstance(extras, (list, tuple)):
333
+ command = tuple(spec.command) + tuple(str(a) for a in extras)
334
+ result.append(
335
+ RunnerSpec(
336
+ id=spec.id,
337
+ language=spec.language,
338
+ command=command,
339
+ cwd=spec.cwd,
340
+ manifest_path=spec.manifest_path,
341
+ )
342
+ )
343
+ else:
344
+ result.append(spec)
345
+ return result
346
+
347
+
348
+ def _load_project_config(project_root: Path) -> dict[str, Any]:
349
+ config_path = project_root / "ref-config.yaml"
350
+ if not config_path.exists():
351
+ return {}
352
+ try:
353
+ import yaml
354
+
355
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
356
+ except ImportError:
357
+ logger.warning("ref_config_yaml_unavailable")
358
+ return {}
359
+ except (OSError, yaml.YAMLError) as exc: # type: ignore[attr-defined]
360
+ logger.warning("ref_config_malformed", error=str(exc))
361
+ return {}
362
+ block = data.get("full_suite") if isinstance(data, dict) else None
363
+ return block if isinstance(block, dict) else {}
364
+
365
+
366
+ def _resolve_timeout(project_root: Path) -> int:
367
+ config = _load_project_config(project_root)
368
+ raw = config.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)
369
+ try:
370
+ value = int(raw)
371
+ if value <= 0:
372
+ return DEFAULT_TIMEOUT_SECONDS
373
+ return value
374
+ except (TypeError, ValueError):
375
+ return DEFAULT_TIMEOUT_SECONDS
376
+
377
+
378
+ # --- subprocess driver --------------------------------------------------------
379
+
380
+
381
+ class _SubprocessDriver:
382
+ """Spawns each runner with env scrub, timeout, output cap."""
383
+
384
+ def __init__(self, timeout: int) -> None:
385
+ self.timeout = timeout
386
+
387
+ def execute(self, spec: RunnerSpec) -> RunnerResult:
388
+ # Honour PATH lookup; fall back to "error" status when binary missing.
389
+ binary = spec.command[0]
390
+ if not shutil.which(binary):
391
+ logger.warning("runner_binary_missing", runner=spec.id, binary=binary)
392
+ return RunnerResult(
393
+ id=spec.id,
394
+ exit_code=127,
395
+ runtime_seconds=0.0,
396
+ status="error",
397
+ failure_tail=f"binary `{binary}` not found on PATH",
398
+ )
399
+
400
+ env = self._scrub_env()
401
+ start = time.monotonic()
402
+ try:
403
+ proc = subprocess.Popen(
404
+ list(spec.command),
405
+ cwd=str(spec.cwd),
406
+ env=env,
407
+ stdout=subprocess.PIPE,
408
+ stderr=subprocess.STDOUT,
409
+ )
410
+ except OSError as exc:
411
+ return RunnerResult(
412
+ id=spec.id,
413
+ exit_code=126,
414
+ runtime_seconds=time.monotonic() - start,
415
+ status="error",
416
+ failure_tail=str(exc),
417
+ )
418
+
419
+ timed_out, output = self._pump_with_timeout(proc, self.timeout)
420
+ elapsed = time.monotonic() - start
421
+ return self._classify_result(spec, proc, timed_out, output, elapsed)
422
+
423
+ @staticmethod
424
+ def _scrub_env() -> dict[str, str]:
425
+ return {key: os.environ[key] for key in ENV_ALLOWLIST if key in os.environ}
426
+
427
+ @staticmethod
428
+ def _pump_with_timeout(
429
+ proc: subprocess.Popen[bytes],
430
+ timeout: int,
431
+ ) -> tuple[bool, bytes]:
432
+ """Drain stdout into a 1 MiB ring buffer while honouring a wall-clock deadline.
433
+
434
+ Uses ``select.select`` so a subprocess that produces no output before the
435
+ deadline (e.g. a hanging ``sleep``) is still killed on time.
436
+ """
437
+ import select
438
+
439
+ buf = bytearray()
440
+ truncated_bytes = 0
441
+ timed_out = False
442
+ deadline = time.monotonic() + timeout
443
+ stdout = proc.stdout
444
+ if stdout is None:
445
+ try:
446
+ proc.wait(timeout=timeout)
447
+ except subprocess.TimeoutExpired:
448
+ timed_out = True
449
+ _terminate_process_tree(proc)
450
+ return timed_out, bytes(buf)
451
+
452
+ fd = stdout.fileno()
453
+ while True:
454
+ remaining = deadline - time.monotonic()
455
+ if remaining <= 0:
456
+ timed_out = True
457
+ _terminate_process_tree(proc)
458
+ break
459
+ try:
460
+ readable, _, _ = select.select([fd], [], [], min(remaining, 1.0))
461
+ except (OSError, ValueError):
462
+ break
463
+ if readable:
464
+ try:
465
+ chunk = os.read(fd, 64 * 1024)
466
+ except OSError:
467
+ break
468
+ if not chunk:
469
+ break
470
+ if len(buf) + len(chunk) <= OUTPUT_CAP_BYTES:
471
+ buf.extend(chunk)
472
+ elif len(buf) < OUTPUT_CAP_BYTES:
473
+ room = OUTPUT_CAP_BYTES - len(buf)
474
+ buf.extend(chunk[:room])
475
+ truncated_bytes += len(chunk) - room
476
+ else:
477
+ truncated_bytes += len(chunk)
478
+ elif proc.poll() is not None:
479
+ break
480
+ if truncated_bytes:
481
+ buf.extend(f"\n[...truncated {truncated_bytes} bytes]\n".encode())
482
+ try:
483
+ proc.wait(timeout=10)
484
+ except subprocess.TimeoutExpired:
485
+ _terminate_process_tree(proc)
486
+ proc.wait()
487
+ return timed_out, bytes(buf)
488
+
489
+ @staticmethod
490
+ def _classify_result( # noqa: PLR0913 # TECH_DEBT: refactor to TypedDict — STORY-046
491
+ spec: RunnerSpec,
492
+ proc: subprocess.Popen[bytes],
493
+ timed_out: bool,
494
+ output: bytes,
495
+ elapsed: float,
496
+ ) -> RunnerResult:
497
+ tail = _tail_lines(output.decode("utf-8", errors="replace"), FAILURE_TAIL_LINES)
498
+ if timed_out:
499
+ return RunnerResult(
500
+ id=spec.id,
501
+ exit_code=124,
502
+ runtime_seconds=elapsed,
503
+ status="timeout",
504
+ failure_tail=tail,
505
+ )
506
+ exit_code = proc.returncode if proc.returncode is not None else -1
507
+ if exit_code == 0:
508
+ status = "passed"
509
+ elif spec.id == "pytest" and exit_code == 5: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
510
+ status = "no_tests"
511
+ else:
512
+ status = "failed"
513
+ return RunnerResult(
514
+ id=spec.id,
515
+ exit_code=exit_code,
516
+ runtime_seconds=elapsed,
517
+ status=status,
518
+ failure_tail=tail if status != "passed" else "",
519
+ )
520
+
521
+
522
+ def _terminate_process_tree(proc: subprocess.Popen[bytes]) -> None:
523
+ try:
524
+ proc.terminate()
525
+ except OSError:
526
+ return
527
+ try:
528
+ proc.wait(timeout=5)
529
+ return
530
+ except subprocess.TimeoutExpired:
531
+ pass
532
+ try:
533
+ proc.kill()
534
+ except OSError:
535
+ return
536
+
537
+
538
+ def _tail_lines(text: str, max_lines: int) -> str:
539
+ lines = text.splitlines()
540
+ if len(lines) <= max_lines:
541
+ return text
542
+ return "\n".join(lines[-max_lines:])
543
+
544
+
545
+ # --- report builder + writer --------------------------------------------------
546
+
547
+
548
+ def _build_report(
549
+ runners: list[RunnerSpec],
550
+ results: list[RunnerResult],
551
+ project_root: Path,
552
+ *,
553
+ status_override: str | None = None,
554
+ ) -> dict[str, Any]:
555
+ exit_codes = {r.id: r.exit_code for r in results}
556
+ runtime_seconds = {r.id: r.runtime_seconds for r in results}
557
+ failure_tails = {r.id: r.failure_tail for r in results if r.failure_tail}
558
+ skipped = [
559
+ {"runner_id": r.id, "reason": r.skipped_reason}
560
+ for r in results
561
+ if r.status == "skipped"
562
+ ]
563
+
564
+ overall_status: str
565
+ if status_override is not None:
566
+ overall_status = status_override
567
+ elif any(r.status == "timeout" for r in results):
568
+ overall_status = "timeout"
569
+ elif all(r.status == "skipped" for r in results) and results:
570
+ overall_status = "no_runners"
571
+ elif all(r.status in ("passed", "skipped") for r in results):
572
+ overall_status = "passed"
573
+ elif all(r.status in ("no_tests", "skipped") for r in results):
574
+ overall_status = "no_tests"
575
+ elif any(r.status == "error" for r in results):
576
+ overall_status = "error"
577
+ else:
578
+ overall_status = "failed"
579
+
580
+ report: dict[str, Any] = {
581
+ "report_version": REPORT_VERSION,
582
+ "runners": [s.id for s in runners],
583
+ "executed_at": datetime.now(timezone.utc).isoformat(),
584
+ "exit_codes": exit_codes,
585
+ "status": overall_status,
586
+ "pass_summary": {
587
+ "total": 0,
588
+ "passed": 0,
589
+ "failed": 0,
590
+ "skipped": skipped,
591
+ },
592
+ "failure_tail": failure_tails,
593
+ "runtime_seconds": runtime_seconds,
594
+ "marker": _marker_signer().sign(),
595
+ }
596
+ logger.info(
597
+ "full_suite_report_built",
598
+ project_root=str(project_root),
599
+ status=overall_status,
600
+ runner_count=len(runners),
601
+ )
602
+ return report
603
+
604
+
605
+ def _write_report(project_root: Path, report: dict[str, Any]) -> None:
606
+ report_dir = project_root / REPORT_DIR_RELATIVE
607
+ report_dir.mkdir(parents=True, exist_ok=True)
608
+ final_path = report_dir / REPORT_FILENAME
609
+ tmp_path = report_dir / f"{REPORT_FILENAME}.tmp"
610
+ tmp_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
611
+ os.replace(tmp_path, final_path)
612
+
613
+
614
+ def _format_summary(report: dict[str, Any]) -> str:
615
+ status = report["status"]
616
+ runners = report["runners"]
617
+ exit_codes = report["exit_codes"]
618
+ lines = [
619
+ f"## Full-Suite Test Report — status: **{status}**",
620
+ f"Runners: {', '.join(runners) if runners else '(none detected)'}",
621
+ ]
622
+ if exit_codes:
623
+ for runner_id, code in exit_codes.items():
624
+ lines.append(f"- `{runner_id}` → exit {code}")
625
+ return "\n".join(lines)
626
+
627
+
628
+ # --- marker signer (process-local, tamper-resistant) --------------------------
629
+
630
+
631
+ class _MarkerSigner:
632
+ """Issues + validates per-process markers (FR-17, FR-18)."""
633
+
634
+ def __init__(self) -> None:
635
+ self._secret = secrets.token_bytes(32)
636
+ self._counter = 0
637
+ self._issued: set[str] = set()
638
+ self._lock = threading.Lock()
639
+
640
+ def sign(self) -> str:
641
+ with self._lock:
642
+ self._counter += 1
643
+ payload = (
644
+ str(os.getpid()).encode()
645
+ + str(time.monotonic_ns()).encode()
646
+ + str(self._counter).encode()
647
+ + self._secret
648
+ )
649
+ marker = hashlib.sha256(payload).hexdigest()[:32]
650
+ self._issued.add(marker)
651
+ return marker
652
+
653
+ def validate(self, marker: str) -> bool:
654
+ with self._lock:
655
+ return marker in self._issued
656
+
657
+ def reset_for_tests(self) -> None:
658
+ """Reset state — only intended for the pytest fixture."""
659
+ with self._lock:
660
+ self._secret = secrets.token_bytes(32)
661
+ self._counter = 0
662
+ self._issued.clear()
663
+
664
+
665
+ _SIGNER: _MarkerSigner | None = None
666
+ _SIGNER_LOCK = threading.Lock()
667
+
668
+
669
+ def _marker_signer() -> _MarkerSigner:
670
+ global _SIGNER
671
+ if _SIGNER is None:
672
+ with _SIGNER_LOCK:
673
+ if _SIGNER is None:
674
+ _SIGNER = _MarkerSigner()
675
+ return _SIGNER
676
+
677
+
678
+ def reset_marker_signer_for_tests() -> None:
679
+ """Reset the singleton — test-only entry point."""
680
+ signer = _marker_signer()
681
+ signer.reset_for_tests()
682
+
683
+
684
+ # --- gate helper consumed by transitions.py ----------------------------------
685
+
686
+
687
+ _REPORT_MAX_AGE_SECONDS: int = 7200 # 2 hours
688
+
689
+
690
+ @dataclass
691
+ class GateDecision:
692
+ passed: bool
693
+ reason: str
694
+ detail: dict[str, Any] = field(default_factory=dict)
695
+
696
+
697
+ def evaluate_gate(project_root: Path) -> GateDecision:
698
+ """Return a GateDecision based on the on-disk report (FR-20).
699
+
700
+ Honours marker validation, freshness, and exit-code semantics. The caller
701
+ layers on top of this any deferral / greenfield artifact bypass logic.
702
+ """
703
+ report_path = project_root / REPORT_DIR_RELATIVE / REPORT_FILENAME
704
+ if not report_path.exists():
705
+ return GateDecision(passed=False, reason="report_missing")
706
+ try:
707
+ age = time.time() - report_path.stat().st_mtime
708
+ except OSError as exc:
709
+ return GateDecision(
710
+ passed=False, reason="stat_failed", detail={"err": str(exc)}
711
+ )
712
+ if age > _REPORT_MAX_AGE_SECONDS:
713
+ return GateDecision(passed=False, reason="report_stale", detail={"age": age})
714
+ try:
715
+ with report_path.open("r", encoding="utf-8") as fh:
716
+ data = json.load(fh)
717
+ except (OSError, json.JSONDecodeError) as exc:
718
+ return GateDecision(
719
+ passed=False, reason="report_unreadable", detail={"err": str(exc)}
720
+ )
721
+
722
+ marker = data.get("marker")
723
+ if not isinstance(marker, str) or not _marker_signer().validate(marker):
724
+ return GateDecision(passed=False, reason="signature_invalid")
725
+
726
+ status = data.get("status")
727
+ exit_codes = data.get("exit_codes", {}) or {}
728
+ if status != "passed":
729
+ return GateDecision(
730
+ passed=False,
731
+ reason="status_not_passed",
732
+ detail={"status": status, "exit_codes": exit_codes},
733
+ )
734
+ if any(code != 0 for code in exit_codes.values()):
735
+ return GateDecision(
736
+ passed=False,
737
+ reason="non_zero_exit",
738
+ detail={"exit_codes": exit_codes},
739
+ )
740
+ return GateDecision(passed=True, reason="ok")