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,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)
|