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