Compare commits
2 Commits
step35/98-
...
step35/162
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
180464cc5e | ||
|
|
4b5a675355 |
472
docs/API.md
472
docs/API.md
@@ -1,472 +0,0 @@
|
||||
# Compounding Intelligence — Scripts API Reference
|
||||
|
||||
*Generated: 2026-04-26 11:02 UTC*
|
||||
|
||||
This document auto-documents the public API surface of all scripts
|
||||
in `scripts/`. Each section covers one script: module purpose,
|
||||
public functions, and their signatures.
|
||||
|
||||
---
|
||||
|
||||
## `scripts/api_doc_generator.py`
|
||||
|
||||
API Doc Generator — Issue #98
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `extract_functions_from_ast` | `extract_functions_from_ast(tree, file_rel)` | Extract public function names, signatures, and first-line doc summaries. |
|
||||
| `parse_module` | `parse_module(filepath)` | Parse a Python file and return its module-level docstring and public functions. |
|
||||
| `scan_scripts_dir` | `scan_scripts_dir(scripts_dir)` | Scan all .py files in scripts/ and extract API info. |
|
||||
| `render_markdown` | `render_markdown(modules)` | Generate full docs/API.md content from the scanned modules. |
|
||||
| `render_json` | `render_json(modules)` | Emit machine-readable JSON version of the API reference. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/automation_opportunity_finder.py`
|
||||
|
||||
Automation Opportunity Finder — Scan fleet for manual processes that could be automated.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `analyze_cron_jobs` | `analyze_cron_jobs(hermes_home)` | Analyze cron job definitions for automation gaps. |
|
||||
| `analyze_documents` | `analyze_documents(root_dirs)` | Scan documentation for manual step patterns. |
|
||||
| `analyze_scripts` | `analyze_scripts(root_dirs)` | Detect repeated command sequences in scripts. |
|
||||
| `analyze_session_transcripts` | `analyze_session_transcripts(session_dirs)` | Find repeated tool-call patterns in session transcripts. |
|
||||
| `analyze_shell_history` | `analyze_shell_history(root_dirs)` | Find repeated shell commands from history files. |
|
||||
| `deduplicate_proposals` | `deduplicate_proposals(proposals)` | Remove duplicate proposals based on title similarity. |
|
||||
| `rank_proposals` | `rank_proposals(proposals)` | Sort proposals by impact * confidence (highest first). |
|
||||
| `format_text_report` | `format_text_report(proposals)` | Format proposals as human-readable text. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/bootstrapper.py`
|
||||
|
||||
Bootstrapper — assemble pre-session context from knowledge store.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `load_index` | `load_index(index_path)` | Load and validate the knowledge index. |
|
||||
| `filter_facts` | `filter_facts(facts, repo, agent, include_global)` | Filter facts by repo, agent, and global scope. |
|
||||
| `sort_facts` | `sort_facts(facts)` | Sort facts by: confidence (desc), then category priority, then fact text. |
|
||||
| `load_repo_knowledge` | `load_repo_knowledge(repo)` | Load per-repo knowledge markdown if it exists. |
|
||||
| `load_agent_knowledge` | `load_agent_knowledge(agent)` | Load per-agent knowledge markdown if it exists. |
|
||||
| `load_global_knowledge` | `load_global_knowledge()` | Load all global knowledge markdown files. |
|
||||
| `render_facts_section` | `render_facts_section(facts, category, label)` | Render a section of facts for a single category. |
|
||||
| `estimate_tokens` | `estimate_tokens(text)` | Rough token estimate. |
|
||||
| `truncate_to_tokens` | `truncate_to_tokens(text, max_tokens)` | Truncate text to approximately max_tokens, cutting at line boundaries. |
|
||||
| `build_bootstrap_context` | `build_bootstrap_context(repo, agent, include_global, max_tokens, index_path)` | Build the full bootstrap context block. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/dead_code_detector.py`
|
||||
|
||||
Dead Code Detector for Python Codebases
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `is_safe_unused` | `is_safe_unused(name, filepath)` | Check if an unused name is expected to be unused. |
|
||||
| `get_git_blame` | `get_git_blame(filepath, lineno)` | Get last author of a line via git blame. |
|
||||
| `analyze_file` | `analyze_file(filepath)` | Analyze a single Python file for dead code. |
|
||||
| `scan_repo` | `scan_repo(repo_path, exclude_patterns)` | Scan an entire repo for dead code. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/dedup.py`
|
||||
|
||||
dedup.py — Knowledge deduplication: content hash + semantic similarity.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `normalize_text` | `normalize_text(text)` | Normalize text for hashing: lowercase, collapse whitespace, strip. |
|
||||
| `content_hash` | `content_hash(text)` | SHA256 hash of normalized text for exact dedup. |
|
||||
| `tokenize` | `tokenize(text)` | Simple tokenizer: lowercase words, 3+ chars. |
|
||||
| `token_similarity` | `token_similarity(a, b)` | Token-based Jaccard similarity (0.0-1.0). |
|
||||
| `quality_score` | `quality_score(fact)` | Compute quality score for merge ranking. |
|
||||
| `merge_facts` | `merge_facts(keep, drop)` | Merge two near-duplicate facts, keeping higher-quality fields. |
|
||||
| `dedup_facts` | `dedup_facts(facts, exact_threshold, near_threshold, dry_run)` | Deduplicate a list of knowledge facts. |
|
||||
| `dedup_index_file` | `dedup_index_file(input_path, output_path, near_threshold, dry_run)` | Deduplicate an index.json file. |
|
||||
| `generate_test_duplicates` | `generate_test_duplicates(n)` | Generate test facts with intentional duplicates for testing. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/dependency_graph.py`
|
||||
|
||||
Cross-Repo Dependency Graph Builder
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `normalize_repo_name` | `normalize_repo_name(name)` | Normalize a repo name for comparison. |
|
||||
| `scan_file_for_deps` | `scan_file_for_deps(filepath, content, own_repo)` | Scan a file's content for references to other repos. |
|
||||
| `scan_repo` | `scan_repo(repo_path, repo_name)` | Scan a repo directory for dependencies. |
|
||||
| `detect_cycles` | `detect_cycles(graph)` | Detect circular dependencies using DFS. |
|
||||
| `to_dot` | `to_dot(graph)` | Generate DOT format output. |
|
||||
| `to_mermaid` | `to_mermaid(graph)` | Generate Mermaid format output. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/diff_analyzer.py`
|
||||
|
||||
Diff Analyzer — Parse unified diffs and categorize every change.
|
||||
|
||||
*(no public functions — script runs as `main()` only)*
|
||||
|
||||
## `scripts/freshness.py`
|
||||
|
||||
Knowledge Freshness Cron — Detect stale entries from code changes (Issue #200)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `compute_file_hash` | `compute_file_hash(filepath)` | Compute SHA-256 hash of a file. Returns None if file doesn't exist. |
|
||||
| `get_git_file_changes` | `get_git_file_changes(repo_path, days)` | Get files changed in git in the last N days. |
|
||||
| `load_knowledge_entries` | `load_knowledge_entries(knowledge_dir)` | Load knowledge entries from YAML files in the knowledge directory. |
|
||||
| `check_freshness` | `check_freshness(knowledge_dir, repo_root, days)` | Check freshness of knowledge entries against recent code changes. |
|
||||
| `update_stale_hashes` | `update_stale_hashes(knowledge_dir, repo_root)` | Update hashes for stale entries. Returns count of updated entries. |
|
||||
| `format_report` | `format_report(result, max_items)` | Format freshness check results as a human-readable report. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/gitea_issue_parser.py`
|
||||
|
||||
Gitea Issue Body Parser — Extract structured data from markdown issue bodies.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `parse_issue_body` | `parse_issue_body(body, title, labels)` | Parse a Gitea issue markdown body into structured JSON. |
|
||||
| `fetch_issue_from_url` | `fetch_issue_from_url(url)` | Fetch an issue from a Gitea API URL and parse it. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/harvester.py`
|
||||
|
||||
harvester.py — Extract durable knowledge from Hermes session transcripts.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `find_api_key` | `find_api_key()` | Find API key from common locations. |
|
||||
| `load_extraction_prompt` | `load_extraction_prompt()` | Load the extraction prompt template. |
|
||||
| `call_llm` | `call_llm(prompt, transcript, api_base, api_key, model)` | Call the LLM API to extract knowledge from a transcript. |
|
||||
| `parse_extraction_response` | `parse_extraction_response(content)` | Parse the LLM response to extract knowledge items. |
|
||||
| `load_existing_knowledge` | `load_existing_knowledge(knowledge_dir)` | Load the existing knowledge index. |
|
||||
| `fact_fingerprint` | `fact_fingerprint(fact)` | Generate a deduplication fingerprint for a fact. |
|
||||
| `deduplicate` | `deduplicate(new_facts, existing, similarity_threshold)` | Remove duplicate facts from new_facts that already exist in the knowledge store. |
|
||||
| `validate_fact` | `validate_fact(fact)` | Validate a single knowledge item has required fields. |
|
||||
| `write_knowledge` | `write_knowledge(index, new_facts, knowledge_dir, source_session)` | Write new facts to the knowledge store. |
|
||||
| `harvest_session` | `harvest_session(session_path, knowledge_dir, api_base, api_key, model, dry_run, min_confidence)` | Harvest knowledge from a single session. |
|
||||
| `batch_harvest` | `batch_harvest(sessions_dir, knowledge_dir, api_base, api_key, model, since, limit, dry_run)` | Harvest knowledge from multiple sessions in batch. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/improvement_proposals.py`
|
||||
|
||||
Improvement Proposal Generator for compounding-intelligence.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `analyze_sessions` | `analyze_sessions(sessions)` | Analyze session data to find waste patterns. |
|
||||
| `generate_proposals` | `generate_proposals(patterns, hourly_rate, implementation_overhead)` | Generate improvement proposals from waste patterns. |
|
||||
| `format_proposals_markdown` | `format_proposals_markdown(proposals, patterns, generated_at)` | Format proposals as a markdown document. |
|
||||
| `format_proposals_json` | `format_proposals_json(proposals)` | Format proposals as JSON. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/knowledge_gap_identifier.py`
|
||||
|
||||
Knowledge Gap Identifier — Pipeline 10.7
|
||||
|
||||
*(no public functions — script runs as `main()` only)*
|
||||
|
||||
## `scripts/knowledge_staleness_check.py`
|
||||
|
||||
Knowledge Store Staleness Detector — Detect stale knowledge entries by comparing source file hashes.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `compute_file_hash` | `compute_file_hash(filepath)` | Compute SHA-256 hash of a file. Returns None if file doesn't exist. |
|
||||
| `check_staleness` | `check_staleness(index_path, repo_root)` | Check all entries in knowledge index for staleness. |
|
||||
| `fix_hashes` | `fix_hashes(index_path, repo_root)` | Add hashes to entries missing them. Returns count of fixed entries. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/perf_bottleneck_finder.py`
|
||||
|
||||
Performance Bottleneck Finder — Identify slow tests, builds, and CI steps.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `find_slow_tests_pytest` | `find_slow_tests_pytest(repo_path)` | Run pytest --durations and parse slow tests. |
|
||||
| `find_slow_tests_by_scan` | `find_slow_tests_by_scan(repo_path)` | Scan test files for patterns that indicate slow tests. |
|
||||
| `analyze_build_artifacts` | `analyze_build_artifacts(repo_path)` | Find large build artifacts that slow down builds. |
|
||||
| `analyze_makefile_targets` | `analyze_makefile_targets(repo_path)` | Analyze Makefile for potentially slow targets. |
|
||||
| `analyze_github_actions` | `analyze_github_actions(repo_path)` | Analyze GitHub Actions workflow files for inefficiencies. |
|
||||
| `analyze_gitea_ci` | `analyze_gitea_ci(repo_path)` | Analyze Gitea/Drone CI config files. |
|
||||
| `find_slow_imports` | `find_slow_imports(repo_path)` | Find Python files with heavy import chains. |
|
||||
| `severity_sort_key` | `severity_sort_key(b)` | Sort by severity then duration. |
|
||||
| `generate_report` | `generate_report(repo_path)` | Run all analyses and generate a performance report. |
|
||||
| `format_markdown` | `format_markdown(report)` | Format report as markdown. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/priority_rebalancer.py`
|
||||
|
||||
Priority Rebalancer — Re-evaluate issue priorities based on accumulated data.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `collect_knowledge_signals` | `collect_knowledge_signals(knowledge_dir)` | Analyze knowledge store for coverage gaps and staleness. |
|
||||
| `collect_staleness_signals` | `collect_staleness_signals(scripts_dir, knowledge_dir)` | Run staleness checker if available. |
|
||||
| `collect_metrics_signals` | `collect_metrics_signals(metrics_dir)` | Analyze metrics directory for pipeline health. |
|
||||
| `extract_priority` | `extract_priority(labels)` | Extract priority level from issue labels. |
|
||||
| `compute_issue_score` | `compute_issue_score(issue, repo, signals, now)` | Compute priority score for a single issue. |
|
||||
| `generate_report` | `generate_report(scores, signals, org, repos_scanned)` | Generate the full priority report. |
|
||||
| `generate_markdown_report` | `generate_markdown_report(report)` | Generate human-readable markdown report. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/refactoring_opportunity_finder.py`
|
||||
|
||||
Finds refactoring opportunities in codebases
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `compute_file_complexity` | `compute_file_complexity(filepath)` | Compute cyclomatic complexity for a Python file. |
|
||||
| `calculate_refactoring_score` | `calculate_refactoring_score(metrics)` | Calculate a refactoring priority score (0-100) based on file metrics. |
|
||||
| `scan_directory` | `scan_directory(directory, extensions)` | Scan directory for source files. |
|
||||
| `generate_proposals` | `generate_proposals(directory, min_score)` | Generate refactoring proposals by analyzing source files. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/sampler.py`
|
||||
|
||||
sampler.py — Score and rank sessions by harvest value.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `scan_session_fast` | `scan_session_fast(path)` | Extract scoring metadata from a session without parsing the full JSONL. |
|
||||
| `parse_session_timestamp` | `parse_session_timestamp(filename)` | Parse timestamp from session filename. |
|
||||
| `score_session` | `score_session(meta, now, seen_repos)` | Score a session for harvest value. Returns (score, breakdown). |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/session_metadata.py`
|
||||
|
||||
session_metadata.py - Extract structured metadata from Hermes session transcripts.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `extract_session_metadata` | `extract_session_metadata(file_path)` | Extract structured metadata from a Hermes session JSONL transcript. |
|
||||
| `process_session_directory` | `process_session_directory(directory_path, output_file)` | Process all JSONL files in a directory. |
|
||||
| `main` | `main()` | CLI entry point. |
|
||||
|
||||
## `scripts/session_pair_harvester.py`
|
||||
|
||||
Session Transcript → Training Pair Harvester
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `compute_hash` | `compute_hash(text)` | Content hash for deduplication. |
|
||||
| `extract_pairs_from_session` | `extract_pairs_from_session(session_data, min_ratio, min_response_words)` | Extract terse→rich pairs from a single session object. |
|
||||
| `extract_from_jsonl_file` | `extract_from_jsonl_file(filepath, **kwargs)` | Extract pairs from a session JSONL file. |
|
||||
| `deduplicate_pairs` | `deduplicate_pairs(pairs)` | Remove duplicate pairs across files. |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
## `scripts/session_reader.py`
|
||||
|
||||
session_reader.py — Parse Hermes session JSONL transcripts.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `read_session` | `read_session(path)` | Read a session JSONL file and return all messages as a list. |
|
||||
| `read_session_iter` | `read_session_iter(path)` | Iterate over session messages without loading all into memory. |
|
||||
| `extract_conversation` | `extract_conversation(messages)` | Extract user/assistant conversation turns, skipping tool-only messages. |
|
||||
| `truncate_for_context` | `truncate_for_context(messages, head, tail)` | Truncate long sessions: keep first N + last N messages. |
|
||||
| `messages_to_text` | `messages_to_text(messages)` | Convert message list to plain text for LLM consumption. |
|
||||
| `get_session_metadata` | `get_session_metadata(path)` | Extract metadata from a session file (first message often has config info). |
|
||||
|
||||
## `scripts/test_automation_opportunity_finder.py`
|
||||
|
||||
Tests for scripts/automation_opportunity_finder.py — 8 tests.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_analyze_cron_jobs_no_file` | `test_analyze_cron_jobs_no_file()` | Returns empty list when no cron jobs file exists. |
|
||||
| `test_analyze_cron_jobs_disabled` | `test_analyze_cron_jobs_disabled()` | Detects disabled cron jobs. |
|
||||
| `test_analyze_cron_jobs_errors` | `test_analyze_cron_jobs_errors()` | Detects cron jobs with error status. |
|
||||
| `test_analyze_documents_finds_todos` | `test_analyze_documents_finds_todos()` | Detects TODO markers in documents. |
|
||||
| `test_analyze_scripts_repeated_commands` | `test_analyze_scripts_repeated_commands()` | Detects repeated shell commands across scripts. |
|
||||
| `test_analyze_session_transcripts` | `test_analyze_session_transcripts()` | Detects repeated tool-call sequences. |
|
||||
| `test_deduplicate_proposals` | `test_deduplicate_proposals()` | Deduplicates proposals with similar titles. |
|
||||
| `test_rank_proposals` | `test_rank_proposals()` | Ranks proposals by impact * confidence. |
|
||||
|
||||
## `scripts/test_bootstrapper.py`
|
||||
|
||||
Tests for bootstrapper.py — context assembly from knowledge store.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `make_index` | `make_index(facts, tmp_dir)` | Create a temporary index.json with given facts. |
|
||||
| `test_empty_index` | `test_empty_index()` | Empty knowledge store produces graceful output. |
|
||||
| `test_filter_by_repo` | `test_filter_by_repo()` | Filter facts by repository. |
|
||||
| `test_filter_by_agent` | `test_filter_by_agent()` | Filter facts by agent type. |
|
||||
| `test_no_global_flag` | `test_no_global_flag()` | Excluding global facts works. |
|
||||
| `test_sort_by_confidence` | `test_sort_by_confidence()` | Facts sort by confidence descending. |
|
||||
| `test_sort_pitfalls_first` | `test_sort_pitfalls_first()` | Pitfalls sort before facts at same confidence. |
|
||||
| `test_truncate_to_tokens` | `test_truncate_to_tokens()` | Truncation cuts at line boundary. |
|
||||
| `test_estimate_tokens` | `test_estimate_tokens()` | Token estimation is reasonable. |
|
||||
| `test_build_full_context` | `test_build_full_context()` | Full context with facts renders correctly. |
|
||||
| `test_max_tokens_respected` | `test_max_tokens_respected()` | Output respects max_tokens limit. |
|
||||
| `test_missing_index_graceful` | `test_missing_index_graceful()` | Missing index.json doesn't crash. |
|
||||
|
||||
## `scripts/test_diff_analyzer.py`
|
||||
|
||||
Tests for scripts/diff_analyzer.py — 10 tests.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_empty` | `test_empty()` | - |
|
||||
| `test_addition` | `test_addition()` | - |
|
||||
| `test_deletion` | `test_deletion()` | - |
|
||||
| `test_modification` | `test_modification()` | - |
|
||||
| `test_rename` | `test_rename()` | - |
|
||||
| `test_multiple_files` | `test_multiple_files()` | - |
|
||||
| `test_binary` | `test_binary()` | - |
|
||||
| `test_to_dict` | `test_to_dict()` | - |
|
||||
| `test_context_only` | `test_context_only()` | - |
|
||||
| `test_multi_hunk` | `test_multi_hunk()` | - |
|
||||
| `run_all` | `run_all()` | - |
|
||||
|
||||
## `scripts/test_gitea_issue_parser.py`
|
||||
|
||||
Tests for scripts/gitea_issue_parser.py
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_basic_parsing` | `test_basic_parsing()` | - |
|
||||
| `test_numbered_criteria` | `test_numbered_criteria()` | - |
|
||||
| `test_epic_ref_from_body` | `test_epic_ref_from_body()` | - |
|
||||
| `test_empty_body` | `test_empty_body()` | - |
|
||||
| `test_no_sections` | `test_no_sections()` | - |
|
||||
| `test_multiple_sections` | `test_multiple_sections()` | - |
|
||||
| `run_all` | `run_all()` | - |
|
||||
|
||||
## `scripts/test_harvest_prompt.py`
|
||||
|
||||
Test harness for knowledge extraction prompt.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `validate_knowledge_item` | `validate_knowledge_item(item, idx)` | Validate a single knowledge item. Returns list of errors. |
|
||||
| `validate_extraction` | `validate_extraction(data)` | Validate a full extraction result. Returns (is_valid, errors, warnings). |
|
||||
| `validate_transcript_coverage` | `validate_transcript_coverage(data, transcript)` | Check that extracted facts are actually supported by the transcript. |
|
||||
| `run_tests` | `run_tests()` | Run the built-in test suite. |
|
||||
| `validate_file` | `validate_file(filepath)` | Validate an existing extraction JSON file. |
|
||||
|
||||
## `scripts/test_harvest_prompt_comprehensive.py`
|
||||
|
||||
Comprehensive tests for knowledge extraction prompt.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `check_prompt_structure` | `check_prompt_structure()` | - |
|
||||
| `check_confidence_scoring` | `check_confidence_scoring()` | - |
|
||||
| `check_example_quality` | `check_example_quality()` | - |
|
||||
| `check_constraint_coverage` | `check_constraint_coverage()` | - |
|
||||
| `check_test_sessions` | `check_test_sessions()` | - |
|
||||
| `test_prompt_structure` | `test_prompt_structure()` | - |
|
||||
| `test_confidence_scoring` | `test_confidence_scoring()` | - |
|
||||
| `test_example_quality` | `test_example_quality()` | - |
|
||||
| `test_constraint_coverage` | `test_constraint_coverage()` | - |
|
||||
| `test_test_sessions` | `test_test_sessions()` | - |
|
||||
|
||||
## `scripts/test_harvester_pipeline.py`
|
||||
|
||||
Smoke test for harvester pipeline — verifies the full chain:
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_session_reader` | `test_session_reader()` | Test that session_reader parses JSONL correctly. |
|
||||
| `test_validate_fact` | `test_validate_fact()` | Test fact validation. |
|
||||
| `test_deduplicate` | `test_deduplicate()` | Test deduplication. |
|
||||
| `test_knowledge_store_roundtrip` | `test_knowledge_store_roundtrip()` | Test loading and writing knowledge index. |
|
||||
| `test_full_chain_no_llm` | `test_full_chain_no_llm()` | Test the full pipeline minus the LLM call. |
|
||||
|
||||
## `scripts/test_improvement_proposals.py`
|
||||
|
||||
Tests for scripts/improvement_proposals.py — 15 tests.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_empty_sessions` | `test_empty_sessions()` | - |
|
||||
| `test_no_patterns_on_clean_sessions` | `test_no_patterns_on_clean_sessions()` | - |
|
||||
| `test_repeated_error_detection` | `test_repeated_error_detection()` | Same error across 3+ sessions triggers pattern. |
|
||||
| `test_repeated_error_threshold` | `test_repeated_error_threshold()` | 2 occurrences should NOT trigger (threshold is 3). |
|
||||
| `test_slow_tool_detection` | `test_slow_tool_detection()` | Tool with avg latency > 5000ms across 5+ calls. |
|
||||
| `test_fast_tool_not_flagged` | `test_fast_tool_not_flagged()` | Tool under 5000ms avg should not trigger. |
|
||||
| `test_failed_retry_detection` | `test_failed_retry_detection()` | 3+ consecutive calls to same tool triggers retry pattern. |
|
||||
| `test_manual_process_detection` | `test_manual_process_detection()` | 10+ tool calls with <= 3 unique tools. |
|
||||
| `test_generate_proposals_from_patterns` | `test_generate_proposals_from_patterns()` | Proposals generated from waste patterns. |
|
||||
| `test_proposal_roi_positive` | `test_proposal_roi_positive()` | ROI weeks should be a positive number for recoverable time. |
|
||||
| `test_proposals_sorted_by_impact` | `test_proposals_sorted_by_impact()` | Proposals should be sorted by monthly hours saved (descending). |
|
||||
| `test_format_markdown` | `test_format_markdown()` | Markdown output should contain expected sections. |
|
||||
| `test_format_json` | `test_format_json()` | JSON output should be valid and parseable. |
|
||||
| `test_normalize_error` | `test_normalize_error()` | Error normalization should remove paths and hashes. |
|
||||
| `test_cli_integration` | `test_cli_integration()` | End-to-end test: write input JSON, run script, check output. |
|
||||
| `run_all` | `run_all()` | - |
|
||||
|
||||
## `scripts/test_knowledge_staleness.py`
|
||||
|
||||
Tests for scripts/knowledge_staleness_check.py — 8 tests.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_fresh_entry` | `test_fresh_entry()` | - |
|
||||
| `test_stale_entry` | `test_stale_entry()` | - |
|
||||
| `test_missing_source` | `test_missing_source()` | - |
|
||||
| `test_no_hash` | `test_no_hash()` | - |
|
||||
| `test_no_source_field` | `test_no_source_field()` | - |
|
||||
| `test_fix_hashes` | `test_fix_hashes()` | - |
|
||||
| `test_empty_index` | `test_empty_index()` | - |
|
||||
| `test_compute_hash_nonexistent` | `test_compute_hash_nonexistent()` | - |
|
||||
| `run_all` | `run_all()` | - |
|
||||
|
||||
## `scripts/test_priority_rebalancer.py`
|
||||
|
||||
Tests for Priority Rebalancer
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test` | `test(name)` | - |
|
||||
| `assert_eq` | `assert_eq(a, b, msg)` | - |
|
||||
| `assert_true` | `assert_true(v, msg)` | - |
|
||||
| `assert_false` | `assert_false(v, msg)` | - |
|
||||
| `make_issue` | `make_issue(**kwargs)` | - |
|
||||
|
||||
## `scripts/test_refactoring_opportunity_finder.py`
|
||||
|
||||
Tests for scripts/refactoring_opportunity_finder.py — 10 tests.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_complexity_simple_function` | `test_complexity_simple_function()` | Simple function should have low complexity. |
|
||||
| `test_complexity_with_conditionals` | `test_complexity_with_conditionals()` | Function with if/else should have higher complexity. |
|
||||
| `test_complexity_with_loops` | `test_complexity_with_loops()` | Function with loops should increase complexity. |
|
||||
| `test_complexity_with_class` | `test_complexity_with_class()` | Class with methods should count both. |
|
||||
| `test_complexity_syntax_error` | `test_complexity_syntax_error()` | File with syntax error should return zeros. |
|
||||
| `test_refactoring_score_high_complexity` | `test_refactoring_score_high_complexity()` | High complexity should give high score. |
|
||||
| `test_refactoring_score_low_complexity` | `test_refactoring_score_low_complexity()` | Low complexity should give lower score. |
|
||||
| `test_refactoring_score_high_churn` | `test_refactoring_score_high_churn()` | High churn should increase score. |
|
||||
| `test_refactoring_score_no_coverage` | `test_refactoring_score_no_coverage()` | No coverage data should assume medium risk. |
|
||||
| `test_refactoring_score_large_file` | `test_refactoring_score_large_file()` | Large files should score higher. |
|
||||
| `run_all` | `run_all()` | - |
|
||||
|
||||
## `scripts/test_session_pair_harvester.py`
|
||||
|
||||
Tests for session_pair_harvester.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `test_basic_extraction` | `test_basic_extraction()` | - |
|
||||
| `test_filters_short_responses` | `test_filters_short_responses()` | - |
|
||||
| `test_skips_tool_results` | `test_skips_tool_results()` | - |
|
||||
| `test_deduplication` | `test_deduplication()` | - |
|
||||
| `test_ratio_filter` | `test_ratio_filter()` | - |
|
||||
|
||||
## `scripts/validate_knowledge.py`
|
||||
|
||||
Validate knowledge files and index.json against the schema.
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `validate_fact` | `validate_fact(fact, src)` | - |
|
||||
| `main` | `main()` | - |
|
||||
|
||||
|
||||
---
|
||||
|
||||
**Total scripts documented:** 33
|
||||
|
||||
*Generated by `scripts/api_doc_generator.py` (Issue #98)*
|
||||
@@ -1,219 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API Doc Generator — Issue #98
|
||||
|
||||
Scans all Python modules in `scripts/`, extracts their public API surface
|
||||
(module docstring + public function signatures + first-line doc summaries),
|
||||
and produces a single markdown reference document at `docs/API.md`.
|
||||
|
||||
Usage:
|
||||
python3 scripts/api_doc_generator.py # Write docs/API.md
|
||||
python3 scripts/api_doc_generator.py --check # Verify docs/API.md is up-to-date
|
||||
python3 scripts/api_doc_generator.py --json # Emit JSON for downstream tooling
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, List, Optional
|
||||
|
||||
|
||||
# ─── Paths ────────────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = SCRIPT_DIR.parent
|
||||
SCRIPTS_DIR = REPO_ROOT / "scripts"
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
OUTPUT_PATH = DOCS_DIR / "API.md"
|
||||
|
||||
|
||||
# ─── Data structures ───────────────────────────────────────────────────────────
|
||||
class FunctionInfo(TypedDict):
|
||||
name: str
|
||||
signature: str
|
||||
summary: str
|
||||
|
||||
|
||||
class ModuleInfo(TypedDict):
|
||||
path: str # relative to repo root, e.g. "scripts/harvester.py"
|
||||
docstring: str
|
||||
functions: List[FunctionInfo]
|
||||
|
||||
|
||||
# ─── AST extraction ────────────────────────────────────────────────────────────
|
||||
def extract_functions_from_ast(tree: ast.AST, file_rel: str) -> List[FunctionInfo]:
|
||||
"""Extract public function names, signatures, and first-line doc summaries."""
|
||||
funcs: list[FunctionInfo] = []
|
||||
|
||||
for node in ast.iter_child_nodes(tree):
|
||||
if not isinstance(node, ast.FunctionDef):
|
||||
continue
|
||||
# Skip private functions
|
||||
if node.name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Build signature: arg1, arg2=default, *args, **kwargs
|
||||
args = []
|
||||
for arg in node.args.args:
|
||||
args.append(arg.arg)
|
||||
if node.args.vararg:
|
||||
args.append(f"*{node.args.vararg.arg}")
|
||||
if node.args.kwarg:
|
||||
args.append(f"**{node.args.kwarg.arg}")
|
||||
|
||||
# Get first line of docstring
|
||||
summary = ""
|
||||
if (node.body and isinstance(node.body[0], ast.Expr) and
|
||||
isinstance(node.body[0].value, ast.Constant) and
|
||||
isinstance(node.body[0].value.value, str)):
|
||||
raw = node.body[0].value.value.strip()
|
||||
summary = raw.split("\n")[0].strip()
|
||||
if len(summary) > 100:
|
||||
summary = summary[:97] + "..."
|
||||
|
||||
funcs.append({
|
||||
"name": node.name,
|
||||
"signature": ", ".join(args),
|
||||
"summary": summary,
|
||||
})
|
||||
|
||||
return funcs
|
||||
|
||||
|
||||
def parse_module(filepath: Path) -> Optional[ModuleInfo]:
|
||||
"""Parse a Python file and return its module-level docstring and public functions."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
source = f.read()
|
||||
tree = ast.parse(source, filename=str(filepath))
|
||||
except Exception as e:
|
||||
print(f"WARNING: Could not parse {filepath}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Module docstring
|
||||
module_doc = ast.get_docstring(tree) or ""
|
||||
module_doc = module_doc.strip().split("\n")[0] # first line only
|
||||
|
||||
# Public functions
|
||||
functions = extract_functions_from_ast(tree, filepath.name)
|
||||
|
||||
rel = filepath.relative_to(REPO_ROOT)
|
||||
return {
|
||||
"path": str(rel),
|
||||
"docstring": module_doc,
|
||||
"functions": functions,
|
||||
}
|
||||
|
||||
|
||||
# ─── Scanning ──────────────────────────────────────────────────────────────────
|
||||
def scan_scripts_dir(scripts_dir: Path) -> List[ModuleInfo]:
|
||||
"""Scan all .py files in scripts/ and extract API info."""
|
||||
modules: list[ModuleInfo] = []
|
||||
for pyfile in sorted(scripts_dir.glob("*.py")):
|
||||
info = parse_module(pyfile)
|
||||
if info is not None:
|
||||
modules.append(info)
|
||||
return modules
|
||||
|
||||
|
||||
# ─── Markdown rendering ─────────────────────────────────────────────────────────
|
||||
def render_markdown(modules: List[ModuleInfo]) -> str:
|
||||
"""Generate full docs/API.md content from the scanned modules."""
|
||||
lines = [
|
||||
"# Compounding Intelligence — Scripts API Reference",
|
||||
"",
|
||||
f"*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*",
|
||||
"",
|
||||
"This document auto-documents the public API surface of all scripts",
|
||||
"in `scripts/`. Each section covers one script: module purpose,",
|
||||
"public functions, and their signatures.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
|
||||
for mod in modules:
|
||||
rel = mod["path"]
|
||||
name = Path(rel).stem # e.g. harvester
|
||||
lines.append(f"## `{rel}`")
|
||||
lines.append("")
|
||||
if mod["docstring"]:
|
||||
lines.append(mod["docstring"])
|
||||
lines.append("")
|
||||
|
||||
if mod["functions"]:
|
||||
lines.append("| Function | Signature | Description |")
|
||||
lines.append("|----------|-----------|-------------|")
|
||||
for fn in mod["functions"]:
|
||||
sig = fn["name"] + "(" + fn["signature"] + ")"
|
||||
desc = fn["summary"] or "-"
|
||||
lines.append(f"| `{fn['name']}` | `{sig}` | {desc} |")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("*(no public functions — script runs as `main()` only)*")
|
||||
lines.append("")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
f"**Total scripts documented:** {len(modules)}",
|
||||
"",
|
||||
"*Generated by `scripts/api_doc_generator.py` (Issue #98)*",
|
||||
])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─── JSON output (optional, for automation) ───────────────────────────────────
|
||||
def render_json(modules: List[ModuleInfo]) -> str:
|
||||
"""Emit machine-readable JSON version of the API reference."""
|
||||
import json
|
||||
payload = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"generator": "scripts/api_doc_generator.py",
|
||||
"repo": "Timmy_Foundation/compounding-intelligence",
|
||||
"modules": modules,
|
||||
}
|
||||
return json.dumps(payload, indent=2)
|
||||
|
||||
|
||||
# ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
def main() -> int:
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Generate API docs for scripts/")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="Exit 1 if docs/API.md is out-of-date")
|
||||
parser.add_argument("--json", action="store_true",
|
||||
help="Emit JSON to stdout instead of writing markdown")
|
||||
args = parser.parse_args()
|
||||
|
||||
modules = scan_scripts_dir(SCRIPTS_DIR)
|
||||
modules.sort(key=lambda m: m["path"])
|
||||
|
||||
if args.json:
|
||||
print(render_json(modules))
|
||||
return 0
|
||||
|
||||
md = render_markdown(modules)
|
||||
|
||||
if args.check:
|
||||
if OUTPUT_PATH.exists():
|
||||
existing = OUTPUT_PATH.read_text(encoding="utf-8")
|
||||
if existing == md:
|
||||
print("✅ docs/API.md is up-to-date")
|
||||
return 0
|
||||
print("❌ docs/API.md is missing or out-of-date — regenerate with "
|
||||
"`python3 scripts/api_doc_generator.py`", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
DOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT_PATH.write_text(md, encoding="utf-8")
|
||||
print(f"✅ Wrote {OUTPUT_PATH} ({len(modules)} modules documented)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
366
scripts/code_duplication_detector.py
Normal file
366
scripts/code_duplication_detector.py
Normal file
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Code Duplication Detector — Issue #162
|
||||
|
||||
Finds duplicate functions and code blocks across Python source files.
|
||||
Reports duplication percentage and outputs a duplication report.
|
||||
|
||||
Usage:
|
||||
python3 scripts/code_duplication_detector.py --output reports/code_duplication.json
|
||||
python3 scripts/code_duplication_detector.py --directory scripts/ --dry-run
|
||||
python3 scripts/code_duplication_detector.py --test # Run built-in test
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
|
||||
|
||||
# ── AST helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def normalize_code(text: str) -> str:
|
||||
"""Normalize code for comparison: strip comments, normalize whitespace."""
|
||||
# Remove comments (both # and docstring triple-quote strings)
|
||||
text = re.sub(r'#.*$', '', text, flags=re.MULTILINE)
|
||||
text = re.sub(r'""".*?"""', '', text, flags=re.DOTALL)
|
||||
text = re.sub(r"'''.*?'''", '', text, flags=re.DOTALL)
|
||||
# Normalize whitespace
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
return text.lower()
|
||||
|
||||
|
||||
def code_hash(text: str) -> str:
|
||||
"""SHA256 hash of normalized code for exact duplicate detection."""
|
||||
normalized = normalize_code(text)
|
||||
return hashlib.sha256(normalized.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
# ── Function extraction via AST ────────────────────────────────────────────
|
||||
|
||||
class FunctionExtractor:
|
||||
"""Extract function and method definitions with their full source bodies."""
|
||||
|
||||
def __init__(self, source: str, filepath: str):
|
||||
self.source = source
|
||||
self.filepath = filepath
|
||||
self.lines = source.splitlines()
|
||||
self.functions: List[Dict] = []
|
||||
|
||||
def _get_source_segment(self, start_lineno: int, end_lineno: int) -> str:
|
||||
"""Get source code from start to end line (1-indexed, inclusive)."""
|
||||
# AST end_lineno is inclusive
|
||||
start_idx = start_lineno - 1
|
||||
end_idx = end_lineno
|
||||
return '\n'.join(self.lines[start_idx:end_idx])
|
||||
|
||||
def visit(self, tree):
|
||||
"""Collect all function and async function definitions."""
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
|
||||
# Get the full source for this function including decorators
|
||||
start = node.lineno
|
||||
end = node.end_lineno
|
||||
body_source = self._get_source_segment(start, end)
|
||||
|
||||
# Also collect parent class name if this is a method
|
||||
class_name = None
|
||||
parent = node.parent if hasattr(node, 'parent') else None
|
||||
if parent and isinstance(parent, ast.ClassDef):
|
||||
class_name = parent.name
|
||||
|
||||
self.functions.append({
|
||||
'name': node.name,
|
||||
'file': self.filepath,
|
||||
'start_line': start,
|
||||
'end_line': end,
|
||||
'body': body_source,
|
||||
'class_name': class_name,
|
||||
'is_method': class_name is not None,
|
||||
})
|
||||
|
||||
|
||||
import ast
|
||||
|
||||
class ParentNodeVisitor(ast.NodeVisitor):
|
||||
"""Annotate nodes with parent references."""
|
||||
def __init__(self, parent=None):
|
||||
self.parent = parent
|
||||
|
||||
def generic_visit(self, node):
|
||||
node.parent = self.parent
|
||||
for child in ast.iter_child_nodes(node):
|
||||
self.__class__(child).parent = node
|
||||
super().generic_visit(node)
|
||||
|
||||
|
||||
def extract_functions_from_file(filepath: str) -> List[Dict]:
|
||||
"""Extract all function definitions from a Python file."""
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
|
||||
source = f.read()
|
||||
tree = ast.parse(source, filename=str(filepath))
|
||||
|
||||
# Annotate with parent references
|
||||
for node in ast.walk(tree):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
child.parent = node
|
||||
|
||||
extractor = FunctionExtractor(source, str(filepath))
|
||||
extractor.visit(tree)
|
||||
return extractor.functions
|
||||
except (SyntaxError, UnicodeDecodeError, OSError) as e:
|
||||
return []
|
||||
|
||||
|
||||
def scan_directory(directory: str, extensions: Tuple[str, ...] = ('.py',)) -> List[Dict]:
|
||||
"""Scan directory for Python files and extract all functions."""
|
||||
all_functions = []
|
||||
path = Path(directory)
|
||||
|
||||
for filepath in path.rglob('*'):
|
||||
if filepath.is_file() and filepath.suffix in extensions:
|
||||
# Skip common non-source dirs
|
||||
parts = filepath.parts
|
||||
if any(ex in parts for ex in ('__pycache__', 'node_modules', '.git', 'venv', '.venv', 'dist', 'build')):
|
||||
continue
|
||||
if filepath.name.startswith('.'):
|
||||
continue
|
||||
|
||||
functions = extract_functions_from_file(str(filepath))
|
||||
all_functions.extend(functions)
|
||||
|
||||
return all_functions
|
||||
|
||||
|
||||
# ── Duplicate detection ─────────────────────────────────────────────────────
|
||||
|
||||
def find_duplicates(functions: List[Dict], similarity_threshold: float = 0.95) -> Dict:
|
||||
"""
|
||||
Find duplicate and near-duplicate functions.
|
||||
|
||||
Returns dict with:
|
||||
- exact_duplicates: {hash: [function_info, ...]}
|
||||
- near_duplicates: [[function_info, ...], ...]
|
||||
- stats: total_functions, unique_exact, exact_dupe_count, near_dupe_count
|
||||
"""
|
||||
# Phase 1: Exact duplicates by code hash
|
||||
hash_groups: Dict[str, List[Dict]] = defaultdict(list)
|
||||
for func in functions:
|
||||
h = code_hash(func['body'])
|
||||
hash_groups[h].append(func)
|
||||
|
||||
exact_duplicates = {h: group for h, group in hash_groups.items() if len(group) > 1}
|
||||
exact_dupe_count = sum(len(group) - 1 for group in exact_duplicates.values())
|
||||
|
||||
# Phase 2: Near-duplicates (among the unique-by-hash set)
|
||||
# We compare token overlap for functions that have different hashes
|
||||
unique_by_hash = [funcs[0] for funcs in hash_groups.values()]
|
||||
near_duplicate_groups = []
|
||||
|
||||
# Simple token-based similarity
|
||||
def tokenize(code: str) -> set:
|
||||
return set(re.findall(r'[a-zA-Z_][a-zA-Z0-9_]*', code.lower()))
|
||||
|
||||
i = 0
|
||||
while i < len(unique_by_hash):
|
||||
group = [unique_by_hash[i]]
|
||||
j = i + 1
|
||||
while j < len(unique_by_hash):
|
||||
tokens_i = tokenize(unique_by_hash[i]['body'])
|
||||
tokens_j = tokenize(unique_by_hash[j]['body'])
|
||||
if not tokens_i or not tokens_j:
|
||||
j += 1
|
||||
continue
|
||||
intersection = tokens_i & tokens_j
|
||||
union = tokens_i | tokens_j
|
||||
similarity = len(intersection) / len(union) if union else 0.0
|
||||
|
||||
if similarity >= similarity_threshold:
|
||||
group.append(unique_by_hash[j])
|
||||
unique_by_hash.pop(j)
|
||||
else:
|
||||
j += 1
|
||||
|
||||
if len(group) > 1:
|
||||
near_duplicate_groups.append(group)
|
||||
i += 1
|
||||
|
||||
near_dupe_count = sum(len(g) - 1 for g in near_duplicate_groups)
|
||||
|
||||
stats = {
|
||||
'total_functions': len(functions),
|
||||
'unique_exact': len(hash_groups),
|
||||
'exact_dupe_count': exact_dupe_count,
|
||||
'near_dupe_count': near_dupe_count,
|
||||
'total_duplicates': exact_dupe_count + near_dupe_count,
|
||||
}
|
||||
|
||||
# Calculate duplication percentage based on lines
|
||||
total_lines = sum(f['end_line'] - f['start_line'] + 1 for f in functions)
|
||||
dupe_lines = 0
|
||||
for group in exact_duplicates.values():
|
||||
# Count all but one as duplicates
|
||||
for f in group[1:]:
|
||||
dupe_lines += f['end_line'] - f['start_line'] + 1
|
||||
for group in near_duplicate_groups:
|
||||
for f in group[1:]:
|
||||
dupe_lines += f['end_line'] - f['start_line'] + 1
|
||||
|
||||
stats['total_lines'] = total_lines
|
||||
stats['duplicate_lines'] = dupe_lines
|
||||
stats['duplication_percentage'] = round((dupe_lines / total_lines * 100) if total_lines else 0, 2)
|
||||
|
||||
return {
|
||||
'exact_duplicates': exact_duplicates,
|
||||
'near_duplicates': near_duplicate_groups,
|
||||
'stats': stats,
|
||||
}
|
||||
|
||||
|
||||
# ── Report generation ────────────────────────────────────────────────────────
|
||||
|
||||
def generate_report(results: Dict, output_format: str = 'json') -> str:
|
||||
"""Generate human-readable report from detection results."""
|
||||
stats = results['stats']
|
||||
|
||||
if output_format == 'json':
|
||||
return json.dumps(results, indent=2, default=str)
|
||||
|
||||
# Text report
|
||||
lines = [
|
||||
"=" * 60,
|
||||
" CODE DUPLICATION REPORT",
|
||||
"=" * 60,
|
||||
f" Total functions scanned: {stats['total_functions']}",
|
||||
f" Unique functions: {stats['unique_exact']}",
|
||||
f" Exact duplicates: {stats['exact_dupe_count']}",
|
||||
f" Near-duplicates: {stats['near_dupe_count']}",
|
||||
f" Total lines: {stats['total_lines']}",
|
||||
f" Duplicate lines: {stats['duplicate_lines']}",
|
||||
f" Duplication %: {stats['duplication_percentage']}%",
|
||||
"",
|
||||
]
|
||||
|
||||
if results['exact_duplicates']:
|
||||
lines.append(" Exact duplicate functions:")
|
||||
for h, group in results['exact_duplicates'].items():
|
||||
first = group[0]
|
||||
lines.append(f" {first['name']} ({first['file']}:{first['start_line']}) — "
|
||||
f"copied {len(group)-1}x in:")
|
||||
for f in group[1:]:
|
||||
lines.append(f" → {f['file']}:{f['start_line']}")
|
||||
lines.append("")
|
||||
|
||||
if results['near_duplicates']:
|
||||
lines.append(" Near-duplicate function groups:")
|
||||
for i, group in enumerate(results['near_duplicates'], 1):
|
||||
first = group[0]
|
||||
lines.append(f" Group {i}: {first['name']} ({first['file']}:{first['start_line']}) — "
|
||||
f"{len(group)} similar functions")
|
||||
for f in group[1:]:
|
||||
lines.append(f" → {f['file']}:{f['start_line']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("=" * 60)
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Code Duplication Detector")
|
||||
parser.add_argument('--directory', default='.',
|
||||
help='Directory to scan (default: current directory)')
|
||||
parser.add_argument('--output', help='Output file for JSON report')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Run without writing file')
|
||||
parser.add_argument('--threshold', type=float, default=0.95,
|
||||
help='Similarity threshold for near-dupes (default: 0.95)')
|
||||
parser.add_argument('--json', action='store_true', help='JSON output to stdout')
|
||||
parser.add_argument('--test', action='store_true', help='Run built-in test')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.test:
|
||||
_run_test()
|
||||
return
|
||||
|
||||
# Scan
|
||||
functions = scan_directory(args.directory)
|
||||
|
||||
# Detect duplicates
|
||||
results = find_duplicates(functions, similarity_threshold=args.threshold)
|
||||
stats = results['stats']
|
||||
|
||||
# Output
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2, default=str))
|
||||
else:
|
||||
print(generate_report(results, output_format='text'))
|
||||
|
||||
# Write file if requested
|
||||
if args.output and not args.dry_run:
|
||||
os.makedirs(os.path.dirname(args.output) or '.', exist_ok=True)
|
||||
with open(args.output, 'w') as f:
|
||||
json.dump(results, f, indent=2, default=str)
|
||||
print(f"\nReport written to: {args.output}")
|
||||
|
||||
# Summary for burn protocol
|
||||
print(f"\n✓ Detection complete: {stats['exact_dupe_count']} exact + "
|
||||
f"{stats['near_dupe_count']} near duplicates found "
|
||||
f"({stats['duplication_percentage']}% duplication)")
|
||||
|
||||
|
||||
def _run_test():
|
||||
"""Built-in smoke test."""
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create test files with duplicate code
|
||||
f1 = Path(tmpdir) / 'mod1.py'
|
||||
f1.write_text('''
|
||||
def hello():
|
||||
print("hello world")
|
||||
|
||||
def duplicated_function():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
|
||||
def unique_func():
|
||||
return 42
|
||||
''')
|
||||
|
||||
f2 = Path(tmpdir) / 'mod2.py'
|
||||
f2.write_text('''
|
||||
def duplicated_function():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
|
||||
def another_unique():
|
||||
return "different"
|
||||
''')
|
||||
|
||||
functions = scan_directory(tmpdir)
|
||||
results = find_duplicates(functions)
|
||||
|
||||
stats = results['stats']
|
||||
assert stats['exact_dupe_count'] >= 1, "Should find at least 1 exact duplicate"
|
||||
assert stats['total_functions'] >= 4, "Should find at least 4 functions"
|
||||
|
||||
# Check duplication percentage is calculated
|
||||
assert 'duplication_percentage' in stats
|
||||
print(f"\n✓ Test passed: {stats['total_functions']} functions, "
|
||||
f"{stats['exact_dupe_count']} exact duplicates, "
|
||||
f"{stats['duplication_percentage']}% duplication")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
351
scripts/pr_complexity_scorer.py
Normal file
351
scripts/pr_complexity_scorer.py
Normal file
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR Complexity Scorer - Estimate review effort for PRs.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
GITEA_BASE = "https://forge.alexanderwhitestone.com/api/v1"
|
||||
|
||||
DEPENDENCY_FILES = {
|
||||
"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg",
|
||||
"Pipfile", "poetry.lock", "package.json", "yarn.lock", "Gemfile",
|
||||
"go.mod", "Cargo.toml", "pom.xml", "build.gradle"
|
||||
}
|
||||
|
||||
TEST_PATTERNS = [
|
||||
r"tests?/.*\.py$", r".*_test\.py$", r"test_.*\.py$",
|
||||
r"spec/.*\.rb$", r".*_spec\.rb$",
|
||||
r"__tests__/", r".*\.test\.(js|ts|jsx|tsx)$"
|
||||
]
|
||||
|
||||
WEIGHT_FILES = 0.25
|
||||
WEIGHT_LINES = 0.25
|
||||
WEIGHT_DEPS = 0.30
|
||||
WEIGHT_TEST_COV = 0.20
|
||||
|
||||
SMALL_FILES = 5
|
||||
MEDIUM_FILES = 20
|
||||
LARGE_FILES = 50
|
||||
|
||||
SMALL_LINES = 100
|
||||
MEDIUM_LINES = 500
|
||||
LARGE_LINES = 2000
|
||||
|
||||
TIME_PER_POINT = {1: 5, 2: 10, 3: 15, 4: 20, 5: 25, 6: 30, 7: 45, 8: 60, 9: 90, 10: 120}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PRComplexity:
|
||||
pr_number: int
|
||||
title: str
|
||||
files_changed: int
|
||||
additions: int
|
||||
deletions: int
|
||||
has_dependency_changes: bool
|
||||
test_coverage_delta: Optional[int]
|
||||
score: int
|
||||
estimated_minutes: int
|
||||
reasons: List[str]
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, token: str):
|
||||
self.token = token
|
||||
self.base_url = GITEA_BASE.rstrip("/")
|
||||
|
||||
def _request(self, path: str, params: Dict = None) -> Any:
|
||||
url = f"{self.base_url}{path}"
|
||||
if params:
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
url += f"?{qs}"
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"token {self.token}")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"API error {e.code}: {e.read().decode()[:200]}", file=sys.stderr)
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Network error: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def get_open_prs(self, org: str, repo: str) -> List[Dict]:
|
||||
prs = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = self._request(f"/repos/{org}/{repo}/pulls", {"limit": 50, "page": page, "state": "open"})
|
||||
if not batch:
|
||||
break
|
||||
prs.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
page += 1
|
||||
return prs
|
||||
|
||||
def get_pr_files(self, org: str, repo: str, pr_number: int) -> List[Dict]:
|
||||
files = []
|
||||
page = 1
|
||||
while True:
|
||||
batch = self._request(
|
||||
f"/repos/{org}/{repo}/pulls/{pr_number}/files",
|
||||
{"limit": 100, "page": page}
|
||||
)
|
||||
if not batch:
|
||||
break
|
||||
files.extend(batch)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
page += 1
|
||||
return files
|
||||
|
||||
def post_comment(self, org: str, repo: str, pr_number: int, body: str) -> bool:
|
||||
data = json.dumps({"body": body}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{self.base_url}/repos/{org}/{repo}/issues/{pr_number}/comments",
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"}
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.status in (200, 201)
|
||||
except urllib.error.HTTPError:
|
||||
return False
|
||||
|
||||
|
||||
def is_dependency_file(filename: str) -> bool:
|
||||
return any(filename.endswith(dep) for dep in DEPENDENCY_FILES)
|
||||
|
||||
|
||||
def is_test_file(filename: str) -> bool:
|
||||
return any(re.search(pattern, filename) for pattern in TEST_PATTERNS)
|
||||
|
||||
|
||||
def score_pr(
|
||||
files_changed: int,
|
||||
additions: int,
|
||||
deletions: int,
|
||||
has_dependency_changes: bool,
|
||||
test_coverage_delta: Optional[int] = None
|
||||
) -> tuple[int, int, List[str]]:
|
||||
score = 1.0
|
||||
reasons = []
|
||||
|
||||
# Files changed
|
||||
if files_changed <= SMALL_FILES:
|
||||
fscore = 1.0
|
||||
reasons.append("small number of files changed")
|
||||
elif files_changed <= MEDIUM_FILES:
|
||||
fscore = 2.0
|
||||
reasons.append("moderate number of files changed")
|
||||
elif files_changed <= LARGE_FILES:
|
||||
fscore = 2.5
|
||||
reasons.append("large number of files changed")
|
||||
else:
|
||||
fscore = 3.0
|
||||
reasons.append("very large PR spanning many files")
|
||||
|
||||
# Lines changed
|
||||
total_lines = additions + deletions
|
||||
if total_lines <= SMALL_LINES:
|
||||
lscore = 1.0
|
||||
reasons.append("small change size")
|
||||
elif total_lines <= MEDIUM_LINES:
|
||||
lscore = 2.0
|
||||
reasons.append("moderate change size")
|
||||
elif total_lines <= LARGE_LINES:
|
||||
lscore = 3.0
|
||||
reasons.append("large change size")
|
||||
else:
|
||||
lscore = 4.0
|
||||
reasons.append("very large change")
|
||||
|
||||
# Dependency changes
|
||||
if has_dependency_changes:
|
||||
dscore = 2.5
|
||||
reasons.append("dependency changes (architectural impact)")
|
||||
else:
|
||||
dscore = 0.0
|
||||
|
||||
# Test coverage delta
|
||||
tscore = 0.0
|
||||
if test_coverage_delta is not None:
|
||||
if test_coverage_delta > 0:
|
||||
reasons.append(f"test additions (+{test_coverage_delta} test files)")
|
||||
tscore = -min(2.0, test_coverage_delta / 2.0)
|
||||
elif test_coverage_delta < 0:
|
||||
reasons.append(f"test removals ({abs(test_coverage_delta)} test files)")
|
||||
tscore = min(2.0, abs(test_coverage_delta) * 0.5)
|
||||
else:
|
||||
reasons.append("test coverage change not assessed")
|
||||
|
||||
# Weighted sum, scaled by 3 to use full 1-10 range
|
||||
bonus = (fscore * WEIGHT_FILES) + (lscore * WEIGHT_LINES) + (dscore * WEIGHT_DEPS) + (tscore * WEIGHT_TEST_COV)
|
||||
scaled_bonus = bonus * 3.0
|
||||
score = 1.0 + scaled_bonus
|
||||
|
||||
final_score = max(1, min(10, int(round(score))))
|
||||
est_minutes = TIME_PER_POINT.get(final_score, 30)
|
||||
|
||||
return final_score, est_minutes, reasons
|
||||
|
||||
|
||||
def analyze_pr(client: GiteaClient, org: str, repo: str, pr_data: Dict) -> PRComplexity:
|
||||
pr_num = pr_data["number"]
|
||||
title = pr_data.get("title", "")
|
||||
files = client.get_pr_files(org, repo, pr_num)
|
||||
|
||||
additions = sum(f.get("additions", 0) for f in files)
|
||||
deletions = sum(f.get("deletions", 0) for f in files)
|
||||
filenames = [f.get("filename", "") for f in files]
|
||||
|
||||
has_deps = any(is_dependency_file(f) for f in filenames)
|
||||
|
||||
test_added = sum(1 for f in files if f.get("status") == "added" and is_test_file(f.get("filename", "")))
|
||||
test_removed = sum(1 for f in files if f.get("status") == "removed" and is_test_file(f.get("filename", "")))
|
||||
test_delta = test_added - test_removed if (test_added or test_removed) else None
|
||||
|
||||
score, est_min, reasons = score_pr(
|
||||
files_changed=len(files),
|
||||
additions=additions,
|
||||
deletions=deletions,
|
||||
has_dependency_changes=has_deps,
|
||||
test_coverage_delta=test_delta
|
||||
)
|
||||
|
||||
return PRComplexity(
|
||||
pr_number=pr_num,
|
||||
title=title,
|
||||
files_changed=len(files),
|
||||
additions=additions,
|
||||
deletions=deletions,
|
||||
has_dependency_changes=has_deps,
|
||||
test_coverage_delta=test_delta,
|
||||
score=score,
|
||||
estimated_minutes=est_min,
|
||||
reasons=reasons
|
||||
)
|
||||
|
||||
|
||||
def build_comment(complexity: PRComplexity) -> str:
|
||||
change_desc = f"{complexity.files_changed} files, +{complexity.additions}/-{complexity.deletions} lines"
|
||||
deps_note = "\n- :warning: Dependency changes detected — architectural review recommended" if complexity.has_dependency_changes else ""
|
||||
test_note = ""
|
||||
if complexity.test_coverage_delta is not None:
|
||||
if complexity.test_coverage_delta > 0:
|
||||
test_note = f"\n- :+1: {complexity.test_coverage_delta} test file(s) added"
|
||||
elif complexity.test_coverage_delta < 0:
|
||||
test_note = f"\n- :warning: {abs(complexity.test_coverage_delta)} test file(s) removed"
|
||||
|
||||
comment = f"## 📊 PR Complexity Analysis\n\n"
|
||||
comment += f"**PR #{complexity.pr_number}: {complexity.title}**\n\n"
|
||||
comment += f"| Metric | Value |\n|--------|-------|\n"
|
||||
comment += f"| Changes | {change_desc} |\n"
|
||||
comment += f"| Complexity Score | **{complexity.score}/10** |\n"
|
||||
comment += f"| Estimated Review Time | ~{complexity.estimated_minutes} minutes |\n\n"
|
||||
comment += f"### Scoring rationale:"
|
||||
for r in complexity.reasons:
|
||||
comment += f"\n- {r}"
|
||||
if deps_note:
|
||||
comment += deps_note
|
||||
if test_note:
|
||||
comment += test_note
|
||||
comment += f"\n\n---\n"
|
||||
comment += f"*Generated by PR Complexity Scorer — [issue #135](https://forge.alexanderwhitestone.com/Timmy_Foundation/compounding-intelligence/issues/135)*"
|
||||
return comment
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="PR Complexity Scorer")
|
||||
parser.add_argument("--org", default="Timmy_Foundation")
|
||||
parser.add_argument("--repo", default="compounding-intelligence")
|
||||
parser.add_argument("--token", default=os.environ.get("GITEA_TOKEN") or os.path.expanduser("~/.config/gitea/token"))
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--output", default="metrics/pr_complexity.json")
|
||||
args = parser.parse_args()
|
||||
|
||||
token_path = args.token
|
||||
if os.path.exists(token_path):
|
||||
with open(token_path) as f:
|
||||
token = f.read().strip()
|
||||
else:
|
||||
token = args.token
|
||||
|
||||
if not token:
|
||||
print("ERROR: No Gitea token provided", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
client = GiteaClient(token)
|
||||
|
||||
print(f"Fetching open PRs for {args.org}/{args.repo}...")
|
||||
prs = client.get_open_prs(args.org, args.repo)
|
||||
if not prs:
|
||||
print("No open PRs found.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {len(prs)} open PR(s). Analyzing...")
|
||||
|
||||
results = []
|
||||
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for pr in prs:
|
||||
pr_num = pr["number"]
|
||||
title = pr.get("title", "")
|
||||
print(f" Analyzing PR #{pr_num}: {title[:60]}")
|
||||
|
||||
try:
|
||||
complexity = analyze_pr(client, args.org, args.repo, pr)
|
||||
results.append(complexity.to_dict())
|
||||
|
||||
comment = build_comment(complexity)
|
||||
|
||||
if args.dry_run:
|
||||
print(f" → Score: {complexity.score}/10, Est: {complexity.estimated_minutes}min [DRY-RUN]")
|
||||
elif args.apply:
|
||||
success = client.post_comment(args.org, args.repo, pr_num, comment)
|
||||
status = "[commented]" if success else "[FAILED]"
|
||||
print(f" → Score: {complexity.score}/10, Est: {complexity.estimated_minutes}min {status}")
|
||||
else:
|
||||
print(f" → Score: {complexity.score}/10, Est: {complexity.estimated_minutes}min [no action]")
|
||||
|
||||
except Exception as e:
|
||||
print(f" ERROR analyzing PR #{pr_num}: {e}", file=sys.stderr)
|
||||
|
||||
with open(args.output, "w") as f:
|
||||
json.dump({
|
||||
"org": args.org,
|
||||
"repo": args.repo,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"pr_count": len(results),
|
||||
"results": results
|
||||
}, f, indent=2)
|
||||
|
||||
if results:
|
||||
scores = [r["score"] for r in results]
|
||||
print(f"\nResults saved to {args.output}")
|
||||
print(f"Summary: {len(results)} PRs, scores range {min(scores):.0f}-{max(scores):.0f}")
|
||||
else:
|
||||
print("\nNo results to save.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
168
scripts/test_code_duplication_detector.py
Normal file
168
scripts/test_code_duplication_detector.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke test for code duplication detector — verifies:
|
||||
- Function extraction from Python files
|
||||
- Exact duplicate detection
|
||||
- Near-duplicate detection (token similarity)
|
||||
- Report generation and stats
|
||||
- JSON output format
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.absolute()
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from code_duplication_detector import (
|
||||
extract_functions_from_file,
|
||||
scan_directory,
|
||||
find_duplicates,
|
||||
generate_report,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_functions():
|
||||
"""Test that function extraction works."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
test_file = Path(tmpdir) / 'sample.py'
|
||||
test_file.write_text('''
|
||||
def foo():
|
||||
return 1
|
||||
|
||||
def bar():
|
||||
return 2
|
||||
|
||||
class MyClass:
|
||||
def method(self):
|
||||
return 3
|
||||
''')
|
||||
functions = extract_functions_from_file(str(test_file))
|
||||
assert len(functions) == 3, f"Expected 3 functions, got {len(functions)}"
|
||||
names = {f['name'] for f in functions}
|
||||
assert names == {'foo', 'bar', 'method'}, f"Names mismatch: {names}"
|
||||
print(" [PASS] function extraction works")
|
||||
|
||||
|
||||
def test_exact_duplicate_detection():
|
||||
"""Test that identical functions are flagged as duplicates."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create two files with the same function
|
||||
f1 = Path(tmpdir) / 'a.py'
|
||||
f1.write_text('''
|
||||
def duplicated():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
''')
|
||||
f2 = Path(tmpdir) / 'b.py'
|
||||
f2.write_text('''
|
||||
def duplicated():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
''')
|
||||
functions = scan_directory(tmpdir)
|
||||
results = find_duplicates(functions)
|
||||
stats = results['stats']
|
||||
assert stats['exact_dupe_count'] >= 1, f"Expected exact duplicate, got count={stats['exact_dupe_count']}"
|
||||
assert len(results['exact_duplicates']) >= 1, "Should have at least one duplicate group"
|
||||
print(" [PASS] exact duplicate detection works")
|
||||
|
||||
|
||||
def test_unique_functions_not_flagged():
|
||||
"""Test that different functions are not flagged as duplicates."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
f1 = Path(tmpdir) / 'a.py'
|
||||
f1.write_text('def func_a(): return 1')
|
||||
f2 = Path(tmpdir) / 'b.py'
|
||||
f2.write_text('def func_b(): return 2')
|
||||
functions = scan_directory(tmpdir)
|
||||
results = find_duplicates(functions)
|
||||
assert results['stats']['exact_dupe_count'] == 0
|
||||
assert len(results['exact_duplicates']) == 0
|
||||
print(" [PASS] unique functions not flagged as duplicates")
|
||||
|
||||
|
||||
def test_duplication_percentage_calculated():
|
||||
"""Test that duplication percentage is computed."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
# Create file with mostly duplicated content
|
||||
f1 = Path(tmpdir) / 'a.py'
|
||||
f1.write_text('''
|
||||
def common():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
|
||||
def unique1():
|
||||
return 100
|
||||
''')
|
||||
f2 = Path(tmpdir) / 'b.py'
|
||||
f2.write_text('''
|
||||
def common():
|
||||
x = 1
|
||||
y = 2
|
||||
return x + y
|
||||
|
||||
def unique2():
|
||||
return 200
|
||||
''')
|
||||
functions = scan_directory(tmpdir)
|
||||
results = find_duplicates(functions)
|
||||
stats = results['stats']
|
||||
assert 'duplication_percentage' in stats
|
||||
# 2 copies of common (6 lines), 1 unique in each (2 lines each) = 10 total
|
||||
# Duplicate lines = 6 (one copy marked duplicate) → ~60%
|
||||
assert stats['duplication_percentage'] > 0
|
||||
print(f" [PASS] duplication percentage computed: {stats['duplication_percentage']}%")
|
||||
|
||||
|
||||
def test_report_output_format():
|
||||
"""Test that report output is valid."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
f1 = Path(tmpdir) / 'a.py'
|
||||
f1.write_text('def dup(): return 1')
|
||||
f2 = Path(tmpdir) / 'b.py'
|
||||
f2.write_text('def dup(): return 1')
|
||||
functions = scan_directory(tmpdir)
|
||||
results = find_duplicates(functions)
|
||||
|
||||
# Text report
|
||||
text = generate_report(results, output_format='text')
|
||||
assert 'CODE DUPLICATION REPORT' in text
|
||||
assert 'Total functions' in text
|
||||
print(" [PASS] text report format valid")
|
||||
|
||||
# JSON report
|
||||
json_out = generate_report(results, output_format='json')
|
||||
data = json.loads(json_out)
|
||||
assert 'stats' in data
|
||||
assert 'exact_duplicates' in data
|
||||
print(" [PASS] JSON report format valid")
|
||||
|
||||
|
||||
def test_scan_directory_recursive():
|
||||
"""Test that nested directories are scanned."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
subdir = Path(tmpdir) / 'sub'
|
||||
subdir.mkdir()
|
||||
(subdir / 'nested.py').write_text('def nested(): pass')
|
||||
(Path(tmpdir) / 'root.py').write_text('def root(): pass')
|
||||
functions = scan_directory(tmpdir)
|
||||
names = {f['name'] for f in functions}
|
||||
assert 'nested' in names and 'root' in names
|
||||
print(" [PASS] recursive directory scanning works")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Running code duplication detector smoke tests...")
|
||||
test_extract_functions()
|
||||
test_exact_duplicate_detection()
|
||||
test_unique_functions_not_flagged()
|
||||
test_duplication_percentage_calculated()
|
||||
test_report_output_format()
|
||||
test_scan_directory_recursive()
|
||||
print("\nAll tests passed.")
|
||||
170
scripts/test_pr_complexity_scorer.py
Normal file
170
scripts/test_pr_complexity_scorer.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for PR Complexity Scorer — unit tests for the scoring logic.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from pr_complexity_scorer import (
|
||||
score_pr,
|
||||
is_dependency_file,
|
||||
is_test_file,
|
||||
TIME_PER_POINT,
|
||||
SMALL_FILES,
|
||||
MEDIUM_FILES,
|
||||
LARGE_FILES,
|
||||
SMALL_LINES,
|
||||
MEDIUM_LINES,
|
||||
LARGE_LINES,
|
||||
)
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
def test(name):
|
||||
def decorator(fn):
|
||||
global PASS, FAIL
|
||||
try:
|
||||
fn()
|
||||
PASS += 1
|
||||
print(f" [PASS] {name}")
|
||||
except AssertionError as e:
|
||||
FAIL += 1
|
||||
print(f" [FAIL] {name}: {e}")
|
||||
except Exception as e:
|
||||
FAIL += 1
|
||||
print(f" [FAIL] {name}: Unexpected error: {e}")
|
||||
return decorator
|
||||
|
||||
def assert_eq(a, b, msg=""):
|
||||
if a != b:
|
||||
raise AssertionError(f"{msg} expected {b!r}, got {a!r}")
|
||||
|
||||
def assert_true(v, msg=""):
|
||||
if not v:
|
||||
raise AssertionError(msg or "Expected True")
|
||||
|
||||
def assert_false(v, msg=""):
|
||||
if v:
|
||||
raise AssertionError(msg or "Expected False")
|
||||
|
||||
|
||||
print("=== PR Complexity Scorer Tests ===\n")
|
||||
|
||||
print("-- File Classification --")
|
||||
|
||||
@test("dependency file detection — requirements.txt")
|
||||
def _():
|
||||
assert_true(is_dependency_file("requirements.txt"))
|
||||
assert_true(is_dependency_file("src/requirements.txt"))
|
||||
assert_false(is_dependency_file("requirements_test.txt"))
|
||||
|
||||
@test("dependency file detection — pyproject.toml")
|
||||
def _():
|
||||
assert_true(is_dependency_file("pyproject.toml"))
|
||||
assert_false(is_dependency_file("myproject.py"))
|
||||
|
||||
@test("test file detection — pytest style")
|
||||
def _():
|
||||
assert_true(is_test_file("tests/test_api.py"))
|
||||
assert_true(is_test_file("test_module.py"))
|
||||
assert_true(is_test_file("src/module_test.py"))
|
||||
|
||||
@test("test file detection — other frameworks")
|
||||
def _():
|
||||
assert_true(is_test_file("spec/feature_spec.rb"))
|
||||
assert_true(is_test_file("__tests__/component.test.js"))
|
||||
assert_false(is_test_file("testfixtures/helper.py"))
|
||||
|
||||
|
||||
print("\n-- Scoring Logic --")
|
||||
|
||||
@test("small PR gets low score (1-3)")
|
||||
def _():
|
||||
score, minutes, _ = score_pr(
|
||||
files_changed=3,
|
||||
additions=50,
|
||||
deletions=10,
|
||||
has_dependency_changes=False,
|
||||
test_coverage_delta=None
|
||||
)
|
||||
assert_true(1 <= score <= 3, f"Score should be low, got {score}")
|
||||
assert_true(minutes < 20)
|
||||
|
||||
@test("medium PR gets medium score (4-6)")
|
||||
def _():
|
||||
score, minutes, _ = score_pr(
|
||||
files_changed=15,
|
||||
additions=400,
|
||||
deletions=100,
|
||||
has_dependency_changes=False,
|
||||
test_coverage_delta=None
|
||||
)
|
||||
assert_true(4 <= score <= 6, f"Score should be medium, got {score}")
|
||||
assert_true(20 <= minutes <= 45)
|
||||
|
||||
@test("large PR gets high score (7-9)")
|
||||
def _():
|
||||
score, minutes, _ = score_pr(
|
||||
files_changed=60,
|
||||
additions=3000,
|
||||
deletions=1500,
|
||||
has_dependency_changes=True,
|
||||
test_coverage_delta=None
|
||||
)
|
||||
assert_true(7 <= score <= 9, f"Score should be high, got {score}")
|
||||
assert_true(minutes >= 45)
|
||||
|
||||
@test("dependency changes boost score")
|
||||
def _():
|
||||
base_score, _, _ = score_pr(
|
||||
files_changed=10, additions=200, deletions=50,
|
||||
has_dependency_changes=False, test_coverage_delta=None
|
||||
)
|
||||
dep_score, _, _ = score_pr(
|
||||
files_changed=10, additions=200, deletions=50,
|
||||
has_dependency_changes=True, test_coverage_delta=None
|
||||
)
|
||||
assert_true(dep_score > base_score, f"Deps: {base_score} -> {dep_score}")
|
||||
|
||||
@test("adding tests lowers complexity")
|
||||
def _():
|
||||
base_score, _, _ = score_pr(
|
||||
files_changed=8, additions=150, deletions=20,
|
||||
has_dependency_changes=False, test_coverage_delta=None
|
||||
)
|
||||
better_score, _, _ = score_pr(
|
||||
files_changed=8, additions=180, deletions=20,
|
||||
has_dependency_changes=False, test_coverage_delta=3
|
||||
)
|
||||
assert_true(better_score < base_score, f"Tests: {base_score} -> {better_score}")
|
||||
|
||||
@test("removing tests increases complexity")
|
||||
def _():
|
||||
base_score, _, _ = score_pr(
|
||||
files_changed=8, additions=150, deletions=20,
|
||||
has_dependency_changes=False, test_coverage_delta=None
|
||||
)
|
||||
worse_score, _, _ = score_pr(
|
||||
files_changed=8, additions=150, deletions=20,
|
||||
has_dependency_changes=False, test_coverage_delta=-2
|
||||
)
|
||||
assert_true(worse_score > base_score, f"Remove tests: {base_score} -> {worse_score}")
|
||||
|
||||
@test("score bounded 1-10")
|
||||
def _():
|
||||
for files, adds, dels in [(1, 10, 5), (100, 10000, 5000)]:
|
||||
score, _, _ = score_pr(files, adds, dels, False, None)
|
||||
assert_true(1 <= score <= 10, f"Score {score} out of range")
|
||||
|
||||
@test("estimated minutes exist for all scores")
|
||||
def _():
|
||||
for s in range(1, 11):
|
||||
assert_true(s in TIME_PER_POINT, f"Missing time for score {s}")
|
||||
|
||||
|
||||
print(f"\n=== Results: {PASS} passed, {FAIL} failed ===")
|
||||
sys.exit(0 if FAIL == 0 else 1)
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Smoke tests for API Doc Generator — Issue #98
|
||||
|
||||
Validates that the generator runs, produces docs/API.md, and that
|
||||
the generated markdown contains expected sections for the known scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Resolve repo root
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS_DIR = REPO_ROOT / "scripts"
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
API_MD = DOCS_DIR / "API.md"
|
||||
GENERATOR = SCRIPTS_DIR / "api_doc_generator.py"
|
||||
|
||||
|
||||
# ─── Generator presence ─────────────────────────────────────────────────────────
|
||||
class TestGeneratorPresence:
|
||||
def test_generator_script_exists(self):
|
||||
assert GENERATOR.exists(), f"Missing: {GENERATOR}"
|
||||
|
||||
def test_generator_is_executable(self):
|
||||
with open(GENERATOR) as f:
|
||||
first = f.readline().strip()
|
||||
assert first.startswith("#!"), "Missing shebang"
|
||||
assert "python" in first.lower()
|
||||
|
||||
|
||||
# ─── API.md generation ──────────────────────────────────────────────────────────
|
||||
class TestAPIDocGeneration:
|
||||
def test_generator_runs_successfully(self):
|
||||
"""Run the generator and verify exit code 0."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR)],
|
||||
capture_output=True, text=True, cwd=REPO_ROOT, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"Generator failed (code {{result.returncode}})\n"
|
||||
f"STDERR: {{result.stderr[:500]}}"
|
||||
)
|
||||
|
||||
def test_api_md_is_created(self):
|
||||
"""docs/API.md must exist after generation."""
|
||||
assert API_MD.exists(), f"Missing output: {API_MD}"
|
||||
|
||||
def test_api_md_is_not_empty(self):
|
||||
"""Generate markdown must have substantial content."""
|
||||
content = API_MD.read_text(encoding="utf-8")
|
||||
assert len(content) > 1000, "API.md is suspiciously small"
|
||||
|
||||
def test_api_md_has_expected_structure(self):
|
||||
"""Top-level headings and table markers must be present."""
|
||||
content = API_MD.read_text(encoding="utf-8")
|
||||
assert "# Compounding Intelligence — Scripts API Reference" in content
|
||||
assert "## `scripts/" in content
|
||||
assert "| Function | Signature | Description |" in content
|
||||
|
||||
def test_api_md_covers_expected_scripts(self):
|
||||
"""At minimum the core scripts should be documented."""
|
||||
content = API_MD.read_text(encoding="utf-8")
|
||||
# Core scripts that must appear
|
||||
core = ["scripts/harvester.py", "scripts/bootstrapper.py",
|
||||
"scripts/session_reader.py", "scripts/dedup.py"]
|
||||
for rel in core:
|
||||
assert f"## `{rel}`" in content, f"Missing section for {rel}"
|
||||
|
||||
def test_api_md_contains_function_names(self):
|
||||
"""Spot-check: known public functions from key modules must appear."""
|
||||
content = API_MD.read_text(encoding="utf-8")
|
||||
checks = [
|
||||
("harvester", "read_session"),
|
||||
("bootstrapper", "load_index"),
|
||||
("session_reader", "extract_conversation"),
|
||||
("dedup", "normalize_text"),
|
||||
]
|
||||
for module_stem, func_name in checks:
|
||||
assert f"| `{func_name}` |" in content, f"Missing function {func_name} from {module_stem}"
|
||||
|
||||
|
||||
# ─── Idempotence / --check ─────────────────────────────────────────────────────
|
||||
class TestIdempotence:
|
||||
def test_check_flag_passes_when_current(self):
|
||||
"""`--check` should exit 0 immediately after generation."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), "--check"],
|
||||
capture_output=True, text=True, cwd=REPO_ROOT, timeout=30
|
||||
)
|
||||
assert result.returncode == 0, (
|
||||
f"--check failed\nSTDOUT: {{result.stdout}}\nSTDERR: {{result.stderr[:200]}}"
|
||||
)
|
||||
|
||||
def test_check_fails_when_api_md_stale(self):
|
||||
"""If docs/API.md is manually altered, --check should detect staleness."""
|
||||
# Generate fresh baseline first
|
||||
subprocess.run([sys.executable, str(GENERATOR)], capture_output=True, cwd=REPO_ROOT, timeout=30)
|
||||
|
||||
# Corrupt API.md slightly (append a line at the end)
|
||||
original = API_MD.read_text(encoding="utf-8")
|
||||
corrupted = original + "\n<!-- corrupted -->\n"
|
||||
API_MD.write_text(corrupted, encoding="utf-8")
|
||||
|
||||
# --check should now fail
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), "--check"],
|
||||
capture_output=True, text=True, cwd=REPO_ROOT, timeout=30
|
||||
)
|
||||
assert result.returncode != 0, "--check should detect stale API.md"
|
||||
assert "out-of-date" in result.stderr.lower() or "out-of-date" in result.stdout.lower()
|
||||
|
||||
# Restore clean state
|
||||
subprocess.run([sys.executable, str(GENERATOR)], capture_output=True, cwd=REPO_ROOT, timeout=30)
|
||||
assert API_MD.read_text(encoding="utf-8") == original
|
||||
|
||||
# ─── JSON output ────────────────────────────────────────────────────────────────
|
||||
class TestJSONOutput:
|
||||
def test_json_flag_emits_valid_json(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), "--json"],
|
||||
capture_output=True, text=True, cwd=REPO_ROOT, timeout=30
|
||||
)
|
||||
assert result.returncode == 0
|
||||
import json
|
||||
payload = json.loads(result.stdout)
|
||||
assert "modules" in payload
|
||||
assert len(payload["modules"]) >= 30
|
||||
|
||||
def test_json_has_expected_fields(self):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(GENERATOR), "--json"],
|
||||
capture_output=True, text=True, cwd=REPO_ROOT, timeout=30
|
||||
)
|
||||
import json
|
||||
payload = json.loads(result.stdout)
|
||||
mod = payload["modules"][0]
|
||||
for key in ("path", "docstring", "functions"):
|
||||
assert key in mod, f"Missing key {{key}} in module payload"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user