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.
- ref_agents/__init__.py +9 -0
- ref_agents/api_keys.json.example +8 -0
- ref_agents/auth.py +129 -0
- ref_agents/codemap/..md +62 -0
- ref_agents/codemap/CODE_MAP.md +37 -0
- ref_agents/codemap/core.md +43 -0
- ref_agents/codemap/models.md +43 -0
- ref_agents/codemap/prompts.md +40 -0
- ref_agents/codemap/security.md +45 -0
- ref_agents/codemap/tools.md +94 -0
- ref_agents/codemap/tools_browser.md +44 -0
- ref_agents/codemap/utils.md +42 -0
- ref_agents/codemap/workflow.md +42 -0
- ref_agents/config/ai_patterns.yaml +101 -0
- ref_agents/config/frameworks/angular.yaml +104 -0
- ref_agents/config/frameworks/aspnet.yaml +84 -0
- ref_agents/config/frameworks/ef_core.yaml +81 -0
- ref_agents/config/frameworks/react.yaml +111 -0
- ref_agents/config/frameworks/spring_boot.yaml +117 -0
- ref_agents/config/languages/csharp.yaml +153 -0
- ref_agents/config/languages/java.yaml +188 -0
- ref_agents/config/languages/javascript.yaml +172 -0
- ref_agents/config/languages/python.yaml +153 -0
- ref_agents/config/languages/typescript.yaml +193 -0
- ref_agents/constants.py +553 -0
- ref_agents/core/__init__.py +15 -0
- ref_agents/core/config_loader.py +160 -0
- ref_agents/core/config_models.py +167 -0
- ref_agents/core/config_parsing.py +84 -0
- ref_agents/core/language_detector.py +388 -0
- ref_agents/core/validation_models.py +66 -0
- ref_agents/core/validation_primitives.py +176 -0
- ref_agents/errors.py +34 -0
- ref_agents/license_client.py +247 -0
- ref_agents/models/__init__.py +22 -0
- ref_agents/models/gherkin.py +45 -0
- ref_agents/models/hierarchy.py +80 -0
- ref_agents/models/invest.py +59 -0
- ref_agents/models/version.py +49 -0
- ref_agents/prompts/__init__.py +9 -0
- ref_agents/prompts/start_agent.py +772 -0
- ref_agents/rules/architecture/backend_patterns.md +43 -0
- ref_agents/rules/architecture/diagramming.md +100 -0
- ref_agents/rules/architecture/frontend_patterns.md +40 -0
- ref_agents/rules/architecture/impact_analysis.md +129 -0
- ref_agents/rules/architecture/migration_strategy.md +208 -0
- ref_agents/rules/architecture/regression_protocol.md +77 -0
- ref_agents/rules/architecture/system_design.md +97 -0
- ref_agents/rules/common/codemap_standard.md +97 -0
- ref_agents/rules/common/core_protocol.md +59 -0
- ref_agents/rules/common/prompt_engineering.md +294 -0
- ref_agents/rules/development/debugging.md +32 -0
- ref_agents/rules/development/implementation.md +205 -0
- ref_agents/rules/operations/completion.md +119 -0
- ref_agents/rules/operations/cutover_protocol.md +218 -0
- ref_agents/rules/operations/discovery.md +179 -0
- ref_agents/rules/operations/fix_workflow.md +87 -0
- ref_agents/rules/operations/forensics.md +278 -0
- ref_agents/rules/operations/platform.md +263 -0
- ref_agents/rules/operations/synchronous_flow.md +25 -0
- ref_agents/rules/product/ac_validation.md +25 -0
- ref_agents/rules/product/brainstorming.md +27 -0
- ref_agents/rules/product/ref_flow.md +101 -0
- ref_agents/rules/product/requirements_std.md +114 -0
- ref_agents/rules/product/spec_writing.md +235 -0
- ref_agents/rules/product/strategy.md +96 -0
- ref_agents/rules/quality/documentation_standards.md +46 -0
- ref_agents/rules/quality/parity_testing.md +234 -0
- ref_agents/rules/quality/project_documentation.md +56 -0
- ref_agents/rules/quality/qa_lead.md +111 -0
- ref_agents/rules/quality/test_design.md +146 -0
- ref_agents/rules/quality/testing_standards.md +293 -0
- ref_agents/rules/review/pr_review.md +116 -0
- ref_agents/rules/security/security_audit.md +83 -0
- ref_agents/security/__init__.py +22 -0
- ref_agents/security/dependency_audit.py +188 -0
- ref_agents/security/file_audit.py +208 -0
- ref_agents/security/network_scan.py +179 -0
- ref_agents/security/report_generator.py +313 -0
- ref_agents/security/secret_scan.py +252 -0
- ref_agents/security/url_scan.py +240 -0
- ref_agents/security_scan.py +236 -0
- ref_agents/server.py +1586 -0
- ref_agents/session.py +100 -0
- ref_agents/tool_names.py +55 -0
- ref_agents/tools/__init__.py +8 -0
- ref_agents/tools/agents_generator.py +315 -0
- ref_agents/tools/ai_pattern_detector.py +815 -0
- ref_agents/tools/brownfield_populator.py +529 -0
- ref_agents/tools/browser/__init__.py +50 -0
- ref_agents/tools/browser/evidence_verifier.py +302 -0
- ref_agents/tools/browser/execution_logger.py +249 -0
- ref_agents/tools/browser/playwright_mcp_client.py +259 -0
- ref_agents/tools/browser/screenshot_utils.py +184 -0
- ref_agents/tools/browser/test_executor.py +537 -0
- ref_agents/tools/code_quality_scanner.py +629 -0
- ref_agents/tools/codemap/..md +93 -0
- ref_agents/tools/codemap/CODE_MAP.md +30 -0
- ref_agents/tools/codemap/browser.md +44 -0
- ref_agents/tools/codemap.py +403 -0
- ref_agents/tools/codemap_freshness.py +234 -0
- ref_agents/tools/comment_smell_scanner.py +346 -0
- ref_agents/tools/complexity.py +436 -0
- ref_agents/tools/complexity_ast.py +333 -0
- ref_agents/tools/compliance.py +246 -0
- ref_agents/tools/compliance_remediation.py +846 -0
- ref_agents/tools/context_graph.py +839 -0
- ref_agents/tools/context_manager.py +550 -0
- ref_agents/tools/context_tools.py +121 -0
- ref_agents/tools/cross_repo_linker.py +393 -0
- ref_agents/tools/dead_code_scanner.py +637 -0
- ref_agents/tools/debt_scanner.py +1092 -0
- ref_agents/tools/dependency_graph.py +272 -0
- ref_agents/tools/discovery_audit.py +372 -0
- ref_agents/tools/docs_scanner.py +600 -0
- ref_agents/tools/evaluate_gate.py +119 -0
- ref_agents/tools/external_detector.py +524 -0
- ref_agents/tools/features_generator.py +282 -0
- ref_agents/tools/flow_gap_detector.py +373 -0
- ref_agents/tools/flow_mapper.py +327 -0
- ref_agents/tools/full_suite_runner.py +740 -0
- ref_agents/tools/gherkin_parser.py +227 -0
- ref_agents/tools/guard_tools.py +139 -0
- ref_agents/tools/handoff_tools.py +282 -0
- ref_agents/tools/health_scanner.py +1211 -0
- ref_agents/tools/hierarchy_manager.py +289 -0
- ref_agents/tools/invest_scorer.py +249 -0
- ref_agents/tools/jira_confluence_export.py +306 -0
- ref_agents/tools/json_output.py +76 -0
- ref_agents/tools/migration_mapper.py +946 -0
- ref_agents/tools/migration_readiness_scanner.py +209 -0
- ref_agents/tools/pattern_learner.py +522 -0
- ref_agents/tools/report_utils.py +155 -0
- ref_agents/tools/requirements_serializer.py +225 -0
- ref_agents/tools/security_audit_tool.py +106 -0
- ref_agents/tools/sequencing_engine.py +288 -0
- ref_agents/tools/summary_generator.py +275 -0
- ref_agents/tools/symbol_resolver.py +306 -0
- ref_agents/tools/symbol_smoke_runner.py +336 -0
- ref_agents/tools/test_plan_validator.py +189 -0
- ref_agents/tools/test_smell_walker.py +902 -0
- ref_agents/tools/tier1_fixer.py +502 -0
- ref_agents/tools/validators/__init__.py +419 -0
- ref_agents/tools/validators/architect.py +268 -0
- ref_agents/tools/validators/cutover_engineer.py +167 -0
- ref_agents/tools/validators/developer.py +180 -0
- ref_agents/tools/validators/discovery.py +150 -0
- ref_agents/tools/validators/forensic_engineer.py +191 -0
- ref_agents/tools/validators/impact_architect.py +181 -0
- ref_agents/tools/validators/migration_planner.py +166 -0
- ref_agents/tools/validators/parity_tester.py +155 -0
- ref_agents/tools/validators/platform_engineer.py +134 -0
- ref_agents/tools/validators/pr_reviewer.py +129 -0
- ref_agents/tools/validators/product_manager.py +291 -0
- ref_agents/tools/validators/qa_lead.py +172 -0
- ref_agents/tools/validators/scrum_master.py +212 -0
- ref_agents/tools/validators/security_owner.py +162 -0
- ref_agents/tools/validators/specifier.py +134 -0
- ref_agents/tools/validators/strategist.py +149 -0
- ref_agents/tools/validators/tester.py +121 -0
- ref_agents/tools/version_manager.py +202 -0
- ref_agents/tools/workflow_tools.py +1549 -0
- ref_agents/utils/__init__.py +21 -0
- ref_agents/utils/git_utils.py +351 -0
- ref_agents/utils/handoff_logger.py +368 -0
- ref_agents/utils/ignore_matcher.py +270 -0
- ref_agents/workflow/__init__.py +19 -0
- ref_agents/workflow/capabilities.py +328 -0
- ref_agents/workflow/state_machine.py +708 -0
- ref_agents/workflow/transitions.py +658 -0
- ref_agents-1.0.0.dist-info/METADATA +365 -0
- ref_agents-1.0.0.dist-info/RECORD +175 -0
- ref_agents-1.0.0.dist-info/WHEEL +4 -0
- ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
- 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")
|