Setting the file. One moment. Scan Provider Usage · Migrate To Parallel · parallel-web/parallel-agent-skills · Skills Docsdef scan
— line 308
This file
- Number
- 1.8
- Position
- 8 of 9
- Type
- Python
- Size
- 23 KB
- Lines
- 389
scripts/scan_provider_usage.py
Python·389 lines·23 KB
13from pathlib import Path
14from typing import Iterable, Optional
15
16
17SKILL_ROOT = Path(__file__).resolve().parent.parent
18
19PROVIDERS = ("exa", "tavily", "perplexity", "firecrawl")
20
21
22IGNORED_DIRS = {
23 ".git",
24 ".hg",
25 ".svn",
26 ".next",
27 ".nuxt",
28 ".pytest_cache",
29 ".ruff_cache",
30 ".mypy_cache",
31 ".tox",
32 ".venv",
33 "venv",
34 "node_modules",
35 "dist",
36 "build",
37 "coverage",
38 "target",
39 "vendor",
40}
41
42TEXT_SUFFIXES = {
43 ".py", ".pyi", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
44 ".json", ".jsonc", ".toml", ".yaml", ".yml", ".md", ".mdx",
45 ".txt", ".ini", ".cfg", ".conf", ".sh", ".bash", ".zsh", ".fish",
46 ".env", ".go", ".rs", ".java", ".kt", ".kts", ".rb", ".php", ".cs",
47 ".xml", ".gradle", ".swift", ".lock", ".snap",
48}
49
50SPECIAL_TEXT_NAMES = {
51 "Dockerfile", "Makefile", "Procfile", "Pipfile", "Pipfile.lock",
52 "poetry.lock", "uv.lock", "requirements.txt", "package-lock.json",
53 "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
54 "requirements.in", "pom.xml", "build.gradle", "build.gradle.kts",
55 "go.mod", "go.sum", "Cargo.toml", "Cargo.lock", "Gemfile",
56 "Gemfile.lock", "composer.json", "composer.lock", "Package.swift",
57 "Package.resolved",
58}
59
60MANIFEST_NAMES = {
61 "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
62 "bun.lock", "bun.lockb", "pyproject.toml", "poetry.lock", "uv.lock",
63 "Pipfile", "Pipfile.lock", "requirements.txt", "requirements.in",
64 "pom.xml", "build.gradle", "build.gradle.kts", "go.mod", "go.sum",
65 "Cargo.toml", "Cargo.lock", "Gemfile", "Gemfile.lock", "composer.json",
66 "composer.lock", "Package.swift", "Package.resolved",
67}
68
69LIMITATIONS = [
70 "Generated and dependency directories are skipped by default.",
71 "Binary, unreadable, oversized, and symlinked files are skipped.",
72 "Provider-neutral downstream consumers require manual data-flow tracing.",
73]
74
75FIXTURE_MARKERS = {
76 "fixture", "fixtures", "__fixtures__", "mock", "mocks",
77 "snapshot", "snapshots", "__snapshots__", "testdata",
78}
79
80
81@dataclass(frozen=True)
82class Rule:
83 provider: str
84 kind: str
85 name: str
86 pattern: re.Pattern[str]
87 legacy: bool = True
88 contextual: bool = False
89
90
91def rule(
92 provider: str,
93 kind: str,
94 name: str,
95 pattern: str,
96 *,
97 legacy: bool = True,
98 contextual: bool = False,
99) -> Rule:
100 return Rule(provider, kind, name, re.compile(pattern, re.IGNORECASE), legacy, contextual)
101
102
103RULES = [
104 rule("exa", "reference", "exa-provider-name", r"(?<![\w-])exa(?![\w-])", legacy=False),
105 rule("exa", "dependency", "exa-package", r"(?<![\w-])(exa-py|exa_py|exa-js|@exalabs/ai-sdk|@ai-sdk/exa|@langchain/exa|langchain[-_]exa|exa-mcp-server|mcp-server-exa|exa-mcp)(?![\w-])"),
106 rule("exa", "runtime", "exa-import-client", r"\b(from\s+exa_py\s+import|AsyncExa\b|ExaSearchRetriever\b|Exa\s*\(|new\s+Exa\s*\(|import\s+Exa\s+from\s+[\"']exa-js[\"']|require\s*\(\s*[\"']exa-js[\"']\s*\))"),
107 rule("exa", "runtime", "exa-search-call", r"\bexa\s*\??\.\s*search\s*(?:\?\.)?\s*\("),
108 rule("exa", "runtime", "exa-endpoint", r"\b(?:https?://)?(?:api|mcp)\.exa\.ai\b"),
109 rule("exa", "config", "exa-api-key", r"\bEXA_API_KEY\b"),
110 rule("exa", "runtime", "exa-legacy-method", r"\b(search_and_contents|searchAndContents|get_contents|getContents|find_similar|findSimilar|find_similar_and_contents|findSimilarAndContents|stream_answer|streamAnswer|stream_search|streamSearch|web_search_exa|web_fetch_exa|web_search_advanced_exa)\b"),
111 rule("exa", "runtime", "exa-operation", r"\.(?:answer|research|stream_answer|streamAnswer|stream_search|streamSearch)\s*\(", contextual=True),
112 rule("exa", "request-contract", "exa-request-field", r"\b(numResults|num_results|startPublishedDate|start_published_date|endPublishedDate|end_published_date|startCrawlDate|start_crawl_date|endCrawlDate|end_crawl_date|userLocation|user_location|additionalQueries|additional_queries|outputSchema|output_schema|systemPrompt|system_prompt|includeDomains|include_domains|excludeDomains|exclude_domains|maxAgeHours|max_age_hours|livecrawl|subpages|subpageTarget|subpage_target)\b|[\"']?(?:type|category|contents|context|moderation|compliance|stream)[\"']?\s*[:=]", contextual=True),
113 rule("exa", "response-contract", "exa-response-field", r"\b(publishedDate|highlightScores|highlights|costDollars|resolvedSearchType|searchTime|autoDate)\b|(?:\.|\[['\"])(?:text|summary|output|author|image|favicon|score)(?:\b|['\"]\])|[\"'](?:text|summary|output|author|image|favicon|score)[\"']\s*:", contextual=True),
114 rule("tavily", "reference", "tavily-provider-name", r"(?<![\w-])tavily(?![\w-])", legacy=False),
115 rule("tavily", "dependency", "tavily-package", r"(?<![\w-])(tavily-python|@tavily/core|@tavily/ai-sdk|@langchain/tavily|langchain[-_]tavily|llama-index-tools-tavily-research|tavily-mcp|mcp-server-tavily)(?![\w-])"),
116 rule("tavily", "runtime", "tavily-import-client", r"\b(from\s+tavily\s+import|TavilyClient\b|AsyncTavilyClient\b|TavilySearchResults\b|TavilySearchAPIRetriever\b|TavilySearch\b|TavilyToolSpec\b|TavilySearchTool\b|tavilySearch\b|tavily\s*\()"),
117 rule("tavily", "runtime", "tavily-search-call", r"\btavily\s*\??\.\s*search\s*(?:\?\.)?\s*\("),
118 rule("tavily", "runtime", "tavily-endpoint", r"\b(?:https?://)?(?:api|mcp)\.tavily\.com\b"),
119 rule("tavily", "config", "tavily-keyless-mode", r"\bX-Tavily-Access-Mode\b|\btavily-search\b"),
120 rule("tavily", "config", "tavily-api-key", r"\bTAVILY_API_KEY\b"),
121 rule("tavily", "runtime", "tavily-legacy-method", r"\b(qna_search|get_search_context|searchQNA|searchContext)\s*\("),
122 rule("tavily", "runtime", "tavily-surface-method", r"\.(?:extract|crawl|map|research)\s*\(", contextual=True),
123 rule("tavily", "request-contract", "tavily-request-field", r"\b(search_depth|searchDepth|extract_depth|extractDepth|chunks_per_source|chunksPerSource|max_results|maxResults|include_answer|includeAnswer|include_raw_content|includeRawContent|include_images|includeImages|include_image_descriptions|includeImageDescriptions|include_domains|includeDomains|exclude_domains|excludeDomains|include_favicon|includeFavicon|include_usage|includeUsage|auto_parameters|autoParameters|exact_match|exactMatch|safe_search|safeSearch|time_range|timeRange|start_date|startDate|end_date|endDate|days)\b|[\"']?(?:topic|country|format)[\"']?\s*[:=]", contextual=True),
124 rule("tavily", "response-contract", "tavily-response-field", r"\b(raw_content|rawContent|response_time|responseTime|request_id|requestId|follow_up_questions|followUpQuestions|published_date|publishedDate)\b|(?:\.|\[['\"])(?:score|content|answer|images|favicon)(?:\b|['\"]\])|[\"'](?:score|content|answer|images|favicon)[\"']\s*:", contextual=True),
125 rule("perplexity", "dependency", "perplexity-package", r"(?<![\w-])(perplexityai|@perplexity-ai/perplexity_ai|@ai-sdk/perplexity|@perplexity-ai/ai-sdk|@perplexity-ai/mcp-server|@langchain/perplexity|@langchain/community/chat_models/perplexity|langchain[-_]perplexity|llama-index-llms-perplexity|llama_index_llms_perplexity)(?![\w-])"),
126 rule("perplexity", "runtime", "perplexity-import-client", r"\b(from\s+perplexity\s+import\s+(?:Async)?Perplexity\b|from\s+llama_index\.llms\.perplexity\s+import\s+Perplexity\b|ChatPerplexity\b|import\s+Perplexity\s+from\s+[\"']@perplexity-ai/perplexity_ai[\"']|require\s*\(\s*[\"']@perplexity-ai/perplexity_ai[\"']\s*\)|perplexitySearch\s*\()"),
127 rule("perplexity", "runtime", "perplexity-endpoint", r"\b(?:https?://)?api\.perplexity\.ai\b"),
128 rule("perplexity", "config", "perplexity-api-key", r"\b(?:PERPLEXITY_API_KEY|PPLX_API_KEY)\b"),
129 rule("perplexity", "runtime", "perplexity-model", r"(?<![\w-])sonar-(?:pro|deep-research|reasoning(?:-pro)?)(?![\w-])"),
130 rule("perplexity", "runtime", "perplexity-routed-model", r"(?<![\w-])perplexity/sonar(?![\w-])"),
131 rule("perplexity", "runtime", "perplexity-sonar-model", r"[\"']?model[\"']?\s*[:=]\s*[\"']sonar[\"']", contextual=True),
132 rule("perplexity", "runtime", "perplexity-search-call", r"\.search\.create\s*\(", contextual=True),
133 rule("perplexity", "runtime", "perplexity-answer-call", r"\.(?:chat\.completions|responses)\.create\s*\(", contextual=True),
134 rule("perplexity", "runtime", "perplexity-agent-tool", r"(?:[\"']type[\"']|\btype)\s*:\s*[\"'](?:web_search|sandbox|fetch_url|people_search|finance_search|mcp|function)[\"']", contextual=True),
135 rule("perplexity", "request-contract", "perplexity-request-field", r"\b(max_results|search_context_size|max_tokens|max_tokens_per_page|max_urls|max_steps|max_tool_calls|max_output_tokens|parallel_tool_calls|previous_response_id|search_language_filter|search_domain_filter|search_after_date_filter|search_before_date_filter|last_updated_after_filter|last_updated_before_filter|search_recency_filter|search_type|return_images|return_related_questions|return_videos|image_domain_filter|image_format_filter|image_url|file_url|pdf_url|media_response|search_mode|response_format|web_search_options)\b|[\"']?(?:query|country|stream|tools|tool_choice|preset|instructions|reasoning)[\"']?\s*[:=]", contextual=True),
136 rule("perplexity", "response-contract", "perplexity-response-field", r"\b(search_results|people_search_results|finance_results|related_questions|citation_tokens|num_search_queries|reasoning_tokens|last_updated|server_time|output_text|function_call_output|tool_calls_details|call_id|annotations)\b|(?:\.|\[['\"])(?:snippet|citations|images|usage)(?:\b|['\"]\])|[\"'](?:snippet|citations|images|usage)[\"']\s*:", contextual=True),
137 rule("firecrawl", "dependency", "firecrawl-package", r"(?<![\w-])(@mendable/firecrawl-js|firecrawl-py|firecrawl-mcp|@mendableai/mcp-server-firecrawl|(?:mendableai|firecrawl)/firecrawl-go)(?![\w-])|[\"']firecrawl[\"']\s*:|(?<![\w-])firecrawl@\d|(?:^|\n)\s*firecrawl(?:\[[^\]]+\])?\s*(?:[<>=~!]|$)"),
138 rule("firecrawl", "runtime", "firecrawl-import-client", r"\b(from\s+firecrawl(?:\.(?:v1|v2|types|v2\.types))?\s+import\s+|FirecrawlApp\b|AsyncFirecrawl\b|FireCrawlLoader\b|import\s*\{[^}]*\bFirecrawl\b[^}]*\}\s*from\s*[\"']firecrawl[\"']|require\s*\(\s*[\"'](?:firecrawl|@mendable/firecrawl-js)[\"']\s*\))"),
139 rule("firecrawl", "runtime", "firecrawl-endpoint", r"\b(?:https?://)?(?:api|mcp)\.firecrawl\.dev\b"),
140 rule("firecrawl", "config", "firecrawl-config", r"\b(?:FIRECRAWL_API_KEY|FIRECRAWL_API_URL)\b"),
141 # Firecrawl adds MCP tools over time. Match their lowercase tool namespace
142 # without misclassifying uppercase FIRECRAWL_* environment variables.
143 rule("firecrawl", "runtime", "firecrawl-mcp-tool", r"(?-i:\bfirecrawl_[a-z][a-z0-9_]*\b)"),
144 rule("firecrawl", "runtime", "firecrawl-legacy-method", r"\b(scrapeUrl|scrape_url|crawlUrl|crawl_url|asyncCrawlUrl|async_crawl_url|mapUrl|map_url|batchScrapeUrls|batch_scrape_urls|asyncBatchScrapeUrls|async_batch_scrape_urls|checkCrawlStatus|check_crawl_status|checkCrawlErrors|check_crawl_errors|checkBatchScrapeStatus|check_batch_scrape_status|checkBatchScrapeErrors|check_batch_scrape_errors|asyncExtract|async_extract|generateLLMsText|generate_llms_text|checkGenerateLLMsTextStatus|get_generate_llms_text_status|crawlUrlAndWatch|crawl_url_and_watch|batchScrapeUrlsAndWatch|batch_scrape_urls_and_watch)\b"),
145 rule("firecrawl", "runtime", "firecrawl-surface-method", r"\.(?:scrape|search|map|crawl|startCrawl|start_crawl|getCrawlStatus|get_crawl_status|getCrawlErrors|get_crawl_errors|getActiveCrawls|get_active_crawls|cancelCrawl|cancel_crawl|batchScrape|batch_scrape|startBatchScrape|start_batch_scrape|getBatchScrapeStatus|get_batch_scrape_status|getBatchScrapeErrors|get_batch_scrape_errors|cancelBatchScrape|cancel_batch_scrape|extract|startExtract|start_extract|getExtractStatus|get_extract_status|agent|startAgent|start_agent|getAgentStatus|get_agent_status|cancelAgent|cancel_agent|parse|interact|stopInteraction|stop_interaction|scrapeExecute|scrape_execute|stopInteractiveBrowser|stop_interactive_browser|deleteScrapeBrowser|delete_scrape_browser|browser|browserExecute|browser_execute|deleteBrowser|delete_browser|listBrowsers|list_browsers|createMonitor|create_monitor|listMonitors|list_monitors|getMonitor|get_monitor|updateMonitor|update_monitor|deleteMonitor|delete_monitor|runMonitor|run_monitor|listMonitorChecks|list_monitor_checks|getMonitorCheck|get_monitor_check|searchPapers|search_papers|getPaper|inspect_paper|read_paper|similarPapers|related_papers|searchGithub|search_github|watcher)\s*\(", contextual=True),
146 rule("firecrawl", "request-contract", "firecrawl-request-field", r"\b(scrapeOptions|scrape_options|includeDomains|include_domains|excludeDomains|exclude_domains|ignoreInvalidURLs|ignore_invalid_urls|onlyMainContent|only_main_content|maxAge|max_age|minAge|min_age|storeInCache|store_in_cache|zeroDataRetention|zero_data_retention|threatProtection|threat_protection|riskScoreThreshold|risk_score_threshold|includePaths|include_paths|excludePaths|exclude_paths|maxDiscoveryDepth|max_discovery_depth|ignoreSitemap|ignore_sitemap|includeSubdomains|include_subdomains|allowExternalLinks|allow_external_links|crawlEntireDomain|crawl_entire_domain|maxConcurrency|max_concurrency|enableWebSearch|enable_web_search|showSources|show_sources|maxCredits|max_credits|strictConstrainToURLs|strict_constrain_to_urls|activityTtl|activity_ttl|skipTlsVerification|skip_tls_verification|waitFor|wait_for|blockAds|block_ads|removeBase64Images|remove_base64_images|changeTracking|change_tracking|retentionDays|retention_days|judgeEnabled|judge_enabled|saveChanges|save_changes)\b|[\"']?(?:query|url|urls|limit|sources|categories|authors|intent|targets|schedule|notification|goal|tbs|location|country|timeout|formats|actions|headers|cookies|proxy|mobile|profile|prompt|schema|model|webhook|parsers|enterprise|anon|zdr)[\"']?\s*[:=]", contextual=True),
147 rule("firecrawl", "response-contract", "firecrawl-response-field", r"\b(rawHtml|raw_html|scrapeId|scrape_id|creditsUsed|credits_used|expiresAt|expires_at|completedAt|completed_at|totalPages|total_pages|concurrencyLimited|concurrency_limited|concurrencyQueueDurationMs|concurrency_queue_duration_ms|liveViewUrl|live_view_url|interactiveLiveViewUrl|interactive_live_view_url|cdpUrl|cdp_url|changeTracking|change_tracking|paperId|paper_id|primaryId|primary_id|nextRunAt|next_run_at|lastRunAt|last_run_at|estimatedCreditsPerMonth|estimated_credits_per_month|currentCheckId|current_check_id)\b|(?:\.|\[['\"])(?:markdown|summary|json|html|metadata|links|images|actions|branding|product|menu|warning|next|data|success|status|result|sources|output|stdout)(?:\b|['\"]\])|[\"'](?:markdown|summary|json|html|metadata|links|images|actions|branding|product|menu|warning|next|data|success|status|result|sources|output|stdout)[\"']\s*:", contextual=True),
148]
149
150
151@dataclass(frozen=True)
152class Finding:
153 provider: str
154 kind: str
155 rule: str
156 path: str
157 line: int
158 legacy: bool
159
160
161def parse_args() -> argparse.Namespace:
162 parser = argparse.ArgumentParser(description=__doc__)
163 parser.add_argument("root", nargs="?", default=".", help="Repository root (default: current directory)")
164 parser.add_argument("--format", choices=("markdown", "json"), default="markdown")
165 parser.add_argument("--provider", action="append", choices=PROVIDERS, default=[], help="Scan only this provider; repeatable")
166 parser.add_argument("--exclude", action="append", default=[], help="Extra directory name or root-relative path to exclude; repeatable")
167 parser.add_argument("--max-file-bytes", type=int, default=10_000_000, help="Skip larger files (default: 10000000)")
168 parser.add_argument("--fail-on-legacy", action="store_true", help="Exit 1 when any legacy finding remains")
169 return parser.parse_args()
170
171
172def is_text_candidate(path: Path) -> bool:
173 name = path.name
174 if name in SPECIAL_TEXT_NAMES or name in MANIFEST_NAMES:
175 return True
176 if name.startswith(".env"):
177 return True
178 if name.startswith("requirements") and name.endswith(".txt"):
179 return True
180 return path.suffix.lower() in TEXT_SUFFIXES
181
182
183def is_within(path: Path, directory: Path) -> bool:
184 try:
185 path.resolve().relative_to(directory.resolve())
186 except (OSError, ValueError):
187 return False
188 return True
189
190
191def should_ignore(path: Path, root: Path, extra_excludes: set[str]) -> bool:
192 # A project-scoped installation lives under the repository being scanned.
193 # Ignore this skill's own migration references without ignoring other skills,
194 # which may contain real provider integrations that also need migration.
195 if is_within(path, SKILL_ROOT):
196 return True
197 try:
198 relative = path.relative_to(root)
199 except ValueError:
200 return True
201 relative_text = relative.as_posix()
202 for part in relative.parts[:-1]:
203 if part in IGNORED_DIRS or part in extra_excludes:
204 return True
205 return any(relative_text == item or relative_text.startswith(item.rstrip("/") + "/") for item in extra_excludes)
206
207
208def iter_files(root: Path, extra_excludes: set[str], max_bytes: int) -> Iterable[Path]:
209 for current, dirs, files in os.walk(root):
210 current_path = Path(current)
211 dirs[:] = sorted(
212 d
213 for d in dirs
214 if d not in IGNORED_DIRS
215 and d not in extra_excludes
216 and not (current_path / d).is_symlink()
217 )
218 for filename in sorted(files):
219 path = current_path / filename
220 if (
221 path.is_symlink()
222 or should_ignore(path, root, extra_excludes)
223 or not is_text_candidate(path)
224 ):
225 continue
226 try:
227 if path.stat().st_size > max_bytes:
228 continue
229 except OSError:
230 continue
231 yield path
232
233
234def read_text(path: Path) -> Optional[str]:
235 try:
236 data = path.read_bytes()
237 except OSError:
238 return None
239 if b"\x00" in data:
240 return None
241 return data.decode("utf-8", errors="replace")
242
243
244def path_provider_hints(relative: Path) -> set[str]:
245 """Activate response-field checks for clearly named fixtures and snapshots."""
246 lowered_parts = {part.lower() for part in relative.parts[:-1]}
247 stem = relative.stem.lower()
248 fixture_context = bool(lowered_parts & FIXTURE_MARKERS) or any(
249 marker in stem for marker in FIXTURE_MARKERS
250 )
251 response_context = any(marker in stem for marker in ("response", "result"))
252 if not (fixture_context or response_context):
253 return set()
254 path_tokens = lowered_parts | {stem}
255 return {
256 provider
257 for provider in PROVIDERS
258 if any(
259 re.search(rf"(?<![a-z0-9]){provider}(?![a-z0-9])", token)
260 for token in path_tokens
261 )
262 }
263
264
265def scan_file(
266 path: Path,
267 root: Path,
268 providers: Optional[set[str]] = None,
269) -> list[Finding]:
270 text = read_text(path)
271 if text is None:
272 return []
273
274 findings: list[Finding] = []
275 relative_path = path.relative_to(root)
276 relative = relative_path.as_posix()
277 # Informational provider names alone must not activate generic field rules.
278 # Require a strong legacy marker or a provider-named fixture/snapshot.
279 direct_providers = {
280 candidate.provider
281 for candidate in RULES
282 if not candidate.contextual
283 and candidate.legacy
284 and candidate.pattern.search(text)
285 } | path_provider_hints(relative_path)
286 line_starts = [0] + [index + 1 for index, character in enumerate(text) if character == "\n"]
287
288 for candidate in RULES:
289 if providers is not None and candidate.provider not in providers:
290 continue
291 if candidate.contextual and candidate.provider not in direct_providers:
292 continue
293 for match in candidate.pattern.finditer(text):
294 line_number = bisect_right(line_starts, match.start())
295 findings.append(
296 Finding(
297 provider=candidate.provider,
298 kind=candidate.kind,
299 rule=candidate.name,
300 path=relative,
301 line=line_number,
302 legacy=candidate.legacy,
303 )
304 )
305 return findings
306
307
308def scan(
309 root: Path,
310 extra_excludes: set[str],
311 max_bytes: int,
312 providers: Optional[set[str]] = None,
313) -> list[Finding]:
314 findings: list[Finding] = []
315 for path in iter_files(root, extra_excludes, max_bytes):
316 findings.extend(scan_file(path, root, providers))
317 return sorted(findings, key=lambda item: (item.provider, item.path, item.line, item.rule))
318
319
320def counts(findings: list[Finding]) -> dict[str, object]:
321 providers = sorted({item.provider for item in findings})
322 by_provider = {provider: sum(item.provider == provider for item in findings) for provider in providers}
323 by_kind = {
324 kind: sum(item.kind == kind for item in findings)
325 for kind in sorted({item.kind for item in findings})
326 }
327 return {
328 "total": len(findings),
329 "legacy": sum(item.legacy for item in findings),
330 "providers": by_provider,
331 "kinds": by_kind,
332 }
333
334
335def render_markdown(root: Path, findings: list[Finding]) -> str:
336 summary = counts(findings)
337 lines = [
338 "# Legacy provider migration inventory",
339 "",
340 f"Root: `{root}`",
341 f"Legacy findings: **{summary['legacy']}**",
342 "",
343 ]
344 if not findings:
345 lines.extend(["No legacy provider migration signatures found in scanned files.", ""])
346
347 for provider in PROVIDERS:
348 provider_findings = [item for item in findings if item.provider == provider]
349 if not provider_findings:
350 continue
351 lines.extend([f"## {provider.title()}", ""])
352 for item in provider_findings:
353 lines.append(f"- `{item.path}:{item.line}` — **{item.kind}** / `{item.rule}`")
354 lines.append("")
355 lines.extend(["Coverage limits:", ""])
356 lines.extend(f"- {item}" for item in LIMITATIONS)
357 lines.extend([
358 "",
359 "Review response-contract findings manually; generic field names are reported only in files with a direct provider signature.",
360 "Use `--fail-on-legacy` only for a provider boundary intended for full removal.",
361 ])
362 return "\n".join(lines)
363
364
365def main() -> int:
366 args = parse_args()
367 root = Path(args.root).expanduser().resolve()
368 if not root.is_dir():
369 print(f"error: repository root is not a directory: {root}", file=sys.stderr)
370 return 2
371 providers = set(args.provider) if args.provider else None
372 findings = scan(root, set(args.exclude), args.max_file_bytes, providers)
373 if args.format == "json":
374 payload = {
375 "root": str(root),
376 "summary": counts(findings),
377 "findings": [asdict(item) for item in findings],
378 "limitations": LIMITATIONS,
379 }
380 print(json.dumps(payload, indent=2, sort_keys=True))
381 else:
382 print(render_markdown(root, findings))
383 if args.fail_on_legacy and any(item.legacy for item in findings):
384 return 1
385 return 0
386
387
388if __name__ == "__main__":
389 raise SystemExit(main())