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,212 @@
|
|
|
1
|
+
"""Scrum Master closure validator — validate_closure.
|
|
2
|
+
|
|
3
|
+
Verifies that a story's closure preconditions are met:
|
|
4
|
+
- All required phase rows present + ✅ in closure doc
|
|
5
|
+
- Codemap freshness vs story creation
|
|
6
|
+
- handoff_log.jsonl has phase entries
|
|
7
|
+
|
|
8
|
+
Registered under role "scrum_master".
|
|
9
|
+
|
|
10
|
+
See SPEC-STORY-025 / DESIGN-STORY-025.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
from ref_agents.core.validation_models import (
|
|
21
|
+
Gap,
|
|
22
|
+
ValidationReport,
|
|
23
|
+
Verdict,
|
|
24
|
+
)
|
|
25
|
+
from ref_agents.session import SessionManager
|
|
26
|
+
from ref_agents.tools.validators import register_validator
|
|
27
|
+
|
|
28
|
+
logger = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
REQUIRED_PHASES = (
|
|
31
|
+
"requirements",
|
|
32
|
+
"qa_lead",
|
|
33
|
+
"specifier",
|
|
34
|
+
"architect",
|
|
35
|
+
"developer",
|
|
36
|
+
"pr_reviewer",
|
|
37
|
+
"tester",
|
|
38
|
+
"security_owner",
|
|
39
|
+
"scrum_master",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Map closure-doc display rows to canonical phase keys
|
|
43
|
+
ROW_ALIASES = {
|
|
44
|
+
"requirements": "requirements",
|
|
45
|
+
"qa gate": "qa_lead",
|
|
46
|
+
"qa lead": "qa_lead",
|
|
47
|
+
"specification": "specifier",
|
|
48
|
+
"spec": "specifier",
|
|
49
|
+
"design": "architect",
|
|
50
|
+
"implementation": "developer",
|
|
51
|
+
"review": "pr_reviewer",
|
|
52
|
+
"testing": "tester",
|
|
53
|
+
"security": "security_owner",
|
|
54
|
+
"scrum master": "scrum_master",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_under_root(candidate: Path) -> Path | None:
|
|
59
|
+
root = SessionManager.get().project_root
|
|
60
|
+
candidate = candidate.resolve()
|
|
61
|
+
if root is not None:
|
|
62
|
+
try:
|
|
63
|
+
candidate.relative_to(root.resolve())
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
return candidate if candidate.exists() else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_closure(artifact_id: str, artifact_path: str) -> Path | None:
|
|
70
|
+
if artifact_path:
|
|
71
|
+
return _resolve_under_root(Path(artifact_path))
|
|
72
|
+
root = SessionManager.get().project_root
|
|
73
|
+
if artifact_id and root is not None:
|
|
74
|
+
return _resolve_under_root(
|
|
75
|
+
root / "docs" / "closure" / f"{artifact_id}-CLOSURE.md"
|
|
76
|
+
)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _phase_status_from_closure(text: str) -> dict[str, str]:
|
|
81
|
+
"""Parse closure doc table rows → {phase_key: marker}.
|
|
82
|
+
|
|
83
|
+
Marker is the first emoji-like token in the status cell.
|
|
84
|
+
"""
|
|
85
|
+
statuses: dict[str, str] = {}
|
|
86
|
+
for line in text.splitlines():
|
|
87
|
+
if not line.startswith("|"):
|
|
88
|
+
continue
|
|
89
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
90
|
+
if len(cells) < 2: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
91
|
+
continue
|
|
92
|
+
first = cells[0].lower().strip()
|
|
93
|
+
for alias, canonical in ROW_ALIASES.items():
|
|
94
|
+
if alias in first:
|
|
95
|
+
statuses[canonical] = cells[1].strip()
|
|
96
|
+
break
|
|
97
|
+
return statuses
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _codemap_max_mtime(root: Path) -> float:
|
|
101
|
+
codemap_dir = root / "codemap"
|
|
102
|
+
if not codemap_dir.exists():
|
|
103
|
+
return 0.0
|
|
104
|
+
mtimes = [p.stat().st_mtime for p in codemap_dir.rglob("*.md")]
|
|
105
|
+
return max(mtimes) if mtimes else 0.0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _story_creation_mtime(root: Path, artifact_id: str) -> float | None:
|
|
109
|
+
story_path = root / "docs" / "requirements" / f"{artifact_id}.md"
|
|
110
|
+
if not story_path.exists():
|
|
111
|
+
return None
|
|
112
|
+
return story_path.stat().st_mtime
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _handoff_log_phases(root: Path, artifact_id: str) -> set[str]:
|
|
116
|
+
"""Return set of phase keys logged for this story in handoff_log.jsonl."""
|
|
117
|
+
log_path = root / "ref-reports" / "handoff_log.jsonl"
|
|
118
|
+
if not log_path.exists():
|
|
119
|
+
return set()
|
|
120
|
+
phases: set[str] = set()
|
|
121
|
+
with log_path.open("r", encoding="utf-8") as f:
|
|
122
|
+
for line in f:
|
|
123
|
+
line = line.strip()
|
|
124
|
+
if not line:
|
|
125
|
+
continue
|
|
126
|
+
try:
|
|
127
|
+
entry = json.loads(line)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
continue
|
|
130
|
+
if entry.get("story_id") != artifact_id:
|
|
131
|
+
continue
|
|
132
|
+
if entry.get("event") == "phase":
|
|
133
|
+
phase = entry.get("phase")
|
|
134
|
+
if isinstance(phase, str):
|
|
135
|
+
phases.add(phase)
|
|
136
|
+
return phases
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@register_validator("scrum_master")
|
|
140
|
+
def validate_closure(
|
|
141
|
+
artifact_id: str = "", artifact_path: str = ""
|
|
142
|
+
) -> ValidationReport:
|
|
143
|
+
"""Validate a story closure document and surrounding state."""
|
|
144
|
+
log = logger.bind(role="scrum_master", artifact_id=artifact_id)
|
|
145
|
+
|
|
146
|
+
closure_path = _resolve_closure(artifact_id, artifact_path)
|
|
147
|
+
if closure_path is None:
|
|
148
|
+
return ValidationReport(
|
|
149
|
+
verdict=Verdict.NEEDS_USER,
|
|
150
|
+
gaps=[
|
|
151
|
+
Gap(code="closure_doc_unresolved", detail=artifact_id or artifact_path)
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
text = closure_path.read_text(encoding="utf-8")
|
|
156
|
+
statuses = _phase_status_from_closure(text)
|
|
157
|
+
gaps: list[Gap] = []
|
|
158
|
+
|
|
159
|
+
# FR-1 + FR-2: phase presence + marker
|
|
160
|
+
for phase in REQUIRED_PHASES:
|
|
161
|
+
marker = statuses.get(phase)
|
|
162
|
+
if marker is None:
|
|
163
|
+
gaps.append(
|
|
164
|
+
Gap(
|
|
165
|
+
code=f"closure_phase_missing:{phase}",
|
|
166
|
+
detail=f"No row for phase '{phase}' in closure doc.",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
if "✅" not in marker:
|
|
171
|
+
gaps.append(
|
|
172
|
+
Gap(
|
|
173
|
+
code=f"closure_phase_not_passed:{phase}",
|
|
174
|
+
detail=f"Phase '{phase}' marker is '{marker}', expected ✅.",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
root = SessionManager.get().project_root
|
|
179
|
+
if root is not None:
|
|
180
|
+
# FR-3: codemap freshness
|
|
181
|
+
story_mtime = _story_creation_mtime(root, artifact_id)
|
|
182
|
+
if story_mtime is not None:
|
|
183
|
+
codemap_mtime = _codemap_max_mtime(root)
|
|
184
|
+
if codemap_mtime < story_mtime:
|
|
185
|
+
gaps.append(
|
|
186
|
+
Gap(
|
|
187
|
+
code="codemap_stale",
|
|
188
|
+
detail=(
|
|
189
|
+
f"Codemap last update ({codemap_mtime}) is older "
|
|
190
|
+
f"than story creation ({story_mtime})."
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# FR-4: handoff log phase entries
|
|
196
|
+
logged = _handoff_log_phases(root, artifact_id)
|
|
197
|
+
for phase in REQUIRED_PHASES:
|
|
198
|
+
if phase not in logged and phase != "requirements":
|
|
199
|
+
# 'requirements' often logged as 'product_manager'
|
|
200
|
+
if phase == "scrum_master" and "scrum_master" in logged:
|
|
201
|
+
continue
|
|
202
|
+
if phase not in logged:
|
|
203
|
+
gaps.append(
|
|
204
|
+
Gap(
|
|
205
|
+
code=f"handoff_log_incomplete:{phase}",
|
|
206
|
+
detail=(f"No 'phase' event for '{phase}' in handoff_log."),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
|
|
211
|
+
log.info("scrum_validate_complete", verdict=verdict.value, gap_count=len(gaps))
|
|
212
|
+
return ValidationReport(verdict=verdict, gaps=gaps)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Security Owner output validator — validate_security_report.
|
|
2
|
+
|
|
3
|
+
Checks finding table schema, severity ↔ verdict consistency, and the
|
|
4
|
+
"CRITICAL forces HALT" invariant.
|
|
5
|
+
|
|
6
|
+
See SPEC-STORY-031.
|
|
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
|
+
SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW")
|
|
27
|
+
VERDICTS = ("PASS", "CONDITIONAL", "FAIL", "HALT")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_under_root(candidate: Path) -> Path | None:
|
|
31
|
+
root = SessionManager.get().project_root
|
|
32
|
+
candidate = candidate.resolve()
|
|
33
|
+
if root is not None:
|
|
34
|
+
try:
|
|
35
|
+
candidate.relative_to(root.resolve())
|
|
36
|
+
except ValueError:
|
|
37
|
+
return None
|
|
38
|
+
return candidate if candidate.exists() else None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_security_report(artifact_id: str, artifact_path: str) -> Path | None:
|
|
42
|
+
if artifact_path:
|
|
43
|
+
return _resolve_under_root(Path(artifact_path))
|
|
44
|
+
root = SessionManager.get().project_root
|
|
45
|
+
if artifact_id and root is not None:
|
|
46
|
+
return _resolve_under_root(
|
|
47
|
+
root / "docs" / "analysis" / f"SECURITY-{artifact_id}.md"
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_verdict(text: str) -> str | None:
|
|
53
|
+
match = re.search(
|
|
54
|
+
r"verdict[:*\s_]*\*{0,2}(PASS|CONDITIONAL|FAIL|HALT)\*{0,2}",
|
|
55
|
+
text,
|
|
56
|
+
flags=re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
if match:
|
|
59
|
+
return match.group(1).upper()
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _count_findings_by_severity(text: str) -> dict[str, int]:
|
|
64
|
+
"""Count findings per severity by scanning table rows."""
|
|
65
|
+
counts: dict[str, int] = {s: 0 for s in SEVERITIES}
|
|
66
|
+
for line in text.splitlines():
|
|
67
|
+
if not line.startswith("|"):
|
|
68
|
+
continue
|
|
69
|
+
cells = [c.strip() for c in line.strip("|").split("|")]
|
|
70
|
+
for cell in cells:
|
|
71
|
+
up = cell.upper().strip("*_` ")
|
|
72
|
+
if up in SEVERITIES:
|
|
73
|
+
counts[up] += 1
|
|
74
|
+
return counts
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _extract_summary_counts(text: str) -> dict[str, int] | None:
|
|
78
|
+
"""Parse 'Critical: N | High: N | Medium: N | Low: N' line if present."""
|
|
79
|
+
match = re.search(
|
|
80
|
+
r"Critical[:\s]+(\d+).*?High[:\s]+(\d+).*?Medium[:\s]+(\d+).*?Low[:\s]+(\d+)",
|
|
81
|
+
text,
|
|
82
|
+
flags=re.IGNORECASE | re.DOTALL,
|
|
83
|
+
)
|
|
84
|
+
if not match:
|
|
85
|
+
return None
|
|
86
|
+
return {
|
|
87
|
+
"CRITICAL": int(match.group(1)),
|
|
88
|
+
"HIGH": int(match.group(2)),
|
|
89
|
+
"MEDIUM": int(match.group(3)),
|
|
90
|
+
"LOW": int(match.group(4)),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register_validator("security_owner")
|
|
95
|
+
def validate_security_report(
|
|
96
|
+
artifact_id: str = "", artifact_path: str = ""
|
|
97
|
+
) -> ValidationReport:
|
|
98
|
+
log = logger.bind(role="security_owner", artifact_id=artifact_id)
|
|
99
|
+
|
|
100
|
+
path = _resolve_security_report(artifact_id, artifact_path)
|
|
101
|
+
if path is None:
|
|
102
|
+
return ValidationReport(
|
|
103
|
+
verdict=Verdict.NEEDS_USER,
|
|
104
|
+
gaps=[
|
|
105
|
+
Gap(
|
|
106
|
+
code="security_report_unresolved",
|
|
107
|
+
detail=artifact_id or artifact_path,
|
|
108
|
+
)
|
|
109
|
+
],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
text = path.read_text(encoding="utf-8")
|
|
113
|
+
gaps: list[Gap] = []
|
|
114
|
+
|
|
115
|
+
verdict = _extract_verdict(text)
|
|
116
|
+
if verdict is None:
|
|
117
|
+
gaps.append(Gap(code="verdict_missing", detail="No verdict found."))
|
|
118
|
+
|
|
119
|
+
summary = _extract_summary_counts(text)
|
|
120
|
+
table_counts = _count_findings_by_severity(text)
|
|
121
|
+
|
|
122
|
+
# Count consistency (when both present)
|
|
123
|
+
if summary is not None:
|
|
124
|
+
for sev in SEVERITIES:
|
|
125
|
+
if summary[sev] != table_counts[sev]:
|
|
126
|
+
gaps.append(
|
|
127
|
+
Gap(
|
|
128
|
+
code=f"count_mismatch:{sev}",
|
|
129
|
+
detail=(
|
|
130
|
+
f"Summary {sev}={summary[sev]} but table has "
|
|
131
|
+
f"{table_counts[sev]} matching rows."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
critical = (summary or table_counts)["CRITICAL"]
|
|
137
|
+
high = (summary or table_counts)["HIGH"]
|
|
138
|
+
|
|
139
|
+
# Severity → verdict invariants
|
|
140
|
+
if verdict == "PASS" and (critical > 0 or high > 0):
|
|
141
|
+
gaps.append(
|
|
142
|
+
Gap(
|
|
143
|
+
code="verdict_inconsistent",
|
|
144
|
+
detail=f"Verdict PASS forbidden with Critical={critical}, High={high}.",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
if critical > 0 and verdict != "HALT":
|
|
148
|
+
gaps.append(
|
|
149
|
+
Gap(
|
|
150
|
+
code="critical_requires_halt",
|
|
151
|
+
detail=f"Critical={critical} forces verdict HALT (got {verdict}).",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
out = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
|
|
156
|
+
log.info(
|
|
157
|
+
"security_validate_complete",
|
|
158
|
+
verdict=out.value,
|
|
159
|
+
gap_count=len(gaps),
|
|
160
|
+
report_verdict=verdict,
|
|
161
|
+
)
|
|
162
|
+
return ValidationReport(verdict=out, gaps=gaps)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Specifier output validator — validate_spec.
|
|
2
|
+
|
|
3
|
+
Promotes Phase 2.5 self-review to tool. Rejects placeholders, code fences
|
|
4
|
+
outside whitelist, and surfaces open questions for user resolution.
|
|
5
|
+
|
|
6
|
+
See SPEC-STORY-029.
|
|
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
|
+
UserQuestion,
|
|
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
|
+
FORBIDDEN_TOKENS = (
|
|
28
|
+
r"\[TBD\]",
|
|
29
|
+
r"\[TODO\]",
|
|
30
|
+
r"\[PLACEHOLDER\]",
|
|
31
|
+
r"\bFIXME\b",
|
|
32
|
+
)
|
|
33
|
+
CODE_FENCE_WHITELIST = ("mermaid",)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _resolve_under_root(candidate: Path) -> Path | None:
|
|
37
|
+
root = SessionManager.get().project_root
|
|
38
|
+
candidate = candidate.resolve()
|
|
39
|
+
if root is not None:
|
|
40
|
+
try:
|
|
41
|
+
candidate.relative_to(root.resolve())
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
return candidate if candidate.exists() else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_spec(artifact_id: str, artifact_path: str) -> Path | None:
|
|
48
|
+
if artifact_path:
|
|
49
|
+
return _resolve_under_root(Path(artifact_path))
|
|
50
|
+
root = SessionManager.get().project_root
|
|
51
|
+
if artifact_id and root is not None:
|
|
52
|
+
return _resolve_under_root(root / "docs" / "specs" / f"SPEC-{artifact_id}.md")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _disallowed_code_fences(text: str) -> list[int]:
|
|
57
|
+
"""Return line numbers where disallowed code fences open."""
|
|
58
|
+
bad: list[int] = []
|
|
59
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
60
|
+
match = re.match(r"^```(\w+)?", line)
|
|
61
|
+
if (
|
|
62
|
+
match
|
|
63
|
+
and match.group(1)
|
|
64
|
+
and match.group(1).lower() not in CODE_FENCE_WHITELIST
|
|
65
|
+
):
|
|
66
|
+
bad.append(idx)
|
|
67
|
+
return bad
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _open_questions(text: str) -> list[str]:
|
|
71
|
+
block = re.search(
|
|
72
|
+
r"##\s+Open Questions\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
|
|
73
|
+
)
|
|
74
|
+
if not block:
|
|
75
|
+
return []
|
|
76
|
+
body = block.group(1).strip()
|
|
77
|
+
if not body or re.fullmatch(r"(?i)(none\.?|n/a\.?)", body.strip()):
|
|
78
|
+
return []
|
|
79
|
+
questions: list[str] = []
|
|
80
|
+
for line in body.splitlines():
|
|
81
|
+
stripped = line.strip(" -*0123456789.")
|
|
82
|
+
if stripped and len(stripped) > 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
83
|
+
questions.append(stripped)
|
|
84
|
+
return questions
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@register_validator("specifier")
|
|
88
|
+
def validate_spec(artifact_id: str = "", artifact_path: str = "") -> ValidationReport:
|
|
89
|
+
log = logger.bind(role="specifier", artifact_id=artifact_id)
|
|
90
|
+
|
|
91
|
+
path = _resolve_spec(artifact_id, artifact_path)
|
|
92
|
+
if path is None:
|
|
93
|
+
return ValidationReport(
|
|
94
|
+
verdict=Verdict.NEEDS_USER,
|
|
95
|
+
gaps=[Gap(code="spec_unresolved", detail=artifact_id or artifact_path)],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
text = path.read_text(encoding="utf-8")
|
|
99
|
+
gaps: list[Gap] = []
|
|
100
|
+
user_questions: list[UserQuestion] = []
|
|
101
|
+
|
|
102
|
+
for pattern in FORBIDDEN_TOKENS:
|
|
103
|
+
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
104
|
+
if re.search(pattern, line):
|
|
105
|
+
gaps.append(
|
|
106
|
+
Gap(
|
|
107
|
+
code=f"forbidden_token:{pattern}",
|
|
108
|
+
detail=f"Forbidden token at line {line_no}.",
|
|
109
|
+
location=f"line {line_no}",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
bad_fences = _disallowed_code_fences(text)
|
|
114
|
+
for line_no in bad_fences:
|
|
115
|
+
gaps.append(
|
|
116
|
+
Gap(
|
|
117
|
+
code="code_fence_in_spec",
|
|
118
|
+
detail="Code fence outside whitelist (only ```mermaid allowed).",
|
|
119
|
+
location=f"line {line_no}",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for q in _open_questions(text):
|
|
124
|
+
user_questions.append(UserQuestion(text=f"Open question to resolve: {q}"))
|
|
125
|
+
|
|
126
|
+
if gaps:
|
|
127
|
+
verdict = Verdict.FAIL_AUTO
|
|
128
|
+
elif user_questions:
|
|
129
|
+
verdict = Verdict.NEEDS_USER
|
|
130
|
+
else:
|
|
131
|
+
verdict = Verdict.PASS
|
|
132
|
+
|
|
133
|
+
log.info("specifier_validate_complete", verdict=verdict.value, gap_count=len(gaps))
|
|
134
|
+
return ValidationReport(verdict=verdict, gaps=gaps, user_questions=user_questions)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Strategist output validator — validate_strategy.
|
|
2
|
+
|
|
3
|
+
Checks ≥3 production-grade options, complete trade-off matrix, weighted
|
|
4
|
+
score reproducibility, and absence of quick-fix labels.
|
|
5
|
+
|
|
6
|
+
See SPEC-STORY-035.
|
|
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
|
+
REQUIRED_DIMS = (
|
|
27
|
+
"scalability",
|
|
28
|
+
"maintainability",
|
|
29
|
+
"architectural soundness",
|
|
30
|
+
"risk",
|
|
31
|
+
"operational impact",
|
|
32
|
+
)
|
|
33
|
+
FORBIDDEN_LABELS = (
|
|
34
|
+
r"\bquick[ -]?fix\b",
|
|
35
|
+
r"\btemporary\b",
|
|
36
|
+
r"\bworkaround\b",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_under_root(candidate: Path) -> Path | None:
|
|
41
|
+
root = SessionManager.get().project_root
|
|
42
|
+
candidate = candidate.resolve()
|
|
43
|
+
if root is not None:
|
|
44
|
+
try:
|
|
45
|
+
candidate.relative_to(root.resolve())
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
return candidate if candidate.exists() else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _resolve_strategy(artifact_id: str, artifact_path: str) -> Path | None:
|
|
52
|
+
if artifact_path:
|
|
53
|
+
return _resolve_under_root(Path(artifact_path))
|
|
54
|
+
root = SessionManager.get().project_root
|
|
55
|
+
if artifact_id and root is not None:
|
|
56
|
+
return _resolve_under_root(root / "ref-reports" / f"strategy-{artifact_id}.md")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _count_options(text: str) -> int:
|
|
61
|
+
# Match "Option A", "Option B" etc., or "### A", "### B" or numbered options
|
|
62
|
+
labels = set()
|
|
63
|
+
for m in re.finditer(r"(?:^|\n)#+\s*Option\s+([A-Z0-9])\b", text):
|
|
64
|
+
labels.add(m.group(1).upper())
|
|
65
|
+
for m in re.finditer(r"(?:^|\n)#+\s+([A-Z])\b\s*[:\.\-]", text):
|
|
66
|
+
labels.add(m.group(1).upper())
|
|
67
|
+
return len(labels)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _missing_dims(text: str) -> list[str]:
|
|
71
|
+
lower = text.lower()
|
|
72
|
+
return [d for d in REQUIRED_DIMS if d not in lower]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _forbidden_labels_found(text: str) -> list[str]:
|
|
76
|
+
found: list[str] = []
|
|
77
|
+
for pattern in FORBIDDEN_LABELS:
|
|
78
|
+
if re.search(pattern, text, flags=re.IGNORECASE):
|
|
79
|
+
found.append(pattern)
|
|
80
|
+
return found
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _has_recommendation_reference(text: str) -> bool:
|
|
84
|
+
"""Recommendation section should reference an option label (A/B/C)."""
|
|
85
|
+
rec_block = re.search(
|
|
86
|
+
r"##\s+Recommendation\s*\n+(.*?)(?=\n##\s|\Z)", text, flags=re.DOTALL
|
|
87
|
+
)
|
|
88
|
+
if not rec_block:
|
|
89
|
+
return False
|
|
90
|
+
body = rec_block.group(1)
|
|
91
|
+
return bool(re.search(r"\bOption\s+[A-Z]\b|\b[A-Z]\b\s+is\s+the\s+best", body))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@register_validator("strategist")
|
|
95
|
+
def validate_strategy(
|
|
96
|
+
artifact_id: str = "", artifact_path: str = ""
|
|
97
|
+
) -> ValidationReport:
|
|
98
|
+
log = logger.bind(role="strategist", artifact_id=artifact_id)
|
|
99
|
+
|
|
100
|
+
path = _resolve_strategy(artifact_id, artifact_path)
|
|
101
|
+
if path is None:
|
|
102
|
+
return ValidationReport(
|
|
103
|
+
verdict=Verdict.NEEDS_USER,
|
|
104
|
+
gaps=[Gap(code="strategy_unresolved", detail=artifact_id or artifact_path)],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
text = path.read_text(encoding="utf-8")
|
|
108
|
+
gaps: list[Gap] = []
|
|
109
|
+
|
|
110
|
+
n_options = _count_options(text)
|
|
111
|
+
if n_options < 3: # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
|
|
112
|
+
gaps.append(
|
|
113
|
+
Gap(
|
|
114
|
+
code="options_lt_3",
|
|
115
|
+
detail=f"Found {n_options} option labels; require ≥3.",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for dim in _missing_dims(text):
|
|
120
|
+
gaps.append(
|
|
121
|
+
Gap(
|
|
122
|
+
code=f"tradeoff_dim_missing:{dim}", detail=f"Missing dimension '{dim}'."
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
for token in _forbidden_labels_found(text):
|
|
127
|
+
gaps.append(
|
|
128
|
+
Gap(
|
|
129
|
+
code=f"non_production_option:{token}",
|
|
130
|
+
detail=f"Forbidden production-grade label pattern '{token}' found.",
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not _has_recommendation_reference(text):
|
|
135
|
+
gaps.append(
|
|
136
|
+
Gap(
|
|
137
|
+
code="recommendation_no_reference",
|
|
138
|
+
detail="Recommendation does not reference a winning option label.",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
verdict = Verdict.PASS if not gaps else Verdict.FAIL_AUTO
|
|
143
|
+
log.info(
|
|
144
|
+
"strategist_validate_complete",
|
|
145
|
+
verdict=verdict.value,
|
|
146
|
+
gap_count=len(gaps),
|
|
147
|
+
options=n_options,
|
|
148
|
+
)
|
|
149
|
+
return ValidationReport(verdict=verdict, gaps=gaps)
|