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,119 @@
1
+ """Validation gate evaluation functions for quantifying AC quality metrics."""
2
+
3
+ from typing import TypedDict
4
+
5
+ import structlog
6
+
7
+ logger = structlog.get_logger(__name__)
8
+
9
+
10
+ def _count_matches_in_list(items: list[str], keywords: list[str]) -> int:
11
+ """Count items that contain any of the keywords.
12
+
13
+ Args:
14
+ items: List of strings to search
15
+ keywords: Keywords to search for
16
+
17
+ Returns:
18
+ Count of items containing at least one keyword
19
+ """
20
+ return sum(
21
+ 1 for item in items if any(keyword in item.lower() for keyword in keywords)
22
+ )
23
+
24
+
25
+ class TestStrategyResult(TypedDict):
26
+ """Result of test strategy evaluation."""
27
+
28
+ has_test_strategy: bool
29
+ gherkin_ratio: float
30
+ gherkin_count: int
31
+ total_ac_count: int
32
+
33
+
34
+ class AmbiguityResult(TypedDict):
35
+ """Result of ambiguity evaluation."""
36
+
37
+ is_ambiguous: bool
38
+ ambiguous_count: int
39
+ total_ac_count: int
40
+ ambiguous_terms_found: list[str]
41
+
42
+
43
+ def evaluate_test_strategy(acceptance_criteria: list[str]) -> TestStrategyResult:
44
+ """Evaluate if acceptance criteria have a test strategy.
45
+
46
+ Uses Gherkin format detection (Given/When/Then keywords) to determine
47
+ if AC have a testable strategy. Threshold: gherkin_ratio >= 0.4.
48
+
49
+ Args:
50
+ acceptance_criteria: List of acceptance criteria strings
51
+
52
+ Returns:
53
+ TestStrategyResult with has_test_strategy boolean and metrics
54
+ """
55
+ if not acceptance_criteria:
56
+ return TestStrategyResult(
57
+ has_test_strategy=False,
58
+ gherkin_ratio=0.0,
59
+ gherkin_count=0,
60
+ total_ac_count=0,
61
+ )
62
+
63
+ gherkin_keywords = ["given", "when", "then", "and", "but"]
64
+ gherkin_count = _count_matches_in_list(acceptance_criteria, gherkin_keywords)
65
+ total_ac_count = len(acceptance_criteria)
66
+ gherkin_ratio = gherkin_count / total_ac_count
67
+
68
+ # Threshold: gherkin_ratio >= 0.4 = has_test_strategy=True
69
+ has_test_strategy = gherkin_ratio >= 0.4 # noqa: PLR2004 # TECH_DEBT: extract to named constant — G25
70
+
71
+ return TestStrategyResult(
72
+ has_test_strategy=has_test_strategy,
73
+ gherkin_ratio=gherkin_ratio,
74
+ gherkin_count=gherkin_count,
75
+ total_ac_count=total_ac_count,
76
+ )
77
+
78
+
79
+ def evaluate_ambiguous(acceptance_criteria: list[str]) -> AmbiguityResult:
80
+ """Evaluate if acceptance criteria contain ambiguous terms.
81
+
82
+ Detects ambiguous terms that make AC untestable or unclear.
83
+ Uses same logic as invest_scorer._score_estimable().
84
+
85
+ Args:
86
+ acceptance_criteria: List of acceptance criteria strings
87
+
88
+ Returns:
89
+ AmbiguityResult with is_ambiguous boolean and detected terms
90
+ """
91
+ if not acceptance_criteria:
92
+ return AmbiguityResult(
93
+ is_ambiguous=False,
94
+ ambiguous_count=0,
95
+ total_ac_count=0,
96
+ ambiguous_terms_found=[],
97
+ )
98
+
99
+ ambiguous_terms = ["fast", "quickly", "user-friendly", "easy", "intuitive"]
100
+ ambiguous_found: list[str] = []
101
+
102
+ for ac in acceptance_criteria:
103
+ ac_lower = ac.lower()
104
+ for term in ambiguous_terms:
105
+ if term in ac_lower and term not in ambiguous_found:
106
+ ambiguous_found.append(term)
107
+
108
+ ambiguous_count = len(ambiguous_found)
109
+ total_ac_count = len(acceptance_criteria)
110
+
111
+ # If any ambiguous terms found, AC is ambiguous
112
+ is_ambiguous = ambiguous_count > 0
113
+
114
+ return AmbiguityResult(
115
+ is_ambiguous=is_ambiguous,
116
+ ambiguous_count=ambiguous_count,
117
+ total_ac_count=total_ac_count,
118
+ ambiguous_terms_found=ambiguous_found,
119
+ )
@@ -0,0 +1,524 @@
1
+ """External Dependency Detector for REF Agents.
2
+
3
+ Scans codebases to auto-detect external dependencies:
4
+ - Environment variable references (API URLs, endpoints)
5
+ - Known third-party API client libraries
6
+ - Hardcoded URL literals
7
+ - Config file endpoint definitions
8
+ - NuGet packages from .csproj and packages.config (C#)
9
+
10
+ Never touches .env files (security).
11
+ """
12
+
13
+ import ast
14
+ import re
15
+ import xml.etree.ElementTree as ET
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import structlog
20
+
21
+ logger = structlog.get_logger(__name__)
22
+
23
+ # Known external API client libraries
24
+ KNOWN_API_CLIENTS: dict[str, str] = {
25
+ "stripe": "Stripe Payment API",
26
+ "boto3": "AWS Services",
27
+ "botocore": "AWS Services",
28
+ "twilio": "Twilio Communication API",
29
+ "sendgrid": "SendGrid Email API",
30
+ "slack_sdk": "Slack API",
31
+ "google.cloud": "Google Cloud Services",
32
+ "azure": "Azure Services",
33
+ "requests_oauthlib": "OAuth Provider",
34
+ "httpx": "HTTP Client (external calls)",
35
+ "aiohttp": "Async HTTP Client (external calls)",
36
+ "requests": "HTTP Client (external calls)",
37
+ "urllib3": "HTTP Client (external calls)",
38
+ "redis": "Redis Database",
39
+ "pymongo": "MongoDB",
40
+ "psycopg2": "PostgreSQL",
41
+ "mysql": "MySQL",
42
+ "elasticsearch": "Elasticsearch",
43
+ "kafka": "Apache Kafka",
44
+ "pika": "RabbitMQ",
45
+ "celery": "Celery Task Queue",
46
+ }
47
+
48
+ # Patterns for env var extraction
49
+ ENV_VAR_PATTERNS: list[str] = [
50
+ r'os\.getenv\(["\']([A-Z_]+(?:URL|API|ENDPOINT|HOST|SERVICE)[A-Z_]*)["\']',
51
+ r'os\.environ\[["\']([A-Z_]+(?:URL|API|ENDPOINT|HOST|SERVICE)[A-Z_]*)["\']',
52
+ r'os\.environ\.get\(["\']([A-Z_]+(?:URL|API|ENDPOINT|HOST|SERVICE)[A-Z_]*)["\']',
53
+ ]
54
+
55
+ # Known external NuGet package prefixes → description
56
+ KNOWN_NUGET_CLIENTS: dict[str, str] = {
57
+ "Stripe.net": "Stripe Payment API",
58
+ "AWSSDK": "AWS Services",
59
+ "Twilio": "Twilio Communication API",
60
+ "SendGrid": "SendGrid Email API",
61
+ "SlackAPI": "Slack API",
62
+ "Google.Cloud": "Google Cloud Services",
63
+ "Azure": "Azure Services",
64
+ "Microsoft.Azure": "Azure Services",
65
+ "StackExchange.Redis": "Redis Database",
66
+ "MongoDB.Driver": "MongoDB",
67
+ "Npgsql": "PostgreSQL",
68
+ "MySql.Data": "MySQL",
69
+ "MySqlConnector": "MySQL",
70
+ "Elasticsearch.Net": "Elasticsearch",
71
+ "NEST": "Elasticsearch",
72
+ "Confluent.Kafka": "Apache Kafka",
73
+ "RabbitMQ.Client": "RabbitMQ",
74
+ "MassTransit": "Message Bus",
75
+ "Refit": "HTTP Client (typed REST)",
76
+ "RestSharp": "HTTP Client (external calls)",
77
+ "Flurl": "HTTP Client (external calls)",
78
+ "Polly": "Resilience / Retry Policy",
79
+ "Serilog": "Structured Logging",
80
+ "NLog": "Logging",
81
+ "Hangfire": "Background Jobs",
82
+ "Quartz": "Job Scheduler",
83
+ "PayPal": "PayPal Payment API",
84
+ "Braintree": "Braintree Payment API",
85
+ "Okta": "Okta Identity API",
86
+ "Auth0": "Auth0 Identity API",
87
+ "Datadog": "Datadog APM",
88
+ "OpenTelemetry": "OpenTelemetry",
89
+ "NewRelic": "New Relic APM",
90
+ "Sentry": "Sentry Error Tracking",
91
+ "LaunchDarkly": "Feature Flags",
92
+ "Contentful": "Contentful CMS API",
93
+ "Algolia": "Algolia Search API",
94
+ "Segment": "Segment Analytics",
95
+ "Mixpanel": "Mixpanel Analytics",
96
+ "SendinBlue": "SendinBlue Email API",
97
+ "PusherServer": "Pusher WebSocket API",
98
+ "FirebaseAdmin": "Firebase API",
99
+ }
100
+
101
+ # URL pattern
102
+ URL_PATTERN = re.compile(
103
+ r'["\']'
104
+ r'(https?://(?!localhost|127\.0\.0\.1|0\.0\.0\.0)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}[^\s"\']*)'
105
+ r'["\']'
106
+ )
107
+
108
+
109
+ @dataclass
110
+ class ExternalDependency:
111
+ """Represents a detected external dependency."""
112
+
113
+ name: str
114
+ dep_type: str # 'env_var', 'api_client', 'url', 'config'
115
+ source_file: str
116
+ line_number: int | None = None
117
+ description: str = ""
118
+
119
+ def to_dict(self) -> dict[str, str | int | None]:
120
+ """Convert to dictionary."""
121
+ return {
122
+ "name": self.name,
123
+ "type": self.dep_type,
124
+ "file": self.source_file,
125
+ "line": self.line_number,
126
+ "description": self.description,
127
+ }
128
+
129
+
130
+ @dataclass
131
+ class ScanResult:
132
+ """Result of external dependency scan."""
133
+
134
+ dependencies: list[ExternalDependency] = field(default_factory=list)
135
+ files_scanned: int = 0
136
+ errors: list[str] = field(default_factory=list)
137
+
138
+ @property
139
+ def env_vars(self) -> list[ExternalDependency]:
140
+ """Get environment variable dependencies."""
141
+ return [d for d in self.dependencies if d.dep_type == "env_var"]
142
+
143
+ @property
144
+ def api_clients(self) -> list[ExternalDependency]:
145
+ """Get API client dependencies."""
146
+ return [d for d in self.dependencies if d.dep_type == "api_client"]
147
+
148
+ @property
149
+ def urls(self) -> list[ExternalDependency]:
150
+ """Get URL dependencies."""
151
+ return [d for d in self.dependencies if d.dep_type == "url"]
152
+
153
+ @property
154
+ def nuget_packages(self) -> list[ExternalDependency]:
155
+ """Get NuGet package dependencies."""
156
+ return [d for d in self.dependencies if d.dep_type == "nuget_package"]
157
+
158
+
159
+ def _scan_file_for_env_vars(file_path: Path, content: str) -> list[ExternalDependency]:
160
+ """Scan file content for environment variable references.
161
+
162
+ Args:
163
+ file_path: Path to the file being scanned.
164
+ content: File content.
165
+
166
+ Returns:
167
+ List of detected env var dependencies.
168
+ """
169
+ deps: list[ExternalDependency] = []
170
+ seen: set[str] = set()
171
+
172
+ for pattern in ENV_VAR_PATTERNS:
173
+ for match in re.finditer(pattern, content):
174
+ var_name = match.group(1)
175
+ if var_name not in seen:
176
+ seen.add(var_name)
177
+ # Find line number
178
+ line_num = content[: match.start()].count("\n") + 1
179
+ deps.append(
180
+ ExternalDependency(
181
+ name=var_name,
182
+ dep_type="env_var",
183
+ source_file=str(file_path),
184
+ line_number=line_num,
185
+ description="Environment variable for external endpoint",
186
+ )
187
+ )
188
+
189
+ return deps
190
+
191
+
192
+ def _scan_file_for_api_clients(
193
+ file_path: Path, content: str
194
+ ) -> list[ExternalDependency]:
195
+ """Scan file for known API client library imports.
196
+
197
+ Args:
198
+ file_path: Path to the file being scanned.
199
+ content: File content.
200
+
201
+ Returns:
202
+ List of detected API client dependencies.
203
+ """
204
+ deps: list[ExternalDependency] = []
205
+ seen: set[str] = set()
206
+
207
+ try:
208
+ tree = ast.parse(content)
209
+ except SyntaxError:
210
+ return deps
211
+
212
+ for node in ast.walk(tree):
213
+ module_name: str | None = None
214
+
215
+ if isinstance(node, ast.Import):
216
+ for alias in node.names:
217
+ module_name = alias.name.split(".")[0]
218
+ elif isinstance(node, ast.ImportFrom):
219
+ if node.module:
220
+ module_name = node.module.split(".")[0]
221
+
222
+ if module_name and module_name in KNOWN_API_CLIENTS:
223
+ if module_name not in seen:
224
+ seen.add(module_name)
225
+ deps.append(
226
+ ExternalDependency(
227
+ name=module_name,
228
+ dep_type="api_client",
229
+ source_file=str(file_path),
230
+ line_number=getattr(node, "lineno", None),
231
+ description=KNOWN_API_CLIENTS[module_name],
232
+ )
233
+ )
234
+
235
+ return deps
236
+
237
+
238
+ def _scan_file_for_urls(file_path: Path, content: str) -> list[ExternalDependency]:
239
+ """Scan file for hardcoded external URLs.
240
+
241
+ Args:
242
+ file_path: Path to the file being scanned.
243
+ content: File content.
244
+
245
+ Returns:
246
+ List of detected URL dependencies.
247
+ """
248
+ deps: list[ExternalDependency] = []
249
+ seen: set[str] = set()
250
+
251
+ for match in URL_PATTERN.finditer(content):
252
+ url = match.group(1)
253
+ # Extract domain
254
+ domain_match = re.search(r"https?://([^/]+)", url)
255
+ domain = domain_match.group(1) if domain_match else url
256
+
257
+ if domain not in seen:
258
+ seen.add(domain)
259
+ line_num = content[: match.start()].count("\n") + 1
260
+ deps.append(
261
+ ExternalDependency(
262
+ name=domain,
263
+ dep_type="url",
264
+ source_file=str(file_path),
265
+ line_number=line_num,
266
+ description=f"External URL: {url[:50]}...",
267
+ )
268
+ )
269
+
270
+ return deps
271
+
272
+
273
+ def _describe_nuget_package(package_id: str) -> str:
274
+ """Return a human-readable description for a NuGet package ID.
275
+
276
+ Args:
277
+ package_id: NuGet package identifier (e.g. 'StackExchange.Redis').
278
+
279
+ Returns:
280
+ Description from KNOWN_NUGET_CLIENTS or generic fallback.
281
+ """
282
+ for prefix, description in KNOWN_NUGET_CLIENTS.items():
283
+ if package_id.startswith(prefix):
284
+ return description
285
+ return f"NuGet package: {package_id}"
286
+
287
+
288
+ def _scan_csproj_for_packages(csproj_path: Path) -> list[ExternalDependency]:
289
+ """Extract PackageReference entries from a .csproj file.
290
+
291
+ Args:
292
+ csproj_path: Path to the .csproj file.
293
+
294
+ Returns:
295
+ List of ExternalDependency for each PackageReference found.
296
+ """
297
+ deps: list[ExternalDependency] = []
298
+ try:
299
+ tree = ET.parse(csproj_path) # noqa: S314 — local file, not from network
300
+ except ET.ParseError as e:
301
+ logger.warning("csproj_parse_error", file=str(csproj_path), error=str(e))
302
+ return deps
303
+ except OSError as e:
304
+ logger.warning("csproj_read_error", file=str(csproj_path), error=str(e))
305
+ return deps
306
+
307
+ root = tree.getroot()
308
+ seen: set[str] = set()
309
+
310
+ # ElementTree strips namespace prefixes; .csproj elements are un-namespaced
311
+ for ref in root.iter("PackageReference"):
312
+ package_id = ref.get("Include") or ref.get("Update") or ""
313
+ if not package_id or package_id in seen:
314
+ continue
315
+ seen.add(package_id)
316
+ deps.append(
317
+ ExternalDependency(
318
+ name=package_id,
319
+ dep_type="nuget_package",
320
+ source_file=str(csproj_path),
321
+ line_number=None,
322
+ description=_describe_nuget_package(package_id),
323
+ )
324
+ )
325
+
326
+ logger.debug(
327
+ "csproj_scanned",
328
+ file=str(csproj_path),
329
+ packages_found=len(deps),
330
+ )
331
+ return deps
332
+
333
+
334
+ def _scan_packages_config(packages_config_path: Path) -> list[ExternalDependency]:
335
+ """Extract package entries from a legacy packages.config file.
336
+
337
+ Args:
338
+ packages_config_path: Path to the packages.config file.
339
+
340
+ Returns:
341
+ List of ExternalDependency for each package found.
342
+ """
343
+ deps: list[ExternalDependency] = []
344
+ try:
345
+ tree = ET.parse(packages_config_path) # noqa: S314 — local file, not from network
346
+ except ET.ParseError as e:
347
+ logger.warning(
348
+ "packages_config_parse_error",
349
+ file=str(packages_config_path),
350
+ error=str(e),
351
+ )
352
+ return deps
353
+ except OSError as e:
354
+ logger.warning(
355
+ "packages_config_read_error",
356
+ file=str(packages_config_path),
357
+ error=str(e),
358
+ )
359
+ return deps
360
+
361
+ root = tree.getroot()
362
+ seen: set[str] = set()
363
+
364
+ for pkg in root.iter("package"):
365
+ package_id = pkg.get("id") or ""
366
+ if not package_id or package_id in seen:
367
+ continue
368
+ seen.add(package_id)
369
+ deps.append(
370
+ ExternalDependency(
371
+ name=package_id,
372
+ dep_type="nuget_package",
373
+ source_file=str(packages_config_path),
374
+ line_number=None,
375
+ description=_describe_nuget_package(package_id),
376
+ )
377
+ )
378
+
379
+ logger.debug(
380
+ "packages_config_scanned",
381
+ file=str(packages_config_path),
382
+ packages_found=len(deps),
383
+ )
384
+ return deps
385
+
386
+
387
+ def scan_directory(directory: str | Path) -> ScanResult:
388
+ """Scan a directory for external dependencies.
389
+
390
+ Args:
391
+ directory: Path to directory to scan.
392
+
393
+ Returns:
394
+ ScanResult with all detected dependencies.
395
+ """
396
+ result = ScanResult()
397
+ dir_path = Path(directory)
398
+
399
+ if not dir_path.exists():
400
+ result.errors.append(f"Directory not found: {directory}")
401
+ return result
402
+
403
+ # Find all Python files, excluding common non-code directories
404
+ exclude_dirs = {
405
+ "__pycache__",
406
+ ".git",
407
+ ".venv",
408
+ "venv",
409
+ "node_modules",
410
+ ".tox",
411
+ "dist",
412
+ "build",
413
+ }
414
+
415
+ for py_file in dir_path.rglob("*.py"):
416
+ # Skip excluded directories
417
+ if any(excluded in py_file.parts for excluded in exclude_dirs):
418
+ continue
419
+
420
+ try:
421
+ content = py_file.read_text(encoding="utf-8")
422
+ result.files_scanned += 1
423
+
424
+ # Scan for all dependency types
425
+ result.dependencies.extend(_scan_file_for_env_vars(py_file, content))
426
+ result.dependencies.extend(_scan_file_for_api_clients(py_file, content))
427
+ result.dependencies.extend(_scan_file_for_urls(py_file, content))
428
+
429
+ except (OSError, UnicodeDecodeError) as e:
430
+ result.errors.append(f"Error reading {py_file}: {e}")
431
+ logger.warning("file_read_error", file=str(py_file), error=str(e))
432
+
433
+ # C# NuGet packages: modern SDK-style (.csproj PackageReference)
434
+ csproj_exclude = {"bin", "obj", ".git", "node_modules"}
435
+ for csproj_file in dir_path.rglob("*.csproj"):
436
+ if any(excluded in csproj_file.parts for excluded in csproj_exclude):
437
+ continue
438
+ result.dependencies.extend(_scan_csproj_for_packages(csproj_file))
439
+ result.files_scanned += 1
440
+
441
+ # C# NuGet packages: legacy packages.config (pre-SDK projects)
442
+ for pkg_config in dir_path.rglob("packages.config"):
443
+ if any(excluded in pkg_config.parts for excluded in csproj_exclude):
444
+ continue
445
+ result.dependencies.extend(_scan_packages_config(pkg_config))
446
+ result.files_scanned += 1
447
+
448
+ logger.info(
449
+ "scan_complete",
450
+ files_scanned=result.files_scanned,
451
+ dependencies_found=len(result.dependencies),
452
+ )
453
+
454
+ return result
455
+
456
+
457
+ def generate_codemap_section(result: ScanResult) -> str:
458
+ """Generate CODE_MAP.md External Dependencies section.
459
+
460
+ Args:
461
+ result: Scan result with dependencies.
462
+
463
+ Returns:
464
+ Markdown formatted section.
465
+ """
466
+ output = """## External Dependencies
467
+ *Auto-detected by external_detector.py*
468
+
469
+ """
470
+
471
+ if not result.dependencies:
472
+ output += "No external dependencies detected.\n"
473
+ return output
474
+
475
+ # Group by type
476
+ if result.env_vars:
477
+ output += "### Environment Variables (External Endpoints)\n\n"
478
+ output += "| Variable | File | Line | Notes |\n"
479
+ output += "|----------|------|------|-------|\n"
480
+ for dep in result.env_vars:
481
+ output += f"| `{dep.name}` | `{Path(dep.source_file).name}` | {dep.line_number or '-'} | {dep.description} |\n"
482
+ output += "\n"
483
+
484
+ if result.api_clients:
485
+ output += "### External API Clients\n\n"
486
+ output += "| Library | Service | File | Line |\n"
487
+ output += "|---------|---------|------|------|\n"
488
+ for dep in result.api_clients:
489
+ output += f"| `{dep.name}` | {dep.description} | `{Path(dep.source_file).name}` | {dep.line_number or '-'} |\n"
490
+ output += "\n"
491
+
492
+ if result.urls:
493
+ output += "### Hardcoded External URLs\n\n"
494
+ output += "| Domain | File | Line | Notes |\n"
495
+ output += "|--------|------|------|-------|\n"
496
+ for dep in result.urls:
497
+ output += f"| `{dep.name}` | `{Path(dep.source_file).name}` | {dep.line_number or '-'} | ⚠️ Consider using env var |\n"
498
+ output += "\n"
499
+
500
+ return output
501
+
502
+
503
+ def scan_and_report(directory: str) -> str:
504
+ """Scan directory and return formatted report.
505
+
506
+ Args:
507
+ directory: Path to scan.
508
+
509
+ Returns:
510
+ Status message with summary.
511
+ """
512
+ result = scan_directory(directory)
513
+
514
+ if result.errors:
515
+ return f"Scan completed with errors: {result.errors}"
516
+
517
+ summary = (
518
+ f"Scanned {result.files_scanned} files. "
519
+ f"Found: {len(result.env_vars)} env vars, "
520
+ f"{len(result.api_clients)} API clients, "
521
+ f"{len(result.urls)} URLs."
522
+ )
523
+
524
+ return summary