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,275 @@
|
|
|
1
|
+
"""Executive Summary Generator for REF Dashboard.
|
|
2
|
+
|
|
3
|
+
Aggregates metrics from security scans, debt scans, and compliance checks
|
|
4
|
+
to generate ref-reports/executive-summary.json for dashboard consumption.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TypedDict
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
from ref_agents.tools.debt_scanner import get_cached_debt_count
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExecutiveSummary(TypedDict):
|
|
21
|
+
"""Schema for executive-summary.json."""
|
|
22
|
+
|
|
23
|
+
generated_at: str
|
|
24
|
+
score: float
|
|
25
|
+
critical_issues: int
|
|
26
|
+
debt_count: int
|
|
27
|
+
security_passed: bool
|
|
28
|
+
compliance_passed: bool
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _find_latest_security_report(base_dir: Path) -> Path | None:
|
|
32
|
+
"""Find the most recent security audit report.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
base_dir: Base directory containing ref-reports.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to latest security report or None if not found.
|
|
39
|
+
"""
|
|
40
|
+
security_dir = base_dir / "ref-reports" / "security"
|
|
41
|
+
if not security_dir.exists():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
reports = list(security_dir.glob("security_audit_*.md"))
|
|
45
|
+
if not reports:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
# Sort by modification time, newest first
|
|
49
|
+
reports.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
50
|
+
return reports[0]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_security_report(report_path: Path) -> tuple[bool, int]:
|
|
54
|
+
"""Parse security report to extract pass/fail status and critical issues.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
report_path: Path to security audit markdown file.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (security_passed, critical_issues_count).
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
content = report_path.read_text(encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
# Check for overall status
|
|
66
|
+
security_passed = "OVERALL: ✅ PASSED" in content
|
|
67
|
+
|
|
68
|
+
# Count critical issues (❌ FAIL lines, not FAILED overall status)
|
|
69
|
+
fail_pattern = re.compile(r"❌\s*FAIL\b(?!ED)")
|
|
70
|
+
critical_issues = len(fail_pattern.findall(content))
|
|
71
|
+
|
|
72
|
+
return security_passed, critical_issues
|
|
73
|
+
|
|
74
|
+
except OSError as e:
|
|
75
|
+
logger.warning(
|
|
76
|
+
"Failed to parse security report", path=str(report_path), error=str(e)
|
|
77
|
+
)
|
|
78
|
+
return True, 0 # Default to passed if can't read
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _get_debt_count(directory: Path) -> int:
|
|
82
|
+
"""Get technical debt item count from cache (non-blocking).
|
|
83
|
+
|
|
84
|
+
Reads from existing cache. Returns 0 if no cache exists.
|
|
85
|
+
Does NOT trigger a new scan - use scan_debt() separately.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
directory: Directory containing .ref cache.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Number of debt items in cache, or 0 if no cache.
|
|
92
|
+
"""
|
|
93
|
+
# Try to read from cache first (instant, non-blocking)
|
|
94
|
+
cached_count = get_cached_debt_count(directory)
|
|
95
|
+
if cached_count is not None:
|
|
96
|
+
logger.info("debt_count_from_cache", count=cached_count)
|
|
97
|
+
return cached_count
|
|
98
|
+
|
|
99
|
+
# No cache exists - return 0 (user should run scan_debt first)
|
|
100
|
+
logger.info("no_debt_cache", directory=str(directory))
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_compliance(base_dir: Path) -> bool:
|
|
105
|
+
"""Check if compliance reports indicate passing status.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
base_dir: Base directory containing ref-reports.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if compliance passed or no compliance reports exist.
|
|
112
|
+
"""
|
|
113
|
+
reports_dir = base_dir / "ref-reports"
|
|
114
|
+
if not reports_dir.exists():
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
# Look for compliance reports in any agent directory
|
|
118
|
+
compliance_files = list(reports_dir.glob("*/compliance_*.md"))
|
|
119
|
+
if not compliance_files:
|
|
120
|
+
return True # No compliance reports = passed
|
|
121
|
+
|
|
122
|
+
# Check latest compliance report
|
|
123
|
+
compliance_files.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
124
|
+
latest = compliance_files[0]
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
content = latest.read_text(encoding="utf-8")
|
|
128
|
+
# If "Issues Found" appears, compliance failed
|
|
129
|
+
return "⚠️ Issues Found" not in content
|
|
130
|
+
except OSError:
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _calculate_score(
|
|
135
|
+
security_passed: bool,
|
|
136
|
+
critical_issues: int,
|
|
137
|
+
debt_count: int,
|
|
138
|
+
compliance_passed: bool,
|
|
139
|
+
) -> float:
|
|
140
|
+
"""Calculate project health score from metrics.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
security_passed: Whether security audit passed.
|
|
144
|
+
critical_issues: Number of critical security issues.
|
|
145
|
+
debt_count: Number of technical debt items.
|
|
146
|
+
compliance_passed: Whether compliance checks passed.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Score from 0.0 to 100.0.
|
|
150
|
+
"""
|
|
151
|
+
score = 100.0
|
|
152
|
+
|
|
153
|
+
# Security failures
|
|
154
|
+
if not security_passed:
|
|
155
|
+
score -= 20.0
|
|
156
|
+
score -= min(critical_issues * 10.0, 30.0)
|
|
157
|
+
|
|
158
|
+
# Technical debt penalty (capped)
|
|
159
|
+
score -= min(debt_count * 2.0, 30.0)
|
|
160
|
+
|
|
161
|
+
# Compliance failure
|
|
162
|
+
if not compliance_passed:
|
|
163
|
+
score -= 10.0
|
|
164
|
+
|
|
165
|
+
return max(0.0, min(100.0, score))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def generate_executive_summary(directory: str = "") -> ExecutiveSummary:
|
|
169
|
+
"""Generate executive summary by aggregating all metrics.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
directory: Base directory to scan. Defaults to project_root if set,
|
|
173
|
+
otherwise current working directory.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
ExecutiveSummary dictionary with all metrics.
|
|
177
|
+
"""
|
|
178
|
+
if directory:
|
|
179
|
+
base_dir = Path(directory)
|
|
180
|
+
else:
|
|
181
|
+
from ref_agents.session import SessionManager
|
|
182
|
+
|
|
183
|
+
session = SessionManager.get()
|
|
184
|
+
base_dir = session.project_root or Path.cwd()
|
|
185
|
+
|
|
186
|
+
security_report = _find_latest_security_report(base_dir)
|
|
187
|
+
if security_report:
|
|
188
|
+
security_passed, critical_issues = _parse_security_report(security_report)
|
|
189
|
+
else:
|
|
190
|
+
security_passed = True
|
|
191
|
+
critical_issues = 0
|
|
192
|
+
|
|
193
|
+
src_dir = base_dir / "src"
|
|
194
|
+
scan_dir = src_dir if src_dir.exists() else base_dir
|
|
195
|
+
debt_count = _get_debt_count(scan_dir)
|
|
196
|
+
|
|
197
|
+
compliance_passed = _check_compliance(base_dir)
|
|
198
|
+
|
|
199
|
+
# Calculate score
|
|
200
|
+
score = _calculate_score(
|
|
201
|
+
security_passed, critical_issues, debt_count, compliance_passed
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return ExecutiveSummary(
|
|
205
|
+
generated_at=datetime.now().isoformat(),
|
|
206
|
+
score=round(score, 1),
|
|
207
|
+
critical_issues=critical_issues,
|
|
208
|
+
debt_count=debt_count,
|
|
209
|
+
security_passed=security_passed,
|
|
210
|
+
compliance_passed=compliance_passed,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def write_executive_summary(directory: str = "") -> Path:
|
|
215
|
+
"""Generate and write executive-summary.json to ref-reports/.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
directory: Base directory. Defaults to project_root if set,
|
|
219
|
+
otherwise current working directory.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Path to written executive-summary.json.
|
|
223
|
+
|
|
224
|
+
Raises:
|
|
225
|
+
OSError: If file cannot be written.
|
|
226
|
+
"""
|
|
227
|
+
if directory:
|
|
228
|
+
base_dir = Path(directory)
|
|
229
|
+
else:
|
|
230
|
+
from ref_agents.session import SessionManager
|
|
231
|
+
|
|
232
|
+
session = SessionManager.get()
|
|
233
|
+
base_dir = session.project_root or Path.cwd()
|
|
234
|
+
summary = generate_executive_summary(directory)
|
|
235
|
+
|
|
236
|
+
output_dir = base_dir / "ref-reports"
|
|
237
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
output_path = output_dir / "executive-summary.json"
|
|
240
|
+
output_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
241
|
+
|
|
242
|
+
logger.info(
|
|
243
|
+
"executive_summary_written",
|
|
244
|
+
path=str(output_path),
|
|
245
|
+
score=summary["score"],
|
|
246
|
+
debt_count=summary["debt_count"],
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return output_path
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def generate_project_summary_tool(directory: str = "") -> str:
|
|
253
|
+
"""MCP tool wrapper for generating executive summary.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
directory: Base directory to scan. Empty string for current directory.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
Status message with path to generated file.
|
|
260
|
+
"""
|
|
261
|
+
try:
|
|
262
|
+
output_path = write_executive_summary(directory)
|
|
263
|
+
summary = generate_executive_summary(directory)
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
f"Executive summary generated: `{output_path}`\n\n"
|
|
267
|
+
f"**Metrics:**\n"
|
|
268
|
+
f"- Score: {summary['score']}/100\n"
|
|
269
|
+
f"- Critical Issues: {summary['critical_issues']}\n"
|
|
270
|
+
f"- Tech Debt Items: {summary['debt_count']}\n"
|
|
271
|
+
f"- Security: {'✅ Passed' if summary['security_passed'] else '❌ Failed'}\n"
|
|
272
|
+
f"- Compliance: {'✅ Passed' if summary['compliance_passed'] else '❌ Failed'}"
|
|
273
|
+
)
|
|
274
|
+
except OSError as e:
|
|
275
|
+
return f"Error generating summary: {e}"
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Symbol resolver for STORY-TQ-003 — grounded interface-contract lookup.
|
|
2
|
+
|
|
3
|
+
Locates symbol names in deterministic precedence order from sources OUTSIDE the
|
|
4
|
+
LLM chain: existing CODE_MAP entries, then the story's ``## Public Interface``
|
|
5
|
+
table, then OpenAPI operation ids, then ``.pyi`` stub declarations. The resolver
|
|
6
|
+
is read-only — never modifies any grounding source.
|
|
7
|
+
|
|
8
|
+
OpenAPI and ``.pyi`` consumption is scaffolded but disabled in v1 per
|
|
9
|
+
``docs/specs/SPEC-STORY-TQ-003.md`` FR-11. Tester generates executable tests
|
|
10
|
+
only for ``code_map`` and ``story_public_interface`` candidates.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import structlog
|
|
21
|
+
|
|
22
|
+
logger = structlog.get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CODEMAP_FRESHNESS_SECONDS: int = 4 * 3600 # 4 hours; matches existing scanner gate.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Candidate:
|
|
30
|
+
"""One grounded candidate for a ``symbol_under_test`` entry.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
name: Bare or dotted symbol name (e.g. ``calculate_total`` or
|
|
34
|
+
``Payment.process``).
|
|
35
|
+
source: One of ``code_map`` / ``story_public_interface`` / ``openapi``
|
|
36
|
+
/ ``pyi_stub``. ``none`` is reserved for ungrounded entries — not
|
|
37
|
+
emitted here.
|
|
38
|
+
module_path: Optional dotted module path (best-effort).
|
|
39
|
+
grounding: Free-text descriptor naming the file + anchor that supplied
|
|
40
|
+
the symbol (e.g. ``CODE_MAP.md:src/pkg/foo.py``).
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
source: str
|
|
45
|
+
module_path: str | None
|
|
46
|
+
grounding: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
VALID_SOURCES: tuple[str, ...] = (
|
|
50
|
+
"code_map",
|
|
51
|
+
"story_public_interface",
|
|
52
|
+
"openapi",
|
|
53
|
+
"pyi_stub",
|
|
54
|
+
"none",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolve(
|
|
59
|
+
name: str,
|
|
60
|
+
story_path: Path | None,
|
|
61
|
+
project_root: Path,
|
|
62
|
+
) -> list[Candidate]:
|
|
63
|
+
"""Return ordered candidates for ``name`` from all grounding sources.
|
|
64
|
+
|
|
65
|
+
First match per source is recorded. Caller picks the first element of the
|
|
66
|
+
returned list to honour precedence (CODE_MAP > story Public Interface >
|
|
67
|
+
OpenAPI > stub).
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: Symbol name being resolved. May contain ``.`` for class methods.
|
|
71
|
+
story_path: Path to the story file containing the optional Public
|
|
72
|
+
Interface table. ``None`` skips that source.
|
|
73
|
+
project_root: Project root for CODE_MAP, OpenAPI, and stub lookup.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of candidates ordered by source precedence. Empty when no source
|
|
77
|
+
contains the symbol.
|
|
78
|
+
"""
|
|
79
|
+
candidates: list[Candidate] = []
|
|
80
|
+
|
|
81
|
+
codemap_path = _find_codemap(project_root)
|
|
82
|
+
if codemap_path is not None and not _is_stale(codemap_path):
|
|
83
|
+
candidates.extend(_lookup_codemap(name, codemap_path, project_root))
|
|
84
|
+
elif codemap_path is not None:
|
|
85
|
+
logger.info(
|
|
86
|
+
"codemap_stale_skipped",
|
|
87
|
+
path=str(codemap_path),
|
|
88
|
+
age_seconds=int(time.time() - codemap_path.stat().st_mtime),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if story_path is not None and story_path.exists():
|
|
92
|
+
candidates.extend(_lookup_public_interface(name, story_path))
|
|
93
|
+
|
|
94
|
+
# OpenAPI and .pyi sources scaffolded but disabled in v1 (FR-11).
|
|
95
|
+
# Hook points are kept here so M3b can wire them without re-design.
|
|
96
|
+
return candidates
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_valid_source(value: str) -> bool:
|
|
100
|
+
"""True iff ``value`` is one of the documented enum entries."""
|
|
101
|
+
return value in VALID_SOURCES
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _find_codemap(project_root: Path) -> Path | None:
|
|
105
|
+
"""Return the project's CODE_MAP path, preferring root over ``docs/``."""
|
|
106
|
+
for candidate in (
|
|
107
|
+
project_root / "CODE_MAP.md",
|
|
108
|
+
project_root / "docs" / "CODE_MAP.md",
|
|
109
|
+
):
|
|
110
|
+
if candidate.exists():
|
|
111
|
+
return candidate
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _is_stale(path: Path) -> bool:
|
|
116
|
+
try:
|
|
117
|
+
age = time.time() - path.stat().st_mtime
|
|
118
|
+
except OSError:
|
|
119
|
+
return True
|
|
120
|
+
return age > CODEMAP_FRESHNESS_SECONDS
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- CODE_MAP lookup ----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Matches a single row in the "Key Classes / Functions" table.
|
|
127
|
+
# Columns: | `name` | `file_path` | purpose | deps |
|
|
128
|
+
_CODEMAP_ROW = re.compile(
|
|
129
|
+
r"^\|\s*`(?P<name>[^`]+?)`\s*\|\s*`(?P<file>[^`]+?)`\s*\|.*?\|.*?\|\s*$",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _lookup_codemap(
|
|
134
|
+
name: str,
|
|
135
|
+
codemap_path: Path,
|
|
136
|
+
project_root: Path,
|
|
137
|
+
) -> list[Candidate]:
|
|
138
|
+
"""Extract all matching rows from the Key Classes / Functions table."""
|
|
139
|
+
try:
|
|
140
|
+
lines = codemap_path.read_text(encoding="utf-8").splitlines()
|
|
141
|
+
except OSError as exc:
|
|
142
|
+
logger.warning("codemap_read_failed", error=str(exc))
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
candidates: list[Candidate] = []
|
|
146
|
+
seen_relative = codemap_path.relative_to(project_root)
|
|
147
|
+
|
|
148
|
+
in_symbols_table = False
|
|
149
|
+
for line in lines:
|
|
150
|
+
stripped = line.strip()
|
|
151
|
+
if stripped.startswith("## Key Classes / Functions") or stripped.startswith(
|
|
152
|
+
"## Functions"
|
|
153
|
+
):
|
|
154
|
+
in_symbols_table = True
|
|
155
|
+
continue
|
|
156
|
+
if (
|
|
157
|
+
in_symbols_table
|
|
158
|
+
and stripped.startswith("## ")
|
|
159
|
+
and not stripped.startswith("## Key Classes / Functions")
|
|
160
|
+
):
|
|
161
|
+
in_symbols_table = False
|
|
162
|
+
continue
|
|
163
|
+
if not in_symbols_table:
|
|
164
|
+
continue
|
|
165
|
+
match = _CODEMAP_ROW.match(line)
|
|
166
|
+
if not match:
|
|
167
|
+
continue
|
|
168
|
+
row_name = match.group("name").strip()
|
|
169
|
+
# Strip trailing parens: `do_thing()` → `do_thing`.
|
|
170
|
+
bare_name = row_name.split("(", 1)[0]
|
|
171
|
+
if bare_name != name:
|
|
172
|
+
continue
|
|
173
|
+
file_path = match.group("file").strip()
|
|
174
|
+
module_path = _module_path_from_file(file_path)
|
|
175
|
+
candidates.append(
|
|
176
|
+
Candidate(
|
|
177
|
+
name=bare_name,
|
|
178
|
+
source="code_map",
|
|
179
|
+
module_path=module_path,
|
|
180
|
+
grounding=f"{seen_relative}:{file_path}",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Deterministic order: shortest module path first (matches lexical sort heuristic).
|
|
185
|
+
candidates.sort(key=lambda c: ((c.module_path or ""), c.grounding))
|
|
186
|
+
return candidates
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _module_path_from_file(file_path: str) -> str | None:
|
|
190
|
+
"""Convert a source-tree file path into a dotted module path.
|
|
191
|
+
|
|
192
|
+
Strips a leading ``src/`` prefix (common Python layout) and the ``.py``
|
|
193
|
+
extension. Returns ``None`` for paths that don't look like Python source.
|
|
194
|
+
"""
|
|
195
|
+
path = Path(file_path)
|
|
196
|
+
if path.suffix not in (".py", ""):
|
|
197
|
+
return None
|
|
198
|
+
parts = list(path.with_suffix("").parts)
|
|
199
|
+
if parts and parts[0] == "src":
|
|
200
|
+
parts = parts[1:]
|
|
201
|
+
if not parts:
|
|
202
|
+
return None
|
|
203
|
+
# Drop a trailing __init__ for package imports.
|
|
204
|
+
if parts[-1] == "__init__":
|
|
205
|
+
parts = parts[:-1]
|
|
206
|
+
if not parts:
|
|
207
|
+
return None
|
|
208
|
+
return ".".join(parts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- Public Interface table lookup --------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_TABLE_HEADER = re.compile(
|
|
215
|
+
r"^\s*\|\s*symbol\s*\|\s*module_path\s*\|\s*signature\s*\|\s*description\s*\|\s*$",
|
|
216
|
+
re.IGNORECASE,
|
|
217
|
+
)
|
|
218
|
+
_TABLE_ROW = re.compile(
|
|
219
|
+
r"^\s*\|\s*`?(?P<symbol>[^|`]+?)`?\s*\|\s*`?(?P<module>[^|`]+?)`?\s*\|\s*`?(?P<signature>[^|]+?)`?\s*\|\s*(?P<description>[^|]*?)\s*\|\s*$",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _lookup_public_interface(
|
|
224
|
+
name: str,
|
|
225
|
+
story_path: Path,
|
|
226
|
+
) -> list[Candidate]:
|
|
227
|
+
"""Parse the ``## Public Interface`` section of a story file."""
|
|
228
|
+
try:
|
|
229
|
+
text = story_path.read_text(encoding="utf-8")
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
logger.warning("story_read_failed", path=str(story_path), error=str(exc))
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
section = _extract_section(text, "Public Interface")
|
|
235
|
+
if section is None:
|
|
236
|
+
return []
|
|
237
|
+
|
|
238
|
+
rows = _parse_table(section)
|
|
239
|
+
candidates: list[Candidate] = []
|
|
240
|
+
for idx, row in enumerate(rows, start=1):
|
|
241
|
+
if row["symbol"].strip() == name:
|
|
242
|
+
candidates.append(
|
|
243
|
+
Candidate(
|
|
244
|
+
name=name,
|
|
245
|
+
source="story_public_interface",
|
|
246
|
+
module_path=row["module_path"].strip() or None,
|
|
247
|
+
grounding=f"{story_path.name}:Public Interface row {idx}",
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
return candidates
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _extract_section(text: str, section_title: str) -> str | None:
|
|
254
|
+
"""Return the markdown content of ``## {section_title}`` (without heading).
|
|
255
|
+
|
|
256
|
+
Tolerates leading whitespace before the heading (test fixtures sometimes
|
|
257
|
+
produce uneven indentation via ``textwrap.dedent``). Section ends at the
|
|
258
|
+
next ``## `` heading or EOF.
|
|
259
|
+
"""
|
|
260
|
+
pattern = re.compile(
|
|
261
|
+
rf"^[ \t]*##\s+{re.escape(section_title)}\s*$",
|
|
262
|
+
re.MULTILINE,
|
|
263
|
+
)
|
|
264
|
+
match = pattern.search(text)
|
|
265
|
+
if not match:
|
|
266
|
+
return None
|
|
267
|
+
start = match.end()
|
|
268
|
+
next_heading = re.search(r"^[ \t]*##\s+", text[start:], re.MULTILINE)
|
|
269
|
+
end = start + next_heading.start() if next_heading else len(text)
|
|
270
|
+
return text[start:end]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _parse_table(section: str) -> list[dict[str, str]]:
|
|
274
|
+
"""Parse a four-column markdown table in the section text.
|
|
275
|
+
|
|
276
|
+
Skips the header + separator lines. Returns a list of dicts with keys
|
|
277
|
+
``symbol``, ``module_path``, ``signature``, ``description``.
|
|
278
|
+
"""
|
|
279
|
+
rows: list[dict[str, str]] = []
|
|
280
|
+
saw_header = False
|
|
281
|
+
saw_separator = False
|
|
282
|
+
for raw in section.splitlines():
|
|
283
|
+
if not saw_header:
|
|
284
|
+
if _TABLE_HEADER.match(raw):
|
|
285
|
+
saw_header = True
|
|
286
|
+
continue
|
|
287
|
+
if not saw_separator:
|
|
288
|
+
stripped_for_sep = raw.lstrip()
|
|
289
|
+
if "---" in raw and stripped_for_sep.startswith("|"):
|
|
290
|
+
saw_separator = True
|
|
291
|
+
continue
|
|
292
|
+
match = _TABLE_ROW.match(raw)
|
|
293
|
+
if not match:
|
|
294
|
+
# Blank line or unrelated content ends the table.
|
|
295
|
+
if raw.strip() == "" or not raw.lstrip().startswith("|"):
|
|
296
|
+
break
|
|
297
|
+
continue
|
|
298
|
+
rows.append(
|
|
299
|
+
{
|
|
300
|
+
"symbol": match.group("symbol"),
|
|
301
|
+
"module_path": match.group("module"),
|
|
302
|
+
"signature": match.group("signature"),
|
|
303
|
+
"description": match.group("description"),
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
return rows
|