roam-code 8.1.0__tar.gz → 8.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {roam_code-8.1.0 → roam_code-8.2.0}/PKG-INFO +4 -4
- {roam_code-8.1.0 → roam_code-8.2.0}/README.md +3 -3
- {roam_code-8.1.0 → roam_code-8.2.0}/pyproject.toml +1 -1
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_health.py +9 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_patterns.py +26 -3
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/graph_helpers.py +0 -8
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/metrics_history.py +49 -13
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/__init__.py +1 -4
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/cycles.py +0 -52
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/layers.py +0 -28
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/pathfinding.py +0 -28
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/file_roles.py +2 -1
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/git_stats.py +0 -64
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/python_lang.py +226 -3
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/PKG-INFO +4 -4
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/SOURCES.txt +2 -0
- roam_code-8.2.0/tests/test_python_extractor_v2.py +485 -0
- roam_code-8.2.0/tests/test_v82_features.py +400 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/LICENSE +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/setup.cfg +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/__main__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/base.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/bridge_protobuf.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/bridge_salesforce.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/registry.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/cli.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/changed_files.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_affected_tests.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_alerts.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_breaking.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_bus_factor.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_clusters.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_complexity.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_context.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_conventions.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_coupling.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_coverage_gaps.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_dead.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_debt.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_deps.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_describe.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_diagnose.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_diff.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_digest.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_doc_staleness.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_entry_points.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fan.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_file.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fitness.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fn_coupling.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_grep.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_impact.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_index.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_init.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_layers.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_map.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_module.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_owner.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_pr_risk.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_preflight.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_report.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_risk.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_safe_delete.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_safe_zones.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_search.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_sketch.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_snapshot.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_split.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_symbol.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_testmap.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_tour.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_trace.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_trend.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_understand.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_uses.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_visualize.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_weather.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_why.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_ws.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_xlang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/context_helpers.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/gate_presets.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/resolve.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/connection.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/queries.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/schema.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/anomaly.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/builder.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/clusters.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/pagerank.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/complexity.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/discovery.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/incremental.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/indexer.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/parser.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/relations.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/symbols.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/test_conventions.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/apex_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/aura_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/base.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/c_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/foxpro_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/generic_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/go_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/java_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/javascript_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/php_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/registry.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/rust_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/sfxml_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/typescript_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/visualforce_lang.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/mcp_server.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/formatter.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/sarif.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/__init__.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/aggregator.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/api_scanner.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/config.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/db.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/dependency_links.txt +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/entry_points.txt +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/requires.txt +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/top_level.txt +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_anomaly.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_basic.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_bridges.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_architecture.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_exploration.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_health.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_refactoring.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_workflow.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_comprehensive.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_dead_aging.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_file_roles.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_fixes.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_formatters.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_foxpro.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_gate_presets.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_json_contracts.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_languages.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_performance.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_pr_risk_author.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_resolve.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_salesforce.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_smoke.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_test_conventions.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v6_features.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v71_features.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v7_features.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_visualize.py +0 -0
- {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: roam-code
|
|
3
|
-
Version: 8.
|
|
3
|
+
Version: 8.2.0
|
|
4
4
|
Summary: Instant codebase comprehension for AI coding agents
|
|
5
5
|
Author: CosmoHac
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1023,7 +1023,7 @@ Delete `.roam/` from your project root to clean up local data.
|
|
|
1023
1023
|
git clone https://github.com/Cranot/roam-code.git
|
|
1024
1024
|
cd roam-code
|
|
1025
1025
|
pip install -e ".[dev]" # includes pytest, ruff
|
|
1026
|
-
pytest tests/ #
|
|
1026
|
+
pytest tests/ # 1729 tests, Python 3.9-3.13
|
|
1027
1027
|
|
|
1028
1028
|
# Or use Make targets:
|
|
1029
1029
|
make dev # install with dev extras
|
|
@@ -1079,7 +1079,7 @@ roam-code/
|
|
|
1079
1079
|
│ └── output/
|
|
1080
1080
|
│ ├── formatter.py # Token-efficient formatting
|
|
1081
1081
|
│ └── sarif.py # SARIF 2.1.0 output
|
|
1082
|
-
└── tests/ #
|
|
1082
|
+
└── tests/ # 1729 tests across 30 test files
|
|
1083
1083
|
```
|
|
1084
1084
|
|
|
1085
1085
|
</details>
|
|
@@ -1120,7 +1120,7 @@ Optional: [fastmcp](https://github.com/jlowin/fastmcp) (MCP server)
|
|
|
1120
1120
|
git clone https://github.com/Cranot/roam-code.git
|
|
1121
1121
|
cd roam-code
|
|
1122
1122
|
pip install -e .
|
|
1123
|
-
pytest tests/ # All
|
|
1123
|
+
pytest tests/ # All 1729 tests must pass
|
|
1124
1124
|
```
|
|
1125
1125
|
|
|
1126
1126
|
Good first contributions: add a [Tier 1 language](src/roam/languages/) (see `go_lang.py` or `php_lang.py` as templates), improve reference resolution, add benchmark repos, extend SARIF converters, add MCP tools.
|
|
@@ -988,7 +988,7 @@ Delete `.roam/` from your project root to clean up local data.
|
|
|
988
988
|
git clone https://github.com/Cranot/roam-code.git
|
|
989
989
|
cd roam-code
|
|
990
990
|
pip install -e ".[dev]" # includes pytest, ruff
|
|
991
|
-
pytest tests/ #
|
|
991
|
+
pytest tests/ # 1729 tests, Python 3.9-3.13
|
|
992
992
|
|
|
993
993
|
# Or use Make targets:
|
|
994
994
|
make dev # install with dev extras
|
|
@@ -1044,7 +1044,7 @@ roam-code/
|
|
|
1044
1044
|
│ └── output/
|
|
1045
1045
|
│ ├── formatter.py # Token-efficient formatting
|
|
1046
1046
|
│ └── sarif.py # SARIF 2.1.0 output
|
|
1047
|
-
└── tests/ #
|
|
1047
|
+
└── tests/ # 1729 tests across 30 test files
|
|
1048
1048
|
```
|
|
1049
1049
|
|
|
1050
1050
|
</details>
|
|
@@ -1085,7 +1085,7 @@ Optional: [fastmcp](https://github.com/jlowin/fastmcp) (MCP server)
|
|
|
1085
1085
|
git clone https://github.com/Cranot/roam-code.git
|
|
1086
1086
|
cd roam-code
|
|
1087
1087
|
pip install -e .
|
|
1088
|
-
pytest tests/ # All
|
|
1088
|
+
pytest tests/ # All 1729 tests must pass
|
|
1089
1089
|
```
|
|
1090
1090
|
|
|
1091
1091
|
Good first contributions: add a [Tier 1 language](src/roam/languages/) (see `go_lang.py` or `php_lang.py` as templates), improve reference resolution, add benchmark repos, extend SARIF converters, add MCP tools.
|
|
@@ -59,12 +59,21 @@ _UTILITY_FILE_PATTERNS = (
|
|
|
59
59
|
"resolve.py", "helpers.py", "common.py", "base.py",
|
|
60
60
|
)
|
|
61
61
|
|
|
62
|
+
# Paths that are NOT production code — treat as expected utilities
|
|
63
|
+
_NON_PRODUCTION_PATH_PATTERNS = (
|
|
64
|
+
"tests/", "test/", "__tests__/", "spec/",
|
|
65
|
+
"dev/", "scripts/", "bin/", "benchmark/",
|
|
66
|
+
"conftest.py",
|
|
67
|
+
)
|
|
68
|
+
|
|
62
69
|
|
|
63
70
|
def _is_utility_path(file_path):
|
|
64
71
|
"""Check if a file is in a utility/infrastructure directory or is a known utility file."""
|
|
65
72
|
p = file_path.replace("\\", "/").lower()
|
|
66
73
|
if any(pat in p for pat in _UTILITY_PATH_PATTERNS):
|
|
67
74
|
return True
|
|
75
|
+
if any(pat in p for pat in _NON_PRODUCTION_PATH_PATTERNS):
|
|
76
|
+
return True
|
|
68
77
|
basename = p.rsplit("/", 1)[-1] if "/" in p else p
|
|
69
78
|
return basename in _UTILITY_FILE_PATTERNS
|
|
70
79
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Detect common architectural patterns in the codebase symbol graph."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
3
6
|
import re
|
|
4
7
|
from collections import defaultdict
|
|
5
8
|
|
|
@@ -10,6 +13,20 @@ from roam.output.formatter import abbrev_kind, loc, format_table, to_json, json_
|
|
|
10
13
|
from roam.commands.resolve import ensure_index
|
|
11
14
|
|
|
12
15
|
|
|
16
|
+
def _is_test_or_detector_path(file_path):
|
|
17
|
+
"""Return True if file is test code or a pattern detector itself."""
|
|
18
|
+
p = file_path.replace("\\", "/").lower()
|
|
19
|
+
base = os.path.basename(p)
|
|
20
|
+
if base.startswith("test_") or base.endswith("_test.py"):
|
|
21
|
+
return True
|
|
22
|
+
if "tests/" in p or "test/" in p or "__tests__/" in p or "spec/" in p:
|
|
23
|
+
return True
|
|
24
|
+
# Exclude the patterns detector itself to avoid self-referential matches
|
|
25
|
+
if base == "cmd_patterns.py":
|
|
26
|
+
return True
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
|
|
13
30
|
# ---------------------------------------------------------------------------
|
|
14
31
|
# Pattern detection helpers
|
|
15
32
|
# ---------------------------------------------------------------------------
|
|
@@ -96,6 +113,8 @@ def _detect_factory(conn):
|
|
|
96
113
|
|
|
97
114
|
results = []
|
|
98
115
|
for r in rows:
|
|
116
|
+
if _is_test_or_detector_path(r["file_path"]):
|
|
117
|
+
continue
|
|
99
118
|
# Check for outgoing edges to constructors or class instantiations
|
|
100
119
|
targets = conn.execute(
|
|
101
120
|
"SELECT DISTINCT t.name, t.kind FROM edges e "
|
|
@@ -287,10 +306,8 @@ def _detect_middleware(conn):
|
|
|
287
306
|
"FROM symbols s JOIN files f ON s.file_id = f.id "
|
|
288
307
|
"WHERE ("
|
|
289
308
|
" s.name LIKE '%Middleware' OR s.name LIKE '%middleware' "
|
|
290
|
-
" OR s.name LIKE '%Handler' "
|
|
291
309
|
" OR s.name LIKE '%Interceptor' OR s.name LIKE '%interceptor' "
|
|
292
|
-
" OR s.name LIKE '%
|
|
293
|
-
" OR s.name LIKE '%Pipe' OR s.name LIKE '%Pipeline' "
|
|
310
|
+
" OR s.name LIKE '%Pipe' OR s.name LIKE '%Pipeline' OR s.name LIKE '%pipeline' "
|
|
294
311
|
") "
|
|
295
312
|
"AND s.kind IN ('class', 'function', 'method')"
|
|
296
313
|
).fetchall()
|
|
@@ -304,6 +321,8 @@ def _detect_middleware(conn):
|
|
|
304
321
|
for r in rows:
|
|
305
322
|
if r["id"] in seen:
|
|
306
323
|
continue
|
|
324
|
+
if _is_test_or_detector_path(r["file_path"]):
|
|
325
|
+
continue
|
|
307
326
|
seen.add(r["id"])
|
|
308
327
|
|
|
309
328
|
# Look for call-chain: what does this symbol call, and what calls it?
|
|
@@ -438,6 +457,8 @@ def _detect_decorator(conn):
|
|
|
438
457
|
for r in rows:
|
|
439
458
|
if r["name"] in seen:
|
|
440
459
|
continue
|
|
460
|
+
if _is_test_or_detector_path(r["file_path"]):
|
|
461
|
+
continue
|
|
441
462
|
seen.add(r["name"])
|
|
442
463
|
|
|
443
464
|
# Count how many symbols this decorator is applied to
|
|
@@ -474,6 +495,8 @@ def _detect_decorator(conn):
|
|
|
474
495
|
for r in wrapper_rows:
|
|
475
496
|
if r["name"] in seen:
|
|
476
497
|
continue
|
|
498
|
+
if _is_test_or_detector_path(r["file_path"]):
|
|
499
|
+
continue
|
|
477
500
|
seen.add(r["name"])
|
|
478
501
|
|
|
479
502
|
usage_count = conn.execute(
|
|
@@ -16,14 +16,6 @@ def build_forward_adj(conn):
|
|
|
16
16
|
return adj
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def build_reverse_adj(conn):
|
|
20
|
-
"""Build reverse adjacency list from edges table (target -> set of sources)."""
|
|
21
|
-
adj = defaultdict(set)
|
|
22
|
-
for row in conn.execute("SELECT source_id, target_id FROM edges").fetchall():
|
|
23
|
-
adj[row["target_id"]].add(row["source_id"])
|
|
24
|
-
return adj
|
|
25
|
-
|
|
26
|
-
|
|
27
19
|
def bfs_reachable(adj, start_ids, max_depth=None):
|
|
28
20
|
"""BFS from *start_ids* through an adjacency dict.
|
|
29
21
|
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"""Collect and persist health metrics for snapshot/trend tracking."""
|
|
2
2
|
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
3
6
|
import subprocess
|
|
4
7
|
import time
|
|
5
8
|
|
|
@@ -7,6 +10,12 @@ from roam.db.connection import find_project_root
|
|
|
7
10
|
from roam.db.queries import UNREFERENCED_EXPORTS
|
|
8
11
|
|
|
9
12
|
|
|
13
|
+
def _is_test_path(file_path):
|
|
14
|
+
"""Check if a file is a test file (discovered by pytest, not imported)."""
|
|
15
|
+
base = os.path.basename(file_path).lower()
|
|
16
|
+
return base.startswith("test_") or base.endswith("_test.py")
|
|
17
|
+
|
|
18
|
+
|
|
10
19
|
def collect_metrics(conn):
|
|
11
20
|
"""Query the DB for all health metrics and compute a health score.
|
|
12
21
|
|
|
@@ -41,8 +50,10 @@ def collect_metrics(conn):
|
|
|
41
50
|
).fetchall()
|
|
42
51
|
bottlenecks = len(bn_rows)
|
|
43
52
|
|
|
44
|
-
# Dead exports
|
|
53
|
+
# Dead exports (filter test files — they're discovered by pytest, not imported)
|
|
45
54
|
dead_rows = conn.execute(UNREFERENCED_EXPORTS).fetchall()
|
|
55
|
+
dead_rows = [r for r in dead_rows
|
|
56
|
+
if not _is_test_path(r["file_path"])]
|
|
46
57
|
dead_exports = len(dead_rows)
|
|
47
58
|
|
|
48
59
|
# Layer violations
|
|
@@ -56,18 +67,43 @@ def collect_metrics(conn):
|
|
|
56
67
|
except Exception:
|
|
57
68
|
pass
|
|
58
69
|
|
|
59
|
-
# Health score:
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
# Health score: weighted geometric mean matching cmd_health.py formula.
|
|
71
|
+
# Each factor h_i = e^(-signal/scale) is a health fraction in (0,1].
|
|
72
|
+
import math
|
|
73
|
+
|
|
74
|
+
def _hf(value, scale):
|
|
75
|
+
return math.exp(-value / scale) if scale > 0 else 1.0
|
|
76
|
+
|
|
77
|
+
tangle_r = 0.0
|
|
78
|
+
if G is not None and symbols > 0:
|
|
79
|
+
try:
|
|
80
|
+
cyc_ids = set()
|
|
81
|
+
for scc in find_cycles(G):
|
|
82
|
+
cyc_ids.update(scc)
|
|
83
|
+
tangle_r = len(cyc_ids) / symbols * 100
|
|
84
|
+
except Exception:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
god_signal = god_components * 0.5
|
|
88
|
+
bn_signal = bottlenecks * 0.3
|
|
89
|
+
|
|
90
|
+
factors = [
|
|
91
|
+
(_hf(tangle_r, 10), 0.30),
|
|
92
|
+
(_hf(god_signal, 5), 0.20),
|
|
93
|
+
(_hf(bn_signal, 4), 0.15),
|
|
94
|
+
(_hf(layer_violations, 5), 0.15),
|
|
95
|
+
]
|
|
96
|
+
# File health (if available)
|
|
97
|
+
try:
|
|
98
|
+
avg_fh = conn.execute(
|
|
99
|
+
"SELECT AVG(health_score) FROM file_stats WHERE health_score IS NOT NULL"
|
|
100
|
+
).fetchone()[0]
|
|
101
|
+
factors.append((min(1.0, (avg_fh or 10) / 10.0), 0.20))
|
|
102
|
+
except Exception:
|
|
103
|
+
factors.append((1.0, 0.20))
|
|
104
|
+
|
|
105
|
+
log_score = sum(w * math.log(max(h, 1e-9)) for h, w in factors)
|
|
106
|
+
health_score = max(0, min(100, int(100 * math.exp(log_score))))
|
|
71
107
|
|
|
72
108
|
# Tangle ratio: percentage of symbols in cycles
|
|
73
109
|
tangle_ratio = 0.0
|
|
@@ -8,14 +8,13 @@ from roam.graph.clusters import (
|
|
|
8
8
|
store_clusters,
|
|
9
9
|
)
|
|
10
10
|
from roam.graph.cycles import (
|
|
11
|
-
condense_cycles,
|
|
12
11
|
find_cycles,
|
|
13
12
|
find_weakest_edge,
|
|
14
13
|
format_cycles,
|
|
15
14
|
)
|
|
16
15
|
from roam.graph.layers import detect_layers, find_violations, format_layers
|
|
17
16
|
from roam.graph.pagerank import compute_centrality, compute_pagerank, store_metrics
|
|
18
|
-
from roam.graph.pathfinding import
|
|
17
|
+
from roam.graph.pathfinding import find_symbol_id, format_path
|
|
19
18
|
|
|
20
19
|
__all__ = [
|
|
21
20
|
"build_symbol_graph",
|
|
@@ -23,7 +22,6 @@ __all__ = [
|
|
|
23
22
|
"compute_pagerank",
|
|
24
23
|
"compute_centrality",
|
|
25
24
|
"store_metrics",
|
|
26
|
-
"condense_cycles",
|
|
27
25
|
"find_cycles",
|
|
28
26
|
"find_weakest_edge",
|
|
29
27
|
"format_cycles",
|
|
@@ -34,7 +32,6 @@ __all__ = [
|
|
|
34
32
|
"detect_layers",
|
|
35
33
|
"find_violations",
|
|
36
34
|
"format_layers",
|
|
37
|
-
"find_path",
|
|
38
35
|
"find_symbol_id",
|
|
39
36
|
"format_path",
|
|
40
37
|
]
|
|
@@ -100,58 +100,6 @@ def format_cycles(
|
|
|
100
100
|
return result
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
def condense_cycles(
|
|
104
|
-
G: nx.DiGraph, cycles: list[list[int]]
|
|
105
|
-
) -> tuple[nx.DiGraph, dict[int, list[int]]]:
|
|
106
|
-
"""Build a condensation DAG from the graph and its SCC cycles.
|
|
107
|
-
|
|
108
|
-
Uses ``nx.condensation(G)`` to collapse each SCC into a single node.
|
|
109
|
-
Each condensation node gets attributes:
|
|
110
|
-
|
|
111
|
-
- **members**: sorted list of original symbol IDs in that SCC
|
|
112
|
-
- **member_count**: number of symbols in the SCC
|
|
113
|
-
- **label**: cluster label derived from the most common name prefix
|
|
114
|
-
|
|
115
|
-
Returns ``(condensation_graph, mapping)`` where *mapping* maps each
|
|
116
|
-
condensation node ID to the list of original symbol IDs.
|
|
117
|
-
"""
|
|
118
|
-
if len(G) == 0 or not cycles:
|
|
119
|
-
empty = nx.DiGraph()
|
|
120
|
-
return empty, {}
|
|
121
|
-
|
|
122
|
-
C = nx.condensation(G)
|
|
123
|
-
|
|
124
|
-
mapping: dict[int, list[int]] = {}
|
|
125
|
-
for node in C.nodes():
|
|
126
|
-
members = sorted(C.nodes[node]["members"])
|
|
127
|
-
C.nodes[node]["members"] = members
|
|
128
|
-
C.nodes[node]["member_count"] = len(members)
|
|
129
|
-
|
|
130
|
-
# Derive a cluster label from the most common name prefix
|
|
131
|
-
prefixes: list[str] = []
|
|
132
|
-
for sid in members:
|
|
133
|
-
if sid in G.nodes:
|
|
134
|
-
name = G.nodes[sid].get("name", "")
|
|
135
|
-
# Use the part before the last underscore/dot as prefix,
|
|
136
|
-
# or the full name if no separator exists
|
|
137
|
-
for sep in (".", "_"):
|
|
138
|
-
idx = name.rfind(sep)
|
|
139
|
-
if idx > 0:
|
|
140
|
-
prefixes.append(name[:idx])
|
|
141
|
-
break
|
|
142
|
-
else:
|
|
143
|
-
prefixes.append(name)
|
|
144
|
-
if prefixes:
|
|
145
|
-
label = Counter(prefixes).most_common(1)[0][0]
|
|
146
|
-
else:
|
|
147
|
-
label = f"scc_{node}"
|
|
148
|
-
C.nodes[node]["label"] = label
|
|
149
|
-
|
|
150
|
-
mapping[node] = members
|
|
151
|
-
|
|
152
|
-
return C, mapping
|
|
153
|
-
|
|
154
|
-
|
|
155
103
|
def propagation_cost(G: nx.DiGraph) -> float:
|
|
156
104
|
"""Compute the Propagation Cost metric (MacCormack et al. 2006).
|
|
157
105
|
|
|
@@ -42,34 +42,6 @@ def detect_layers(G: nx.DiGraph) -> dict[int, int]:
|
|
|
42
42
|
return layers
|
|
43
43
|
|
|
44
44
|
|
|
45
|
-
def layer_balance(layers: dict[int, int]) -> float:
|
|
46
|
-
"""Compute Gini coefficient of layer sizes as a balance metric.
|
|
47
|
-
|
|
48
|
-
Returns a value in [0, 1] where 0 = perfectly balanced (all layers
|
|
49
|
-
have the same number of nodes) and 1 = maximally imbalanced (all
|
|
50
|
-
nodes in one layer).
|
|
51
|
-
|
|
52
|
-
The Gini coefficient is a standard inequality measure from economics
|
|
53
|
-
(Gini, 1912) applied here to architectural layer distribution.
|
|
54
|
-
"""
|
|
55
|
-
if not layers:
|
|
56
|
-
return 0.0
|
|
57
|
-
from collections import Counter
|
|
58
|
-
sizes = sorted(Counter(layers.values()).values())
|
|
59
|
-
n = len(sizes)
|
|
60
|
-
if n <= 1:
|
|
61
|
-
return 0.0
|
|
62
|
-
total = sum(sizes)
|
|
63
|
-
if total == 0:
|
|
64
|
-
return 0.0
|
|
65
|
-
cumulative = 0.0
|
|
66
|
-
weighted_sum = 0.0
|
|
67
|
-
for i, size in enumerate(sizes):
|
|
68
|
-
cumulative += size
|
|
69
|
-
weighted_sum += (2 * (i + 1) - n - 1) * size
|
|
70
|
-
return round(weighted_sum / (n * total), 4)
|
|
71
|
-
|
|
72
|
-
|
|
73
45
|
def find_violations(
|
|
74
46
|
G: nx.DiGraph, layers: dict[int, int]
|
|
75
47
|
) -> list[dict]:
|
|
@@ -26,34 +26,6 @@ def _ensure_weights(G: nx.DiGraph) -> None:
|
|
|
26
26
|
data["weight"] = _EDGE_WEIGHTS.get(data.get("kind", ""), 2)
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def find_path(
|
|
30
|
-
G: nx.DiGraph, source_id: int, target_id: int
|
|
31
|
-
) -> list[int] | None:
|
|
32
|
-
"""Find the shortest path from *source_id* to *target_id*.
|
|
33
|
-
|
|
34
|
-
Prefers call edges over import edges via edge-kind weighting.
|
|
35
|
-
Tries the directed graph first; if no directed path exists, falls back to
|
|
36
|
-
the undirected projection. Returns ``None`` when no path exists at all.
|
|
37
|
-
"""
|
|
38
|
-
if source_id not in G or target_id not in G:
|
|
39
|
-
return None
|
|
40
|
-
|
|
41
|
-
_ensure_weights(G)
|
|
42
|
-
|
|
43
|
-
# Directed attempt (weighted)
|
|
44
|
-
try:
|
|
45
|
-
return list(nx.shortest_path(G, source_id, target_id, weight="weight"))
|
|
46
|
-
except nx.NetworkXNoPath:
|
|
47
|
-
pass
|
|
48
|
-
|
|
49
|
-
# Undirected fallback (weighted)
|
|
50
|
-
try:
|
|
51
|
-
undirected = G.to_undirected()
|
|
52
|
-
return list(nx.shortest_path(undirected, source_id, target_id, weight="weight"))
|
|
53
|
-
except (nx.NetworkXNoPath, nx.NodeNotFound):
|
|
54
|
-
return None
|
|
55
|
-
|
|
56
|
-
|
|
57
29
|
def find_k_paths(
|
|
58
30
|
G: nx.DiGraph, source_id: int, target_id: int, k: int = 3
|
|
59
31
|
) -> list[list[int]]:
|
|
@@ -76,9 +76,10 @@ _PATH_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
|
76
76
|
(re.compile(r"(^|/)samples/"), ROLE_EXAMPLES),
|
|
77
77
|
(re.compile(r"(^|/)sample/"), ROLE_EXAMPLES),
|
|
78
78
|
|
|
79
|
-
# Scripts / bin
|
|
79
|
+
# Scripts / bin / dev tools
|
|
80
80
|
(re.compile(r"(^|/)scripts/"), ROLE_SCRIPTS),
|
|
81
81
|
(re.compile(r"(^|/)bin/"), ROLE_SCRIPTS),
|
|
82
|
+
(re.compile(r"(^|/)dev/"), ROLE_SCRIPTS),
|
|
82
83
|
|
|
83
84
|
# Build / dist output
|
|
84
85
|
(re.compile(r"(^|/)build/"), ROLE_BUILD),
|
|
@@ -553,70 +553,6 @@ def get_blame_for_file(
|
|
|
553
553
|
return entries
|
|
554
554
|
|
|
555
555
|
|
|
556
|
-
def get_symbol_blame(
|
|
557
|
-
conn: sqlite3.Connection, project_root: Path, symbol_id: int
|
|
558
|
-
) -> dict:
|
|
559
|
-
"""Get aggregated blame info for a symbol's line range.
|
|
560
|
-
|
|
561
|
-
Returns a dict keyed by author::
|
|
562
|
-
|
|
563
|
-
{
|
|
564
|
-
"author_name": {
|
|
565
|
-
"lines": int,
|
|
566
|
-
"commits": set_count,
|
|
567
|
-
"first_date": int (epoch),
|
|
568
|
-
"last_date": int (epoch),
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
"""
|
|
572
|
-
row = conn.execute(
|
|
573
|
-
"SELECT s.line_start, s.line_end, f.path "
|
|
574
|
-
"FROM symbols s JOIN files f ON s.file_id = f.id "
|
|
575
|
-
"WHERE s.id = ?",
|
|
576
|
-
(symbol_id,),
|
|
577
|
-
).fetchone()
|
|
578
|
-
if row is None:
|
|
579
|
-
return {}
|
|
580
|
-
|
|
581
|
-
line_start = row[0] if not isinstance(row, sqlite3.Row) else row["line_start"]
|
|
582
|
-
line_end = row[1] if not isinstance(row, sqlite3.Row) else row["line_end"]
|
|
583
|
-
file_path = row[2] if not isinstance(row, sqlite3.Row) else row["path"]
|
|
584
|
-
|
|
585
|
-
if line_start is None or line_end is None:
|
|
586
|
-
return {}
|
|
587
|
-
|
|
588
|
-
blame = get_blame_for_file(project_root, file_path)
|
|
589
|
-
if not blame:
|
|
590
|
-
return {}
|
|
591
|
-
|
|
592
|
-
# Filter to the symbol's line range (1-indexed)
|
|
593
|
-
relevant = blame[line_start - 1: line_end]
|
|
594
|
-
|
|
595
|
-
authors: dict[str, dict] = {}
|
|
596
|
-
for entry in relevant:
|
|
597
|
-
author = entry["author"]
|
|
598
|
-
if author not in authors:
|
|
599
|
-
authors[author] = {
|
|
600
|
-
"lines": 0,
|
|
601
|
-
"commits": set(),
|
|
602
|
-
"first_date": entry["timestamp"],
|
|
603
|
-
"last_date": entry["timestamp"],
|
|
604
|
-
}
|
|
605
|
-
info = authors[author]
|
|
606
|
-
info["lines"] += 1
|
|
607
|
-
info["commits"].add(entry["commit_hash"])
|
|
608
|
-
if entry["timestamp"] < info["first_date"]:
|
|
609
|
-
info["first_date"] = entry["timestamp"]
|
|
610
|
-
if entry["timestamp"] > info["last_date"]:
|
|
611
|
-
info["last_date"] = entry["timestamp"]
|
|
612
|
-
|
|
613
|
-
# Convert commit sets to counts for JSON-friendliness
|
|
614
|
-
for info in authors.values():
|
|
615
|
-
info["commits"] = len(info["commits"])
|
|
616
|
-
|
|
617
|
-
return authors
|
|
618
|
-
|
|
619
|
-
|
|
620
556
|
# ---------------------------------------------------------------------------
|
|
621
557
|
# Helpers
|
|
622
558
|
# ---------------------------------------------------------------------------
|