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,181 @@
1
+ """Impact Architect output validator — validate_impact_reconciliation.
2
+
3
+ Reconciles the CHANGE doc's "Direct Files Touched" with actual git diff and
4
+ checks risk-level → gate-decision consistency.
5
+
6
+ See SPEC-STORY-028.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ ValidationReport,
19
+ Verdict,
20
+ )
21
+ from ref_agents.session import SessionManager
22
+ from ref_agents.tools.validators import register_validator
23
+ from ref_agents.utils.git_utils import get_changed_files, is_git_repo
24
+
25
+ logger = structlog.get_logger(__name__)
26
+
27
+ RISK_LEVELS = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
28
+ RISK_TO_GATE = {
29
+ "LOW": "APPROVED",
30
+ "MEDIUM": "APPROVED",
31
+ "HIGH": "REQUIRES_USER_APPROVAL",
32
+ "CRITICAL": "L3_HALT",
33
+ }
34
+ SCAN_PREFIXES = ("src/", "tests/")
35
+ EXCLUDED_PREFIXES = ("codemap/", "docs/", "ref-reports/", ".ref_cache/", "ref_reports/")
36
+
37
+
38
+ def _resolve_under_root(candidate: Path) -> Path | None:
39
+ root = SessionManager.get().project_root
40
+ candidate = candidate.resolve()
41
+ if root is not None:
42
+ try:
43
+ candidate.relative_to(root.resolve())
44
+ except ValueError:
45
+ return None
46
+ return candidate if candidate.exists() else None
47
+
48
+
49
+ def _resolve_change(artifact_id: str, artifact_path: str) -> Path | None:
50
+ if artifact_path:
51
+ return _resolve_under_root(Path(artifact_path))
52
+ root = SessionManager.get().project_root
53
+ if artifact_id and root is not None:
54
+ return _resolve_under_root(
55
+ root / "docs" / "analysis" / f"CHANGE-{artifact_id}.md"
56
+ )
57
+ return None
58
+
59
+
60
+ def _extract_listed_files(text: str) -> list[str]:
61
+ """Extract file paths listed under '## Direct Files Touched'."""
62
+ match = re.search(
63
+ r"##\s+Direct Files Touched\s*\n+(.*?)(?=\n##\s|\Z)",
64
+ text,
65
+ flags=re.DOTALL,
66
+ )
67
+ if not match:
68
+ return []
69
+ block = match.group(1)
70
+ files: list[str] = []
71
+ for line in block.splitlines():
72
+ stripped = line.strip().lstrip("-*0123456789. ")
73
+ # Strip backticks / inline bold markers
74
+ stripped = stripped.strip("`*_")
75
+ if not stripped:
76
+ continue
77
+ if "/" in stripped or stripped.endswith(".py") or stripped.endswith(".md"):
78
+ # Pick first whitespace-delimited token (path)
79
+ token = stripped.split()[0].rstrip(":,;")
80
+ files.append(token)
81
+ return files
82
+
83
+
84
+ def _extract_risk(text: str) -> str | None:
85
+ block = re.search(
86
+ r"##\s+(?:Regression\s+)?Risk(?:\s+Assessment|\s+Score)?\s*\n+(.*?)(?=\n##\s|\Z)",
87
+ text,
88
+ flags=re.DOTALL,
89
+ )
90
+ body = block.group(1).upper() if block else text.upper()
91
+ for level in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
92
+ if re.search(rf"\bRISK[\s:_-]*\*{{0,2}}{level}\b", body) or re.search(
93
+ rf"\b{level}\b", body
94
+ ):
95
+ return level
96
+ return None
97
+
98
+
99
+ def _extract_gate(text: str) -> str | None:
100
+ match = re.search(
101
+ r"gate\s+decision[:*\s_]*\*{0,2}(APPROVED|REQUIRES_USER_APPROVAL|L3_HALT)",
102
+ text,
103
+ flags=re.IGNORECASE,
104
+ )
105
+ if match:
106
+ return match.group(1).upper()
107
+ return None
108
+
109
+
110
+ @register_validator("impact_architect")
111
+ def validate_impact_reconciliation(
112
+ artifact_id: str = "", artifact_path: str = ""
113
+ ) -> ValidationReport:
114
+ log = logger.bind(role="impact_architect", artifact_id=artifact_id)
115
+
116
+ change_path = _resolve_change(artifact_id, artifact_path)
117
+ if change_path is None:
118
+ return ValidationReport(
119
+ verdict=Verdict.NEEDS_USER,
120
+ gaps=[
121
+ Gap(code="change_doc_unresolved", detail=artifact_id or artifact_path)
122
+ ],
123
+ )
124
+
125
+ text = change_path.read_text(encoding="utf-8")
126
+ gaps: list[Gap] = []
127
+
128
+ # FR-2: Listed files
129
+ listed = _extract_listed_files(text)
130
+ if not listed:
131
+ gaps.append(
132
+ Gap(
133
+ code="impact_files_empty",
134
+ detail="'Direct Files Touched' empty/missing.",
135
+ )
136
+ )
137
+
138
+ # FR-3: Reconciliation
139
+ root = SessionManager.get().project_root
140
+ if root is not None and is_git_repo(root):
141
+ changed = [str(p).replace("\\", "/") for p in get_changed_files(directory=root)]
142
+ for c in changed:
143
+ if any(c.startswith(p) for p in EXCLUDED_PREFIXES):
144
+ continue
145
+ if not any(c.startswith(p) for p in SCAN_PREFIXES):
146
+ continue
147
+ if not any(c in entry or entry in c for entry in listed):
148
+ gaps.append(
149
+ Gap(
150
+ code=f"impact_underpredicted:{c}",
151
+ detail=f"{c} changed but not in 'Direct Files Touched'.",
152
+ location=c,
153
+ )
154
+ )
155
+
156
+ # FR-4 + FR-5: Risk + gate consistency
157
+ risk = _extract_risk(text)
158
+ gate = _extract_gate(text)
159
+ if risk is None:
160
+ gaps.append(
161
+ Gap(code="risk_missing", detail="No risk level found in CHANGE doc.")
162
+ )
163
+ elif gate is not None:
164
+ expected = RISK_TO_GATE[risk]
165
+ if gate != expected:
166
+ gaps.append(
167
+ Gap(
168
+ code="gate_inconsistent",
169
+ detail=f"Risk={risk} expects gate={expected}; doc has gate={gate}.",
170
+ )
171
+ )
172
+
173
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
174
+ log.info(
175
+ "impact_validate_complete",
176
+ verdict=verdict.value,
177
+ gap_count=len(gaps),
178
+ risk=risk,
179
+ gate=gate,
180
+ )
181
+ return ValidationReport(verdict=verdict, gaps=gaps)
@@ -0,0 +1,166 @@
1
+ """Migration Planner output validator — validate_migration_plan.
2
+
3
+ Checks strategy enum, batch DAG validity, feature-flag naming, and
4
+ rollback completeness.
5
+
6
+ See SPEC-STORY-036.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ ValidationReport,
19
+ Verdict,
20
+ )
21
+ from ref_agents.core.validation_primitives import dag_validator
22
+ from ref_agents.errors import CycleDetectedError
23
+ from ref_agents.session import SessionManager
24
+ from ref_agents.tools.validators import register_validator
25
+
26
+ logger = structlog.get_logger(__name__)
27
+
28
+ STRATEGIES = ("Strangler Fig", "Parallel Run", "Big Bang")
29
+ FLAG_REGEX = re.compile(r"^ff-[a-z0-9][a-z0-9\-]*-v\d+$")
30
+
31
+
32
+ def _resolve_under_root(candidate: Path) -> Path | None:
33
+ root = SessionManager.get().project_root
34
+ candidate = candidate.resolve()
35
+ if root is not None:
36
+ try:
37
+ candidate.relative_to(root.resolve())
38
+ except ValueError:
39
+ return None
40
+ return candidate if candidate.exists() else None
41
+
42
+
43
+ def _resolve_plan(artifact_path: str) -> Path | None:
44
+ if artifact_path:
45
+ return _resolve_under_root(Path(artifact_path))
46
+ root = SessionManager.get().project_root
47
+ if root is not None:
48
+ return _resolve_under_root(root / "docs" / "MIGRATION_PLAN.md")
49
+ return None
50
+
51
+
52
+ def _detect_strategy(text: str) -> str | None:
53
+ for s in STRATEGIES:
54
+ if re.search(rf"\b{re.escape(s)}\b", text, flags=re.IGNORECASE):
55
+ return s
56
+ return None
57
+
58
+
59
+ def _parse_batches(text: str) -> tuple[list[str], list[tuple[str, str]]]:
60
+ """Return (batch_ids, prereq_edges) from a markdown table.
61
+
62
+ Expects table rows with first cell = batch id (Batch 0, B-1, etc.) and a
63
+ cell containing prerequisite batch ids comma-separated.
64
+ """
65
+ nodes: list[str] = []
66
+ edges: list[tuple[str, str]] = []
67
+ block = re.search(
68
+ r"##\s+Batch(?:\s+Schedule)?\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
69
+ )
70
+ if not block:
71
+ return nodes, edges
72
+
73
+ header_seen = False
74
+ for line in block.group(1).splitlines():
75
+ if not line.startswith("|"):
76
+ continue
77
+ cells = [c.strip() for c in line.strip("|").split("|")]
78
+ if re.match(r"-+", cells[0]):
79
+ continue
80
+ if not header_seen:
81
+ header_seen = True
82
+ continue
83
+ batch_id = cells[0]
84
+ if not batch_id:
85
+ continue
86
+ nodes.append(batch_id)
87
+ if len(cells) >= 4: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
88
+ prereq_cell = cells[-1] or cells[3]
89
+ for prereq in re.split(r"[,;]", prereq_cell):
90
+ prereq = prereq.strip()
91
+ if prereq and prereq.lower() not in ("none", "n/a", "-"):
92
+ edges.append((prereq, batch_id))
93
+ return nodes, edges
94
+
95
+
96
+ def _extract_flag_names(text: str) -> list[str]:
97
+ return re.findall(r"\bff-[\w][\w\-]*\b", text, flags=re.IGNORECASE)
98
+
99
+
100
+ def _has_rollback_protocol(text: str) -> bool:
101
+ return bool(re.search(r"##\s+Rollback Protocol", text, flags=re.IGNORECASE))
102
+
103
+
104
+ @register_validator("migration_planner")
105
+ def validate_migration_plan(
106
+ artifact_id: str = "", artifact_path: str = ""
107
+ ) -> ValidationReport:
108
+ log = logger.bind(role="migration_planner", artifact_id=artifact_id)
109
+
110
+ path = _resolve_plan(artifact_path)
111
+ if path is None:
112
+ return ValidationReport(
113
+ verdict=Verdict.NEEDS_USER,
114
+ gaps=[
115
+ Gap(code="migration_plan_unresolved", detail=artifact_path or "default")
116
+ ],
117
+ )
118
+
119
+ text = path.read_text(encoding="utf-8")
120
+ gaps: list[Gap] = []
121
+
122
+ if _detect_strategy(text) is None:
123
+ gaps.append(
124
+ Gap(
125
+ code="strategy_invalid",
126
+ detail=f"No strategy from {STRATEGIES} found.",
127
+ )
128
+ )
129
+
130
+ nodes, edges = _parse_batches(text)
131
+ if not nodes:
132
+ gaps.append(Gap(code="batches_empty", detail="No batches parsed."))
133
+ else:
134
+ try:
135
+ dag_validator(nodes, edges)
136
+ except CycleDetectedError as exc:
137
+ gaps.append(
138
+ Gap(
139
+ code="batch_cycle",
140
+ detail=f"Cycle in batch prerequisites: {' -> '.join(exc.path)}",
141
+ )
142
+ )
143
+
144
+ flags = _extract_flag_names(text)
145
+ for flag in flags:
146
+ if not FLAG_REGEX.match(flag):
147
+ gaps.append(
148
+ Gap(
149
+ code=f"flag_name_invalid:{flag}",
150
+ detail=f"Flag '{flag}' does not match ff-<kebab>-v<n>.",
151
+ )
152
+ )
153
+
154
+ if not _has_rollback_protocol(text):
155
+ gaps.append(
156
+ Gap(code="rollback_missing", detail="No 'Rollback Protocol' section.")
157
+ )
158
+
159
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
160
+ log.info(
161
+ "migration_planner_validate_complete",
162
+ verdict=verdict.value,
163
+ gap_count=len(gaps),
164
+ batches=len(nodes),
165
+ )
166
+ return ValidationReport(verdict=verdict, gaps=gaps)
@@ -0,0 +1,155 @@
1
+ """Parity Tester output validator — validate_parity_artifacts.
2
+
3
+ Checks golden master symbol coverage, ≥2 input categories per symbol, and
4
+ verdict ↔ divergence consistency.
5
+
6
+ See SPEC-STORY-037.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ import structlog
15
+
16
+ from ref_agents.core.validation_models import (
17
+ Gap,
18
+ ValidationReport,
19
+ Verdict,
20
+ )
21
+ from ref_agents.session import SessionManager
22
+ from ref_agents.tools.validators import register_validator
23
+
24
+ logger = structlog.get_logger(__name__)
25
+
26
+ INPUT_CATEGORIES = ("Happy Path", "Boundary", "Error Path", "Side Effects")
27
+
28
+
29
+ def _resolve_under_root(candidate: Path) -> Path | None:
30
+ root = SessionManager.get().project_root
31
+ candidate = candidate.resolve()
32
+ if root is not None:
33
+ try:
34
+ candidate.relative_to(root.resolve())
35
+ except ValueError:
36
+ return None
37
+ return candidate if candidate.exists() else None
38
+
39
+
40
+ def _resolve_gate(batch_id: str, artifact_path: str) -> Path | None:
41
+ if artifact_path:
42
+ return _resolve_under_root(Path(artifact_path))
43
+ root = SessionManager.get().project_root
44
+ if batch_id and root is not None:
45
+ return _resolve_under_root(
46
+ root / "docs" / "parity" / f"PARITY-{batch_id}-gate.md"
47
+ )
48
+ return None
49
+
50
+
51
+ def _resolve_golden(batch_id: str) -> Path | None:
52
+ root = SessionManager.get().project_root
53
+ if batch_id and root is not None:
54
+ return _resolve_under_root(
55
+ root / "docs" / "parity" / f"PARITY-{batch_id}-golden.md"
56
+ )
57
+ return None
58
+
59
+
60
+ def _extract_verdict(text: str) -> str | None:
61
+ match = re.search(
62
+ r"(?:Gate\s+)?Verdict[:*\s_]*\*{0,2}(PASS|FAIL)\*{0,2}",
63
+ text,
64
+ flags=re.IGNORECASE,
65
+ )
66
+ if match:
67
+ return match.group(1).upper()
68
+ return None
69
+
70
+
71
+ def _golden_symbols_and_categories(text: str) -> dict[str, set[str]]:
72
+ """Parse golden contracts table: first col symbol, subsequent col category."""
73
+ result: dict[str, set[str]] = {}
74
+ header_seen = False
75
+ headers: list[str] = []
76
+ for line in text.splitlines():
77
+ if not line.startswith("|"):
78
+ continue
79
+ cells = [c.strip() for c in line.strip("|").split("|")]
80
+ if re.match(r"-+", cells[0]):
81
+ continue
82
+ if not header_seen:
83
+ headers = [h.lower() for h in cells]
84
+ header_seen = True
85
+ continue
86
+ symbol = cells[0]
87
+ if not symbol:
88
+ continue
89
+ cats = result.setdefault(symbol, set())
90
+ for idx, value in enumerate(cells[1:], start=1):
91
+ header = headers[idx] if idx < len(headers) else ""
92
+ for cat in INPUT_CATEGORIES:
93
+ if cat.lower() in header or cat.lower() in value.lower():
94
+ cats.add(cat)
95
+ return result
96
+
97
+
98
+ def _has_unacceptable_divergence(text: str) -> bool:
99
+ return bool(re.search(r"unacceptable\s+divergence", text, flags=re.IGNORECASE))
100
+
101
+
102
+ @register_validator("parity_tester")
103
+ def validate_parity_artifacts(
104
+ artifact_id: str = "", artifact_path: str = ""
105
+ ) -> ValidationReport:
106
+ """Validate parity gate report; artifact_id should be the batch_id."""
107
+ log = logger.bind(role="parity_tester", artifact_id=artifact_id)
108
+
109
+ gate_path = _resolve_gate(artifact_id, artifact_path)
110
+ if gate_path is None:
111
+ return ValidationReport(
112
+ verdict=Verdict.NEEDS_USER,
113
+ gaps=[
114
+ Gap(code="parity_gate_unresolved", detail=artifact_id or artifact_path)
115
+ ],
116
+ )
117
+
118
+ gate_text = gate_path.read_text(encoding="utf-8")
119
+ gaps: list[Gap] = []
120
+
121
+ verdict = _extract_verdict(gate_text)
122
+ if verdict is None:
123
+ gaps.append(Gap(code="verdict_missing", detail="No PASS/FAIL verdict."))
124
+ elif verdict == "PASS" and _has_unacceptable_divergence(gate_text):
125
+ gaps.append(
126
+ Gap(
127
+ code="verdict_inconsistent",
128
+ detail="Verdict PASS forbidden with unacceptable divergence present.",
129
+ )
130
+ )
131
+
132
+ golden_path = _resolve_golden(artifact_id)
133
+ if golden_path is None:
134
+ gaps.append(Gap(code="golden_missing", detail="Golden master file missing."))
135
+ else:
136
+ golden = _golden_symbols_and_categories(golden_path.read_text(encoding="utf-8"))
137
+ if not golden:
138
+ gaps.append(Gap(code="golden_empty", detail="No symbols in golden master."))
139
+ for symbol, cats in golden.items():
140
+ if len(cats) < 2: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
141
+ gaps.append(
142
+ Gap(
143
+ code=f"symbol_input_cats_lt_2:{symbol}",
144
+ detail=f"Symbol '{symbol}' has {len(cats)} input categories; need ≥2.",
145
+ )
146
+ )
147
+
148
+ out_verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
149
+ log.info(
150
+ "parity_validate_complete",
151
+ verdict=out_verdict.value,
152
+ gap_count=len(gaps),
153
+ gate_verdict=verdict,
154
+ )
155
+ return ValidationReport(verdict=out_verdict, gaps=gaps)
@@ -0,0 +1,134 @@
1
+ """Platform Engineer output validator — health report freshness.
2
+
3
+ Validates ref-reports/health_report.json has a recent `generated_at`
4
+ timestamp and required summary structure. HMAC signing is intentionally
5
+ deferred to backend infrastructure; this validator catches replay /
6
+ staleness via timestamp.
7
+
8
+ See SPEC-STORY-033.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import structlog
18
+
19
+ from ref_agents.core.validation_models import (
20
+ Gap,
21
+ ValidationReport,
22
+ Verdict,
23
+ )
24
+ from ref_agents.session import SessionManager
25
+ from ref_agents.tools.validators import register_validator
26
+
27
+ logger = structlog.get_logger(__name__)
28
+
29
+ DEFAULT_MAX_AGE_SECONDS = 2 * 60 * 60 # 2 hours
30
+
31
+
32
+ def _resolve_under_root(candidate: Path) -> Path | None:
33
+ root = SessionManager.get().project_root
34
+ candidate = candidate.resolve()
35
+ if root is not None:
36
+ try:
37
+ candidate.relative_to(root.resolve())
38
+ except ValueError:
39
+ return None
40
+ return candidate if candidate.exists() else None
41
+
42
+
43
+ def _resolve_report(artifact_path: str) -> Path | None:
44
+ if artifact_path:
45
+ return _resolve_under_root(Path(artifact_path))
46
+ root = SessionManager.get().project_root
47
+ if root is not None:
48
+ return _resolve_under_root(root / "ref-reports" / "health_report.json")
49
+ return None
50
+
51
+
52
+ def _parse_iso8601(value: str) -> datetime | None:
53
+ try:
54
+ # Accept both naive and Z-suffix
55
+ normalized = value.replace("Z", "+00:00")
56
+ return datetime.fromisoformat(normalized)
57
+ except ValueError:
58
+ return None
59
+
60
+
61
+ @register_validator("platform_engineer")
62
+ def validate_health_freshness(
63
+ artifact_id: str = "", artifact_path: str = ""
64
+ ) -> ValidationReport:
65
+ """Validate ref-reports/health_report.json freshness + schema."""
66
+ log = logger.bind(role="platform_engineer", artifact_id=artifact_id)
67
+
68
+ path = _resolve_report(artifact_path)
69
+ if path is None:
70
+ return ValidationReport(
71
+ verdict=Verdict.NEEDS_USER,
72
+ gaps=[
73
+ Gap(code="health_report_unresolved", detail=artifact_path or "default")
74
+ ],
75
+ )
76
+
77
+ try:
78
+ data = json.loads(path.read_text(encoding="utf-8"))
79
+ except (OSError, json.JSONDecodeError) as exc:
80
+ return ValidationReport(
81
+ verdict=Verdict.FAIL_AUTO,
82
+ gaps=[Gap(code="health_report_unreadable", detail=str(exc))],
83
+ )
84
+
85
+ gaps: list[Gap] = []
86
+
87
+ generated_at = data.get("generated_at") if isinstance(data, dict) else None
88
+ if not generated_at:
89
+ gaps.append(
90
+ Gap(
91
+ code="generated_at_missing",
92
+ detail="health_report.json missing 'generated_at' timestamp.",
93
+ )
94
+ )
95
+ else:
96
+ ts = _parse_iso8601(str(generated_at))
97
+ if ts is None:
98
+ gaps.append(
99
+ Gap(
100
+ code="generated_at_unparseable",
101
+ detail=f"Could not parse '{generated_at}' as ISO8601.",
102
+ )
103
+ )
104
+ else:
105
+ now = datetime.now(timezone.utc)
106
+ if ts.tzinfo is None:
107
+ ts = ts.replace(tzinfo=timezone.utc)
108
+ age = (now - ts).total_seconds()
109
+ if age > DEFAULT_MAX_AGE_SECONDS:
110
+ gaps.append(
111
+ Gap(
112
+ code="report_stale",
113
+ detail=(
114
+ f"health_report.json is {int(age)}s old "
115
+ f"(max {DEFAULT_MAX_AGE_SECONDS}s)."
116
+ ),
117
+ )
118
+ )
119
+
120
+ if isinstance(data, dict) and "summary" not in data:
121
+ gaps.append(
122
+ Gap(
123
+ code="summary_missing",
124
+ detail="health_report.json missing 'summary' object.",
125
+ )
126
+ )
127
+
128
+ verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
129
+ log.info(
130
+ "platform_validate_complete",
131
+ verdict=verdict.value,
132
+ gap_count=len(gaps),
133
+ )
134
+ return ValidationReport(verdict=verdict, gaps=gaps)