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,129 @@
1
+ """PR Reviewer output validator — validate_review_output.
2
+
3
+ Checks PR review doc structure (3 stages), verdict presence, finding
4
+ citation discipline, and APPROVE/L3 consistency.
5
+
6
+ See SPEC-STORY-026.
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
+ STAGE_HEADERS = ("STAGE 0", "STAGE 1", "STAGE 2")
27
+ APPROVE = "APPROVE"
28
+ REQUEST_CHANGES = "REQUEST_CHANGES"
29
+
30
+
31
+ def _resolve_under_root(candidate: Path) -> Path | None:
32
+ root = SessionManager.get().project_root
33
+ candidate = candidate.resolve()
34
+ if root is not None:
35
+ try:
36
+ candidate.relative_to(root.resolve())
37
+ except ValueError:
38
+ return None
39
+ return candidate if candidate.exists() else None
40
+
41
+
42
+ def _resolve_review(artifact_id: str, artifact_path: str) -> Path | None:
43
+ if artifact_path:
44
+ return _resolve_under_root(Path(artifact_path))
45
+ root = SessionManager.get().project_root
46
+ if artifact_id and root is not None:
47
+ return _resolve_under_root(
48
+ root / "docs" / "analysis" / f"PR-REVIEW-{artifact_id}.md"
49
+ )
50
+ return None
51
+
52
+
53
+ def _extract_verdict(text: str) -> str | None:
54
+ match = re.search(
55
+ r"verdict[:*\s_]*\*{0,2}(APPROVE|REQUEST[_ ]CHANGES)\*{0,2}",
56
+ text,
57
+ flags=re.IGNORECASE,
58
+ )
59
+ if not match:
60
+ return None
61
+ raw = match.group(1).upper().replace(" ", "_")
62
+ return raw
63
+
64
+
65
+ @register_validator("pr_reviewer")
66
+ def validate_review_output(
67
+ artifact_id: str = "", artifact_path: str = ""
68
+ ) -> ValidationReport:
69
+ log = logger.bind(role="pr_reviewer", artifact_id=artifact_id)
70
+
71
+ path = _resolve_review(artifact_id, artifact_path)
72
+ if path is None:
73
+ return ValidationReport(
74
+ verdict=Verdict.NEEDS_USER,
75
+ gaps=[
76
+ Gap(code="pr_review_unresolved", detail=artifact_id or artifact_path)
77
+ ],
78
+ )
79
+
80
+ text = path.read_text(encoding="utf-8")
81
+ gaps: list[Gap] = []
82
+
83
+ # FR-1: stage headers
84
+ for stage in STAGE_HEADERS:
85
+ if not re.search(rf"^#+\s*.*{re.escape(stage)}", text, flags=re.MULTILINE):
86
+ gaps.append(
87
+ Gap(
88
+ code=f"pr_stage_missing:{stage.replace(' ', '_')}",
89
+ detail=f"Missing required header for {stage}.",
90
+ )
91
+ )
92
+
93
+ # FR-2: verdict
94
+ verdict = _extract_verdict(text)
95
+ if verdict is None:
96
+ gaps.append(Gap(code="pr_verdict_missing", detail="No verdict line found."))
97
+
98
+ # FR-3: REQUEST_CHANGES requires path:line citations
99
+ if verdict == REQUEST_CHANGES:
100
+ citations = re.findall(r"[\w./\\-]+\.\w+:\d+", text)
101
+ if not citations:
102
+ gaps.append(
103
+ Gap(
104
+ code="pr_finding_no_citation",
105
+ detail="Verdict REQUEST_CHANGES but no file:line citations found.",
106
+ )
107
+ )
108
+
109
+ # FR-4: APPROVE forbidden with L3/critical finding
110
+ if verdict == APPROVE:
111
+ if re.search(r"severity\s*[:\-]\s*critical", text, flags=re.IGNORECASE) or (
112
+ re.search(r"\bL3\b", text)
113
+ and re.search(r"\b(FAIL|BLOCK|UNRESOLVED)\b", text, flags=re.IGNORECASE)
114
+ ):
115
+ gaps.append(
116
+ Gap(
117
+ code="pr_approve_with_l3",
118
+ detail="Verdict APPROVE but L3 / Critical finding present.",
119
+ )
120
+ )
121
+
122
+ out_verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
123
+ log.info(
124
+ "pr_validate_complete",
125
+ verdict=out_verdict.value,
126
+ gap_count=len(gaps),
127
+ pr_verdict=verdict,
128
+ )
129
+ return ValidationReport(verdict=out_verdict, gaps=gaps)
@@ -0,0 +1,291 @@
1
+ """Product Manager output validator — validate_story_quality.
2
+
3
+ Reads STORY-{id}.md, extracts user story + acceptance criteria,
4
+ scores via INVEST, applies AC count / format / measurability / edge-case /
5
+ E2E-section checks, returns a ValidationReport. Registered under role
6
+ "product_manager".
7
+
8
+ See SPEC-STORY-022 / DESIGN-STORY-022.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+
16
+ import structlog
17
+
18
+ from ref_agents.core.validation_models import (
19
+ Gap,
20
+ UserQuestion,
21
+ ValidationReport,
22
+ Verdict,
23
+ )
24
+ from ref_agents.session import SessionManager
25
+ from ref_agents.tools.invest_scorer import score_invest_principles
26
+ from ref_agents.tools.validators import register_validator
27
+
28
+ logger = structlog.get_logger(__name__)
29
+
30
+ VAGUE_PHRASES = (
31
+ "should work",
32
+ "works well",
33
+ "nice",
34
+ " fast ",
35
+ "easy",
36
+ "intuitive",
37
+ "user-friendly",
38
+ )
39
+ FRONTEND_KEYWORDS = (
40
+ "ui",
41
+ "route",
42
+ "component",
43
+ "screen",
44
+ "page",
45
+ "form",
46
+ "button",
47
+ "frontend",
48
+ "fullstack",
49
+ )
50
+ INVEST_FLOOR = 3.0
51
+ INVEST_PASS = 4.0
52
+
53
+
54
+ def _resolve_path(artifact_id: str, artifact_path: str) -> Path | None:
55
+ """Resolve story path safely under project root.
56
+
57
+ Args:
58
+ artifact_id: Story ID (e.g., "STORY-021").
59
+ artifact_path: Explicit path or empty.
60
+
61
+ Returns:
62
+ Resolved Path under project root, or None if traversal/missing.
63
+ """
64
+ root = SessionManager.get().project_root
65
+ if artifact_path:
66
+ candidate = Path(artifact_path).resolve()
67
+ elif artifact_id and root is not None:
68
+ candidate = (root / "docs" / "requirements" / f"{artifact_id}.md").resolve()
69
+ else:
70
+ return None
71
+
72
+ if root is not None:
73
+ try:
74
+ candidate.relative_to(root.resolve())
75
+ except ValueError:
76
+ return None
77
+
78
+ if not candidate.exists():
79
+ return None
80
+ return candidate
81
+
82
+
83
+ def _parse_story(path: Path) -> dict[str, object]:
84
+ """Extract user_story, ACs, and presence flags from a story markdown file.
85
+
86
+ Args:
87
+ path: Story markdown file.
88
+
89
+ Returns:
90
+ Dict with keys: user_story (str), acs (list[str]),
91
+ has_edge_cases (bool), has_e2e (bool), full_text (str).
92
+ """
93
+ text = path.read_text(encoding="utf-8")
94
+
95
+ # User Story section: between '## User Story' and the next '## '
96
+ us_match = re.search(
97
+ r"##\s+User Story\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
98
+ )
99
+ user_story = us_match.group(1).strip() if us_match else ""
100
+
101
+ # Acceptance Criteria block(s)
102
+ ac_blocks = re.findall(
103
+ r"##\s+Acceptance Criteria\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
104
+ )
105
+ ac_body = "\n".join(ac_blocks)
106
+
107
+ # AC patterns: "**AC1:** ..." or "AC1: ..." or "- AC1: ..."
108
+ acs = re.findall(
109
+ r"(?:^|\n)\s*[-*]?\s*\*{0,2}AC\d+\*{0,2}\s*[:\.\-]?\s*(.+?)(?=\n\s*[-*]?\s*\*{0,2}AC\d+\*{0,2}\s*[:\.\-]|\n##\s|\Z)",
110
+ ac_body,
111
+ flags=re.DOTALL,
112
+ )
113
+ acs = [a.strip() for a in acs if a.strip()]
114
+
115
+ has_edge_cases = bool(
116
+ re.search(r"##\s+Edge Cases\s*\n+\s*[-*0-9]", text, flags=re.IGNORECASE)
117
+ )
118
+ has_e2e = bool(
119
+ re.search(r"##\s+E2E Testing Requirements", text, flags=re.IGNORECASE)
120
+ )
121
+
122
+ return {
123
+ "user_story": user_story,
124
+ "acs": acs,
125
+ "has_edge_cases": has_edge_cases,
126
+ "has_e2e": has_e2e,
127
+ "full_text": text,
128
+ }
129
+
130
+
131
+ def _ac_has_given_when_then(ac: str) -> bool:
132
+ lower = ac.lower()
133
+ return "given" in lower and "when" in lower and "then" in lower
134
+
135
+
136
+ def _detect_frontend(user_story: str, full_text: str) -> bool:
137
+ combined = (user_story + " " + full_text).lower()
138
+ return any(kw in combined for kw in FRONTEND_KEYWORDS)
139
+
140
+
141
+ def _find_vague(ac: str) -> list[str]:
142
+ lower = ac.lower()
143
+ return [phrase.strip() for phrase in VAGUE_PHRASES if phrase in lower]
144
+
145
+
146
+ @register_validator("product_manager")
147
+ def validate_story_quality(
148
+ artifact_id: str = "", artifact_path: str = ""
149
+ ) -> ValidationReport:
150
+ """Validate a Product Manager story artifact.
151
+
152
+ Args:
153
+ artifact_id: Story identifier (e.g., "STORY-021"). Resolves to
154
+ `<project_root>/docs/requirements/<artifact_id>.md` when
155
+ `artifact_path` not supplied.
156
+ artifact_path: Explicit story file path.
157
+
158
+ Returns:
159
+ ValidationReport per SPEC-STORY-022.
160
+ """
161
+ log = logger.bind(role="product_manager", artifact_id=artifact_id)
162
+
163
+ path = _resolve_path(artifact_id, artifact_path)
164
+ if path is None:
165
+ return ValidationReport(
166
+ verdict=Verdict.NEEDS_USER,
167
+ gaps=[
168
+ Gap(code="story_path_unresolved", detail=artifact_id or artifact_path)
169
+ ],
170
+ user_questions=[
171
+ UserQuestion(
172
+ text=f"Provide a path to the story file for {artifact_id or '?'}."
173
+ )
174
+ ],
175
+ )
176
+
177
+ parsed = _parse_story(path)
178
+ acs: list[str] = parsed["acs"] # type: ignore[assignment]
179
+ user_story: str = parsed["user_story"] # type: ignore[assignment]
180
+ has_edge_cases: bool = parsed["has_edge_cases"] # type: ignore[assignment]
181
+ has_e2e: bool = parsed["has_e2e"] # type: ignore[assignment]
182
+ full_text: str = parsed["full_text"] # type: ignore[assignment]
183
+
184
+ gaps: list[Gap] = []
185
+ user_questions: list[UserQuestion] = []
186
+
187
+ # FR-2: AC count
188
+ if len(acs) < 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
189
+ gaps.append(
190
+ Gap(code="ac_count_lt_3", detail=f"Found {len(acs)} ACs; require ≥3.")
191
+ )
192
+
193
+ # FR-3: Given/When/Then
194
+ for idx, ac in enumerate(acs, start=1):
195
+ if not _ac_has_given_when_then(ac):
196
+ gaps.append(
197
+ Gap(
198
+ code=f"ac_format_invalid:{idx}",
199
+ detail="Missing Given/When/Then structure.",
200
+ location=f"AC{idx}",
201
+ )
202
+ )
203
+
204
+ # FR-4: vague phrases → NEEDS_USER
205
+ for idx, ac in enumerate(acs, start=1):
206
+ vague = _find_vague(ac)
207
+ if vague:
208
+ user_questions.append(
209
+ UserQuestion(
210
+ text=(
211
+ f"AC{idx} uses vague phrasing ({', '.join(vague)}). "
212
+ "Provide a measurable assertion."
213
+ )
214
+ )
215
+ )
216
+
217
+ # FR-6: Edge Cases
218
+ if not has_edge_cases:
219
+ gaps.append(
220
+ Gap(
221
+ code="edge_cases_missing",
222
+ detail="No populated '## Edge Cases' section found.",
223
+ )
224
+ )
225
+
226
+ # FR-7: Frontend → E2E required
227
+ if _detect_frontend(user_story, full_text) and not has_e2e:
228
+ gaps.append(
229
+ Gap(
230
+ code="e2e_section_missing",
231
+ detail="Frontend tag detected but no '## E2E Testing Requirements' section.",
232
+ )
233
+ )
234
+
235
+ # FR-5: INVEST scoring
236
+ invest_avg: float | None = None
237
+ if user_story or acs:
238
+ try:
239
+ scores = score_invest_principles(
240
+ user_story=user_story, acceptance_criteria=acs
241
+ )
242
+ score_values = [
243
+ scores.independent,
244
+ scores.negotiable,
245
+ scores.valuable,
246
+ scores.estimable,
247
+ scores.small,
248
+ scores.testable,
249
+ ]
250
+ invest_avg = sum(score_values) / len(score_values)
251
+ except Exception as exc: # noqa: BLE001 - scorer is best-effort
252
+ log.warning("invest_score_failed", error=str(exc))
253
+
254
+ # Verdict resolution
255
+ if gaps:
256
+ verdict = Verdict.FAIL_AUTO
257
+ elif user_questions or (invest_avg is not None and invest_avg < INVEST_PASS):
258
+ verdict = Verdict.NEEDS_USER
259
+ if invest_avg is not None and invest_avg < INVEST_FLOOR:
260
+ verdict = Verdict.FAIL_AUTO
261
+ gaps.append(
262
+ Gap(
263
+ code="invest_below_floor",
264
+ detail=f"INVEST avg {invest_avg:.2f} below floor {INVEST_FLOOR}.",
265
+ )
266
+ )
267
+ elif invest_avg is not None and invest_avg < INVEST_PASS:
268
+ user_questions.append(
269
+ UserQuestion(
270
+ text=(
271
+ f"INVEST average {invest_avg:.2f} (target ≥{INVEST_PASS}). "
272
+ "Improve weakest dimensions before proceeding."
273
+ )
274
+ )
275
+ )
276
+ else:
277
+ verdict = Verdict.PASS
278
+
279
+ log.info(
280
+ "pm_validate_complete",
281
+ verdict=verdict.value,
282
+ invest_avg=invest_avg,
283
+ ac_count=len(acs),
284
+ gap_count=len(gaps),
285
+ )
286
+ return ValidationReport(
287
+ verdict=verdict,
288
+ score=invest_avg,
289
+ gaps=gaps,
290
+ user_questions=user_questions,
291
+ )
@@ -0,0 +1,172 @@
1
+ """QA Lead output validator — validate_ac_review.
2
+
3
+ Reads QA Lead AC validation report markdown; cross-checks FR coverage vs
4
+ SPEC, INVEST score completeness, verdict enum, and verdict-INVEST
5
+ consistency. Registered under role "qa_lead".
6
+
7
+ See SPEC-STORY-024 / DESIGN-STORY-024.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from pathlib import Path
14
+
15
+ import structlog
16
+
17
+ from ref_agents.core.validation_models import (
18
+ Gap,
19
+ ValidationReport,
20
+ Verdict,
21
+ )
22
+ from ref_agents.session import SessionManager
23
+ from ref_agents.tools.validators import register_validator
24
+
25
+ logger = structlog.get_logger(__name__)
26
+
27
+ INVEST_DIMS = (
28
+ "independent",
29
+ "negotiable",
30
+ "valuable",
31
+ "estimable",
32
+ "small",
33
+ "testable",
34
+ )
35
+ VERDICT_LITERALS = ("APPROVED", "NEEDS_REVISION", "BLOCKED")
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_report_path(artifact_id: str, artifact_path: str) -> Path | None:
50
+ root = SessionManager.get().project_root
51
+ if artifact_path:
52
+ return _resolve_under_root(Path(artifact_path))
53
+ if artifact_id and root is not None:
54
+ return _resolve_under_root(root / "docs" / "analysis" / f"QA-{artifact_id}.md")
55
+ return None
56
+
57
+
58
+ def _resolve_spec_path(artifact_id: str) -> Path | None:
59
+ root = SessionManager.get().project_root
60
+ if artifact_id and root is not None:
61
+ return _resolve_under_root(root / "docs" / "specs" / f"SPEC-{artifact_id}.md")
62
+ return None
63
+
64
+
65
+ def _extract_fr_ids(text: str) -> set[str]:
66
+ return set(re.findall(r"FR-\d+", text))
67
+
68
+
69
+ def _extract_invest_scores(text: str) -> dict[str, int | None]:
70
+ scores: dict[str, int | None] = {dim: None for dim in INVEST_DIMS}
71
+ lower = text.lower()
72
+ for dim in INVEST_DIMS:
73
+ match = re.search(rf"\|\s*{dim}\b\s*\|\s*(\d)", lower) or re.search(
74
+ rf"{dim}\s*[:=]\s*(\d)", lower
75
+ )
76
+ if match:
77
+ scores[dim] = int(match.group(1))
78
+ return scores
79
+
80
+
81
+ def _extract_verdict(text: str) -> str | None:
82
+ match = re.search(
83
+ r"verdict\s*[:*\s_]*\s*\*{0,2}(APPROVED|NEEDS_REVISION|BLOCKED)\*{0,2}",
84
+ text,
85
+ flags=re.IGNORECASE,
86
+ )
87
+ if match:
88
+ return match.group(1).upper()
89
+ return None
90
+
91
+
92
+ @register_validator("qa_lead")
93
+ def validate_ac_review(
94
+ artifact_id: str = "", artifact_path: str = ""
95
+ ) -> ValidationReport:
96
+ """Validate a QA Lead AC validation report."""
97
+ log = logger.bind(role="qa_lead", artifact_id=artifact_id)
98
+
99
+ report_path = _resolve_report_path(artifact_id, artifact_path)
100
+ if report_path is None:
101
+ return ValidationReport(
102
+ verdict=Verdict.NEEDS_USER,
103
+ gaps=[
104
+ Gap(code="qa_report_unresolved", detail=artifact_id or artifact_path)
105
+ ],
106
+ )
107
+
108
+ text = report_path.read_text(encoding="utf-8")
109
+ gaps: list[Gap] = []
110
+
111
+ # FR-2: FR coverage vs SPEC
112
+ spec_path = _resolve_spec_path(artifact_id)
113
+ if spec_path is not None:
114
+ spec_text = spec_path.read_text(encoding="utf-8")
115
+ spec_frs = _extract_fr_ids(spec_text)
116
+ report_frs = _extract_fr_ids(text)
117
+ missing = sorted(spec_frs - report_frs)
118
+ for fr in missing:
119
+ gaps.append(
120
+ Gap(
121
+ code=f"fr_not_reviewed:{fr}",
122
+ detail=f"{fr} present in SPEC but not cited in QA report.",
123
+ location=fr,
124
+ )
125
+ )
126
+
127
+ # FR-3: INVEST scores present + numeric 1-5
128
+ scores = _extract_invest_scores(text)
129
+ for dim, score in scores.items():
130
+ if score is None:
131
+ gaps.append(
132
+ Gap(
133
+ code=f"invest_missing:{dim}",
134
+ detail=f"INVEST dimension '{dim}' missing or non-numeric.",
135
+ )
136
+ )
137
+ elif not 1 <= score <= 5: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
138
+ gaps.append(
139
+ Gap(
140
+ code=f"invest_out_of_range:{dim}",
141
+ detail=f"INVEST '{dim}' = {score} (must be 1-5).",
142
+ )
143
+ )
144
+
145
+ # FR-4: Verdict
146
+ verdict = _extract_verdict(text)
147
+ if verdict is None:
148
+ gaps.append(Gap(code="verdict_missing", detail="No verdict found in report."))
149
+
150
+ # FR-5: Consistency
151
+ if verdict == "APPROVED":
152
+ low_dims = [
153
+ dim
154
+ for dim, score in scores.items()
155
+ if score is not None and score < 3 # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
156
+ ]
157
+ if low_dims:
158
+ gaps.append(
159
+ Gap(
160
+ code="verdict_inconsistent",
161
+ detail=(f"Verdict APPROVED but INVEST dim(s) {low_dims} below 3."),
162
+ )
163
+ )
164
+
165
+ out_verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
166
+ log.info(
167
+ "qa_validate_complete",
168
+ verdict=out_verdict.value,
169
+ gap_count=len(gaps),
170
+ qa_verdict=verdict,
171
+ )
172
+ return ValidationReport(verdict=out_verdict, gaps=gaps)