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.
Files changed (160) hide show
  1. {roam_code-8.1.0 → roam_code-8.2.0}/PKG-INFO +4 -4
  2. {roam_code-8.1.0 → roam_code-8.2.0}/README.md +3 -3
  3. {roam_code-8.1.0 → roam_code-8.2.0}/pyproject.toml +1 -1
  4. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_health.py +9 -0
  5. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_patterns.py +26 -3
  6. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/graph_helpers.py +0 -8
  7. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/metrics_history.py +49 -13
  8. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/__init__.py +1 -4
  9. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/cycles.py +0 -52
  10. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/layers.py +0 -28
  11. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/pathfinding.py +0 -28
  12. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/file_roles.py +2 -1
  13. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/git_stats.py +0 -64
  14. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/python_lang.py +226 -3
  15. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/PKG-INFO +4 -4
  16. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/SOURCES.txt +2 -0
  17. roam_code-8.2.0/tests/test_python_extractor_v2.py +485 -0
  18. roam_code-8.2.0/tests/test_v82_features.py +400 -0
  19. {roam_code-8.1.0 → roam_code-8.2.0}/LICENSE +0 -0
  20. {roam_code-8.1.0 → roam_code-8.2.0}/setup.cfg +0 -0
  21. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/__init__.py +0 -0
  22. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/__main__.py +0 -0
  23. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/__init__.py +0 -0
  24. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/base.py +0 -0
  25. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/bridge_protobuf.py +0 -0
  26. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/bridge_salesforce.py +0 -0
  27. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/bridges/registry.py +0 -0
  28. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/cli.py +0 -0
  29. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/__init__.py +0 -0
  30. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/changed_files.py +0 -0
  31. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_affected_tests.py +0 -0
  32. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_alerts.py +0 -0
  33. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_breaking.py +0 -0
  34. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_bus_factor.py +0 -0
  35. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_clusters.py +0 -0
  36. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_complexity.py +0 -0
  37. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_context.py +0 -0
  38. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_conventions.py +0 -0
  39. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_coupling.py +0 -0
  40. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_coverage_gaps.py +0 -0
  41. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_dead.py +0 -0
  42. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_debt.py +0 -0
  43. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_deps.py +0 -0
  44. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_describe.py +0 -0
  45. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_diagnose.py +0 -0
  46. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_diff.py +0 -0
  47. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_digest.py +0 -0
  48. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_doc_staleness.py +0 -0
  49. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_entry_points.py +0 -0
  50. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fan.py +0 -0
  51. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_file.py +0 -0
  52. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fitness.py +0 -0
  53. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_fn_coupling.py +0 -0
  54. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_grep.py +0 -0
  55. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_impact.py +0 -0
  56. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_index.py +0 -0
  57. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_init.py +0 -0
  58. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_layers.py +0 -0
  59. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_map.py +0 -0
  60. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_module.py +0 -0
  61. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_owner.py +0 -0
  62. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_pr_risk.py +0 -0
  63. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_preflight.py +0 -0
  64. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_report.py +0 -0
  65. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_risk.py +0 -0
  66. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_safe_delete.py +0 -0
  67. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_safe_zones.py +0 -0
  68. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_search.py +0 -0
  69. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_sketch.py +0 -0
  70. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_snapshot.py +0 -0
  71. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_split.py +0 -0
  72. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_symbol.py +0 -0
  73. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_testmap.py +0 -0
  74. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_tour.py +0 -0
  75. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_trace.py +0 -0
  76. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_trend.py +0 -0
  77. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_understand.py +0 -0
  78. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_uses.py +0 -0
  79. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_visualize.py +0 -0
  80. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_weather.py +0 -0
  81. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_why.py +0 -0
  82. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_ws.py +0 -0
  83. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/cmd_xlang.py +0 -0
  84. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/context_helpers.py +0 -0
  85. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/gate_presets.py +0 -0
  86. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/commands/resolve.py +0 -0
  87. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/__init__.py +0 -0
  88. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/connection.py +0 -0
  89. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/queries.py +0 -0
  90. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/db/schema.py +0 -0
  91. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/anomaly.py +0 -0
  92. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/builder.py +0 -0
  93. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/clusters.py +0 -0
  94. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/graph/pagerank.py +0 -0
  95. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/__init__.py +0 -0
  96. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/complexity.py +0 -0
  97. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/discovery.py +0 -0
  98. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/incremental.py +0 -0
  99. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/indexer.py +0 -0
  100. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/parser.py +0 -0
  101. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/relations.py +0 -0
  102. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/symbols.py +0 -0
  103. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/index/test_conventions.py +0 -0
  104. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/__init__.py +0 -0
  105. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/apex_lang.py +0 -0
  106. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/aura_lang.py +0 -0
  107. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/base.py +0 -0
  108. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/c_lang.py +0 -0
  109. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/foxpro_lang.py +0 -0
  110. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/generic_lang.py +0 -0
  111. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/go_lang.py +0 -0
  112. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/java_lang.py +0 -0
  113. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/javascript_lang.py +0 -0
  114. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/php_lang.py +0 -0
  115. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/registry.py +0 -0
  116. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/rust_lang.py +0 -0
  117. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/sfxml_lang.py +0 -0
  118. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/typescript_lang.py +0 -0
  119. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/languages/visualforce_lang.py +0 -0
  120. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/mcp_server.py +0 -0
  121. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/__init__.py +0 -0
  122. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/formatter.py +0 -0
  123. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/output/sarif.py +0 -0
  124. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/__init__.py +0 -0
  125. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/aggregator.py +0 -0
  126. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/api_scanner.py +0 -0
  127. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/config.py +0 -0
  128. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam/workspace/db.py +0 -0
  129. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/dependency_links.txt +0 -0
  130. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/entry_points.txt +0 -0
  131. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/requires.txt +0 -0
  132. {roam_code-8.1.0 → roam_code-8.2.0}/src/roam_code.egg-info/top_level.txt +0 -0
  133. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_anomaly.py +0 -0
  134. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_basic.py +0 -0
  135. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_bridges.py +0 -0
  136. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_architecture.py +0 -0
  137. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_exploration.py +0 -0
  138. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_health.py +0 -0
  139. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_refactoring.py +0 -0
  140. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_commands_workflow.py +0 -0
  141. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_comprehensive.py +0 -0
  142. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_dead_aging.py +0 -0
  143. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_file_roles.py +0 -0
  144. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_fixes.py +0 -0
  145. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_formatters.py +0 -0
  146. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_foxpro.py +0 -0
  147. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_gate_presets.py +0 -0
  148. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_json_contracts.py +0 -0
  149. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_languages.py +0 -0
  150. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_performance.py +0 -0
  151. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_pr_risk_author.py +0 -0
  152. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_resolve.py +0 -0
  153. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_salesforce.py +0 -0
  154. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_smoke.py +0 -0
  155. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_test_conventions.py +0 -0
  156. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v6_features.py +0 -0
  157. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v71_features.py +0 -0
  158. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_v7_features.py +0 -0
  159. {roam_code-8.1.0 → roam_code-8.2.0}/tests/test_visualize.py +0 -0
  160. {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.1.0
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/ # 1664 tests, Python 3.9-3.13
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/ # 1664 tests across 28 test files
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 1664 tests must pass
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/ # 1664 tests, Python 3.9-3.13
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/ # 1664 tests across 28 test files
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 1664 tests must pass
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "roam-code"
7
- version = "8.1.0"
7
+ version = "8.2.0"
8
8
  description = "Instant codebase comprehension for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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 '%Filter' "
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: 100 minus penalties, clamped to 0-100
60
- # Penalties scale with codebase size
61
- penalty = 0
62
- if symbols > 0:
63
- penalty += min(20, cycles * 3) # cycles: up to 20
64
- penalty += min(15, god_components * 2) # god: up to 15
65
- penalty += min(15, bottlenecks * 2) # bottlenecks: up to 15
66
- penalty += min(25, dead_exports * 100 / symbols) # dead %: up to 25
67
- penalty += min(15, layer_violations * 3) # layers: up to 15
68
- penalty += min(10, max(0, (cycles + god_components + bottlenecks + layer_violations) - 5))
69
-
70
- health_score = max(0, min(100, 100 - int(penalty)))
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 find_path, find_symbol_id, format_path
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
  # ---------------------------------------------------------------------------