ref-agents 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. ref_agents/__init__.py +9 -0
  2. ref_agents/api_keys.json.example +8 -0
  3. ref_agents/auth.py +129 -0
  4. ref_agents/codemap/..md +62 -0
  5. ref_agents/codemap/CODE_MAP.md +37 -0
  6. ref_agents/codemap/core.md +43 -0
  7. ref_agents/codemap/models.md +43 -0
  8. ref_agents/codemap/prompts.md +40 -0
  9. ref_agents/codemap/security.md +45 -0
  10. ref_agents/codemap/tools.md +94 -0
  11. ref_agents/codemap/tools_browser.md +44 -0
  12. ref_agents/codemap/utils.md +42 -0
  13. ref_agents/codemap/workflow.md +42 -0
  14. ref_agents/config/ai_patterns.yaml +101 -0
  15. ref_agents/config/frameworks/angular.yaml +104 -0
  16. ref_agents/config/frameworks/aspnet.yaml +84 -0
  17. ref_agents/config/frameworks/ef_core.yaml +81 -0
  18. ref_agents/config/frameworks/react.yaml +111 -0
  19. ref_agents/config/frameworks/spring_boot.yaml +117 -0
  20. ref_agents/config/languages/csharp.yaml +153 -0
  21. ref_agents/config/languages/java.yaml +188 -0
  22. ref_agents/config/languages/javascript.yaml +172 -0
  23. ref_agents/config/languages/python.yaml +153 -0
  24. ref_agents/config/languages/typescript.yaml +193 -0
  25. ref_agents/constants.py +553 -0
  26. ref_agents/core/__init__.py +15 -0
  27. ref_agents/core/config_loader.py +160 -0
  28. ref_agents/core/config_models.py +167 -0
  29. ref_agents/core/config_parsing.py +84 -0
  30. ref_agents/core/language_detector.py +388 -0
  31. ref_agents/core/validation_models.py +66 -0
  32. ref_agents/core/validation_primitives.py +176 -0
  33. ref_agents/errors.py +34 -0
  34. ref_agents/license_client.py +247 -0
  35. ref_agents/models/__init__.py +22 -0
  36. ref_agents/models/gherkin.py +45 -0
  37. ref_agents/models/hierarchy.py +80 -0
  38. ref_agents/models/invest.py +59 -0
  39. ref_agents/models/version.py +49 -0
  40. ref_agents/prompts/__init__.py +9 -0
  41. ref_agents/prompts/start_agent.py +772 -0
  42. ref_agents/rules/architecture/backend_patterns.md +43 -0
  43. ref_agents/rules/architecture/diagramming.md +100 -0
  44. ref_agents/rules/architecture/frontend_patterns.md +40 -0
  45. ref_agents/rules/architecture/impact_analysis.md +129 -0
  46. ref_agents/rules/architecture/migration_strategy.md +208 -0
  47. ref_agents/rules/architecture/regression_protocol.md +77 -0
  48. ref_agents/rules/architecture/system_design.md +97 -0
  49. ref_agents/rules/common/codemap_standard.md +97 -0
  50. ref_agents/rules/common/core_protocol.md +59 -0
  51. ref_agents/rules/common/prompt_engineering.md +294 -0
  52. ref_agents/rules/development/debugging.md +32 -0
  53. ref_agents/rules/development/implementation.md +205 -0
  54. ref_agents/rules/operations/completion.md +119 -0
  55. ref_agents/rules/operations/cutover_protocol.md +218 -0
  56. ref_agents/rules/operations/discovery.md +179 -0
  57. ref_agents/rules/operations/fix_workflow.md +87 -0
  58. ref_agents/rules/operations/forensics.md +278 -0
  59. ref_agents/rules/operations/platform.md +263 -0
  60. ref_agents/rules/operations/synchronous_flow.md +25 -0
  61. ref_agents/rules/product/ac_validation.md +25 -0
  62. ref_agents/rules/product/brainstorming.md +27 -0
  63. ref_agents/rules/product/ref_flow.md +101 -0
  64. ref_agents/rules/product/requirements_std.md +114 -0
  65. ref_agents/rules/product/spec_writing.md +235 -0
  66. ref_agents/rules/product/strategy.md +96 -0
  67. ref_agents/rules/quality/documentation_standards.md +46 -0
  68. ref_agents/rules/quality/parity_testing.md +234 -0
  69. ref_agents/rules/quality/project_documentation.md +56 -0
  70. ref_agents/rules/quality/qa_lead.md +111 -0
  71. ref_agents/rules/quality/test_design.md +146 -0
  72. ref_agents/rules/quality/testing_standards.md +293 -0
  73. ref_agents/rules/review/pr_review.md +116 -0
  74. ref_agents/rules/security/security_audit.md +83 -0
  75. ref_agents/security/__init__.py +22 -0
  76. ref_agents/security/dependency_audit.py +188 -0
  77. ref_agents/security/file_audit.py +208 -0
  78. ref_agents/security/network_scan.py +179 -0
  79. ref_agents/security/report_generator.py +313 -0
  80. ref_agents/security/secret_scan.py +252 -0
  81. ref_agents/security/url_scan.py +240 -0
  82. ref_agents/security_scan.py +236 -0
  83. ref_agents/server.py +1586 -0
  84. ref_agents/session.py +100 -0
  85. ref_agents/tool_names.py +55 -0
  86. ref_agents/tools/__init__.py +8 -0
  87. ref_agents/tools/agents_generator.py +315 -0
  88. ref_agents/tools/ai_pattern_detector.py +815 -0
  89. ref_agents/tools/brownfield_populator.py +529 -0
  90. ref_agents/tools/browser/__init__.py +50 -0
  91. ref_agents/tools/browser/evidence_verifier.py +302 -0
  92. ref_agents/tools/browser/execution_logger.py +249 -0
  93. ref_agents/tools/browser/playwright_mcp_client.py +259 -0
  94. ref_agents/tools/browser/screenshot_utils.py +184 -0
  95. ref_agents/tools/browser/test_executor.py +537 -0
  96. ref_agents/tools/code_quality_scanner.py +629 -0
  97. ref_agents/tools/codemap/..md +93 -0
  98. ref_agents/tools/codemap/CODE_MAP.md +30 -0
  99. ref_agents/tools/codemap/browser.md +44 -0
  100. ref_agents/tools/codemap.py +403 -0
  101. ref_agents/tools/codemap_freshness.py +234 -0
  102. ref_agents/tools/comment_smell_scanner.py +346 -0
  103. ref_agents/tools/complexity.py +436 -0
  104. ref_agents/tools/complexity_ast.py +333 -0
  105. ref_agents/tools/compliance.py +246 -0
  106. ref_agents/tools/compliance_remediation.py +846 -0
  107. ref_agents/tools/context_graph.py +839 -0
  108. ref_agents/tools/context_manager.py +550 -0
  109. ref_agents/tools/context_tools.py +121 -0
  110. ref_agents/tools/cross_repo_linker.py +393 -0
  111. ref_agents/tools/dead_code_scanner.py +637 -0
  112. ref_agents/tools/debt_scanner.py +1092 -0
  113. ref_agents/tools/dependency_graph.py +272 -0
  114. ref_agents/tools/discovery_audit.py +372 -0
  115. ref_agents/tools/docs_scanner.py +600 -0
  116. ref_agents/tools/evaluate_gate.py +119 -0
  117. ref_agents/tools/external_detector.py +524 -0
  118. ref_agents/tools/features_generator.py +282 -0
  119. ref_agents/tools/flow_gap_detector.py +373 -0
  120. ref_agents/tools/flow_mapper.py +327 -0
  121. ref_agents/tools/full_suite_runner.py +740 -0
  122. ref_agents/tools/gherkin_parser.py +227 -0
  123. ref_agents/tools/guard_tools.py +139 -0
  124. ref_agents/tools/handoff_tools.py +282 -0
  125. ref_agents/tools/health_scanner.py +1211 -0
  126. ref_agents/tools/hierarchy_manager.py +289 -0
  127. ref_agents/tools/invest_scorer.py +249 -0
  128. ref_agents/tools/jira_confluence_export.py +306 -0
  129. ref_agents/tools/json_output.py +76 -0
  130. ref_agents/tools/migration_mapper.py +946 -0
  131. ref_agents/tools/migration_readiness_scanner.py +209 -0
  132. ref_agents/tools/pattern_learner.py +522 -0
  133. ref_agents/tools/report_utils.py +155 -0
  134. ref_agents/tools/requirements_serializer.py +225 -0
  135. ref_agents/tools/security_audit_tool.py +106 -0
  136. ref_agents/tools/sequencing_engine.py +288 -0
  137. ref_agents/tools/summary_generator.py +275 -0
  138. ref_agents/tools/symbol_resolver.py +306 -0
  139. ref_agents/tools/symbol_smoke_runner.py +336 -0
  140. ref_agents/tools/test_plan_validator.py +189 -0
  141. ref_agents/tools/test_smell_walker.py +902 -0
  142. ref_agents/tools/tier1_fixer.py +502 -0
  143. ref_agents/tools/validators/__init__.py +419 -0
  144. ref_agents/tools/validators/architect.py +268 -0
  145. ref_agents/tools/validators/cutover_engineer.py +167 -0
  146. ref_agents/tools/validators/developer.py +180 -0
  147. ref_agents/tools/validators/discovery.py +150 -0
  148. ref_agents/tools/validators/forensic_engineer.py +191 -0
  149. ref_agents/tools/validators/impact_architect.py +181 -0
  150. ref_agents/tools/validators/migration_planner.py +166 -0
  151. ref_agents/tools/validators/parity_tester.py +155 -0
  152. ref_agents/tools/validators/platform_engineer.py +134 -0
  153. ref_agents/tools/validators/pr_reviewer.py +129 -0
  154. ref_agents/tools/validators/product_manager.py +291 -0
  155. ref_agents/tools/validators/qa_lead.py +172 -0
  156. ref_agents/tools/validators/scrum_master.py +212 -0
  157. ref_agents/tools/validators/security_owner.py +162 -0
  158. ref_agents/tools/validators/specifier.py +134 -0
  159. ref_agents/tools/validators/strategist.py +149 -0
  160. ref_agents/tools/validators/tester.py +121 -0
  161. ref_agents/tools/version_manager.py +202 -0
  162. ref_agents/tools/workflow_tools.py +1549 -0
  163. ref_agents/utils/__init__.py +21 -0
  164. ref_agents/utils/git_utils.py +351 -0
  165. ref_agents/utils/handoff_logger.py +368 -0
  166. ref_agents/utils/ignore_matcher.py +270 -0
  167. ref_agents/workflow/__init__.py +19 -0
  168. ref_agents/workflow/capabilities.py +328 -0
  169. ref_agents/workflow/state_machine.py +708 -0
  170. ref_agents/workflow/transitions.py +658 -0
  171. ref_agents-1.0.0.dist-info/METADATA +365 -0
  172. ref_agents-1.0.0.dist-info/RECORD +175 -0
  173. ref_agents-1.0.0.dist-info/WHEEL +4 -0
  174. ref_agents-1.0.0.dist-info/entry_points.txt +2 -0
  175. ref_agents-1.0.0.dist-info/licenses/LICENSE +115 -0
@@ -0,0 +1,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