roam-code 8.0.1__tar.gz → 8.1.1__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 (159) hide show
  1. {roam_code-8.0.1 → roam_code-8.1.1}/PKG-INFO +4 -4
  2. {roam_code-8.0.1 → roam_code-8.1.1}/README.md +3 -3
  3. {roam_code-8.0.1 → roam_code-8.1.1}/pyproject.toml +4 -1
  4. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_complexity.py +20 -9
  5. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_dead.py +28 -0
  6. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_health.py +10 -2
  7. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/python_lang.py +208 -0
  8. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/PKG-INFO +4 -4
  9. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/SOURCES.txt +1 -0
  10. roam_code-8.1.1/tests/test_python_extractor_v2.py +485 -0
  11. {roam_code-8.0.1 → roam_code-8.1.1}/LICENSE +0 -0
  12. {roam_code-8.0.1 → roam_code-8.1.1}/setup.cfg +0 -0
  13. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/__init__.py +0 -0
  14. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/__main__.py +0 -0
  15. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/bridges/__init__.py +0 -0
  16. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/bridges/base.py +0 -0
  17. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/bridges/bridge_protobuf.py +0 -0
  18. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/bridges/bridge_salesforce.py +0 -0
  19. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/bridges/registry.py +0 -0
  20. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/cli.py +0 -0
  21. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/__init__.py +0 -0
  22. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/changed_files.py +0 -0
  23. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_affected_tests.py +0 -0
  24. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_alerts.py +0 -0
  25. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_breaking.py +0 -0
  26. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_bus_factor.py +0 -0
  27. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_clusters.py +0 -0
  28. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_context.py +0 -0
  29. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_conventions.py +0 -0
  30. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_coupling.py +0 -0
  31. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_coverage_gaps.py +0 -0
  32. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_debt.py +0 -0
  33. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_deps.py +0 -0
  34. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_describe.py +0 -0
  35. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_diagnose.py +0 -0
  36. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_diff.py +0 -0
  37. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_digest.py +0 -0
  38. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_doc_staleness.py +0 -0
  39. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_entry_points.py +0 -0
  40. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_fan.py +0 -0
  41. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_file.py +0 -0
  42. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_fitness.py +0 -0
  43. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_fn_coupling.py +0 -0
  44. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_grep.py +0 -0
  45. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_impact.py +0 -0
  46. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_index.py +0 -0
  47. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_init.py +0 -0
  48. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_layers.py +0 -0
  49. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_map.py +0 -0
  50. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_module.py +0 -0
  51. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_owner.py +0 -0
  52. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_patterns.py +0 -0
  53. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_pr_risk.py +0 -0
  54. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_preflight.py +0 -0
  55. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_report.py +0 -0
  56. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_risk.py +0 -0
  57. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_safe_delete.py +0 -0
  58. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_safe_zones.py +0 -0
  59. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_search.py +0 -0
  60. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_sketch.py +0 -0
  61. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_snapshot.py +0 -0
  62. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_split.py +0 -0
  63. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_symbol.py +0 -0
  64. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_testmap.py +0 -0
  65. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_tour.py +0 -0
  66. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_trace.py +0 -0
  67. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_trend.py +0 -0
  68. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_understand.py +0 -0
  69. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_uses.py +0 -0
  70. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_visualize.py +0 -0
  71. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_weather.py +0 -0
  72. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_why.py +0 -0
  73. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_ws.py +0 -0
  74. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/cmd_xlang.py +0 -0
  75. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/context_helpers.py +0 -0
  76. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/gate_presets.py +0 -0
  77. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/graph_helpers.py +0 -0
  78. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/metrics_history.py +0 -0
  79. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/commands/resolve.py +0 -0
  80. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/db/__init__.py +0 -0
  81. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/db/connection.py +0 -0
  82. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/db/queries.py +0 -0
  83. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/db/schema.py +0 -0
  84. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/__init__.py +0 -0
  85. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/anomaly.py +0 -0
  86. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/builder.py +0 -0
  87. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/clusters.py +0 -0
  88. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/cycles.py +0 -0
  89. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/layers.py +0 -0
  90. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/pagerank.py +0 -0
  91. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/graph/pathfinding.py +0 -0
  92. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/__init__.py +0 -0
  93. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/complexity.py +0 -0
  94. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/discovery.py +0 -0
  95. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/file_roles.py +0 -0
  96. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/git_stats.py +0 -0
  97. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/incremental.py +0 -0
  98. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/indexer.py +0 -0
  99. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/parser.py +0 -0
  100. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/relations.py +0 -0
  101. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/symbols.py +0 -0
  102. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/index/test_conventions.py +0 -0
  103. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/__init__.py +0 -0
  104. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/apex_lang.py +0 -0
  105. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/aura_lang.py +0 -0
  106. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/base.py +0 -0
  107. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/c_lang.py +0 -0
  108. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/foxpro_lang.py +0 -0
  109. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/generic_lang.py +0 -0
  110. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/go_lang.py +0 -0
  111. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/java_lang.py +0 -0
  112. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/javascript_lang.py +0 -0
  113. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/php_lang.py +0 -0
  114. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/registry.py +0 -0
  115. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/rust_lang.py +0 -0
  116. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/sfxml_lang.py +0 -0
  117. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/typescript_lang.py +0 -0
  118. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/languages/visualforce_lang.py +0 -0
  119. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/mcp_server.py +0 -0
  120. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/output/__init__.py +0 -0
  121. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/output/formatter.py +0 -0
  122. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/output/sarif.py +0 -0
  123. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/workspace/__init__.py +0 -0
  124. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/workspace/aggregator.py +0 -0
  125. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/workspace/api_scanner.py +0 -0
  126. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/workspace/config.py +0 -0
  127. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam/workspace/db.py +0 -0
  128. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/dependency_links.txt +0 -0
  129. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/entry_points.txt +0 -0
  130. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/requires.txt +0 -0
  131. {roam_code-8.0.1 → roam_code-8.1.1}/src/roam_code.egg-info/top_level.txt +0 -0
  132. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_anomaly.py +0 -0
  133. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_basic.py +0 -0
  134. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_bridges.py +0 -0
  135. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_commands_architecture.py +0 -0
  136. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_commands_exploration.py +0 -0
  137. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_commands_health.py +0 -0
  138. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_commands_refactoring.py +0 -0
  139. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_commands_workflow.py +0 -0
  140. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_comprehensive.py +0 -0
  141. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_dead_aging.py +0 -0
  142. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_file_roles.py +0 -0
  143. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_fixes.py +0 -0
  144. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_formatters.py +0 -0
  145. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_foxpro.py +0 -0
  146. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_gate_presets.py +0 -0
  147. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_json_contracts.py +0 -0
  148. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_languages.py +0 -0
  149. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_performance.py +0 -0
  150. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_pr_risk_author.py +0 -0
  151. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_resolve.py +0 -0
  152. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_salesforce.py +0 -0
  153. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_smoke.py +0 -0
  154. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_test_conventions.py +0 -0
  155. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_v6_features.py +0 -0
  156. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_v71_features.py +0 -0
  157. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_v7_features.py +0 -0
  158. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_visualize.py +0 -0
  159. {roam_code-8.0.1 → roam_code-8.1.1}/tests/test_workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roam-code
3
- Version: 8.0.1
3
+ Version: 8.1.1
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/ # 1691 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/ # 1691 tests across 29 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 1691 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/ # 1691 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/ # 1691 tests across 29 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 1691 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.0.1"
7
+ version = "8.1.1"
8
8
  description = "Instant codebase comprehension for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -55,6 +55,9 @@ where = ["src"]
55
55
  [tool.pytest.ini_options]
56
56
  testpaths = ["tests"]
57
57
  addopts = "-q --tb=short"
58
+ markers = [
59
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
60
+ ]
58
61
 
59
62
  [tool.ruff]
60
63
  target-version = "py39"
@@ -5,6 +5,8 @@ functions/methods by cognitive complexity to identify the hardest-to-
5
5
  understand code in the project.
6
6
  """
7
7
 
8
+ from __future__ import annotations
9
+
8
10
  import click
9
11
 
10
12
  from roam.db.connection import open_db
@@ -12,6 +14,15 @@ from roam.commands.resolve import ensure_index
12
14
  from roam.output.formatter import loc, abbrev_kind, to_json, json_envelope
13
15
 
14
16
 
17
+ def _safe_metric(row, key, default=0.0):
18
+ """Safely access a metric column that may not exist in older DBs."""
19
+ try:
20
+ v = row[key]
21
+ return v if v is not None else default
22
+ except (KeyError, IndexError):
23
+ return default
24
+
25
+
15
26
  def _severity(score: float) -> str:
16
27
  """Map cognitive complexity score to a severity label."""
17
28
  if score >= 25:
@@ -144,11 +155,11 @@ def complexity(ctx, target, limit, threshold, by_file, bumpy_road):
144
155
  "return_count": r["return_count"],
145
156
  "bool_op_count": r["bool_op_count"],
146
157
  "callback_depth": r["callback_depth"],
147
- "cyclomatic_density": r["cyclomatic_density"] or 0,
148
- "halstead_volume": r["halstead_volume"] or 0,
149
- "halstead_difficulty": r["halstead_difficulty"] or 0,
150
- "halstead_effort": r["halstead_effort"] or 0,
151
- "halstead_bugs": r["halstead_bugs"] or 0,
158
+ "cyclomatic_density": _safe_metric(r, "cyclomatic_density"),
159
+ "halstead_volume": _safe_metric(r, "halstead_volume"),
160
+ "halstead_difficulty": _safe_metric(r, "halstead_difficulty"),
161
+ "halstead_effort": _safe_metric(r, "halstead_effort"),
162
+ "halstead_bugs": _safe_metric(r, "halstead_bugs"),
152
163
  "severity": _severity(r["cognitive_complexity"]),
153
164
  }
154
165
  for r in rows
@@ -181,11 +192,11 @@ def complexity(ctx, target, limit, threshold, by_file, bumpy_road):
181
192
  factors.append(f"params={r['param_count']}")
182
193
  if r["return_count"] >= 4:
183
194
  factors.append(f"ret={r['return_count']}")
184
- cd = r["cyclomatic_density"]
185
- if cd and cd > 0.15:
195
+ cd = _safe_metric(r, "cyclomatic_density")
196
+ if cd > 0.15:
186
197
  factors.append(f"density={cd:.2f}")
187
- hv = r["halstead_volume"]
188
- if hv and hv > 500:
198
+ hv = _safe_metric(r, "halstead_volume")
199
+ if hv > 500:
189
200
  factors.append(f"H.vol={hv:.0f}")
190
201
 
191
202
  factor_str = f" ({', '.join(factors)})" if factors else ""
@@ -40,6 +40,20 @@ _API_PREFIXES = ("get", "use", "create", "validate", "fetch", "update",
40
40
  "delete", "find", "check", "make", "build", "parse")
41
41
 
42
42
 
43
+ _ABC_METHOD_NAMES = frozenset({
44
+ "language_name", "file_extensions", "extract_symbols", "extract_references",
45
+ "get_docstring", "get_signature", "node_text",
46
+ "detect", "supported_bridges",
47
+ "resolve_cross_language", "get_bridge_edges",
48
+ })
49
+
50
+
51
+ def _is_test_path(file_path):
52
+ """Check if a file is a test file (discovered by pytest, not imported)."""
53
+ base = os.path.basename(file_path).lower()
54
+ return base.startswith("test_") or base.endswith("_test.py")
55
+
56
+
43
57
  def _dead_action(r, file_imported):
44
58
  """Compute actionable verdict and confidence % for a dead symbol.
45
59
 
@@ -62,6 +76,18 @@ def _dead_action(r, file_imported):
62
76
  except (KeyError, IndexError):
63
77
  kind = ""
64
78
 
79
+ # Test file symbols — discovered by pytest, never imported directly
80
+ if _is_test_path(r["file_path"]):
81
+ return "INTENTIONAL", 10
82
+
83
+ # CLI command functions — loaded dynamically via LazyGroup/importlib
84
+ if base.startswith("cmd_") and kind == "function":
85
+ return "INTENTIONAL", 20
86
+
87
+ # ABC method overrides — called polymorphically, not by direct import
88
+ if kind == "method" and name in _ABC_METHOD_NAMES:
89
+ return "INTENTIONAL", 10
90
+
65
91
  # Entry point / lifecycle hooks (check original case for camelCase hooks)
66
92
  if name in _ENTRY_NAMES or name_lower in _ENTRY_NAMES:
67
93
  return "INTENTIONAL", 60
@@ -251,6 +277,8 @@ def _analyze_dead(conn):
251
277
  Returns (high, low, imported_files) where high/low are lists of Row objects.
252
278
  """
253
279
  rows = conn.execute(UNREFERENCED_EXPORTS).fetchall()
280
+ # Exclude test files — their symbols are discovered by pytest, not imported
281
+ rows = [r for r in rows if not _is_test_path(r["file_path"])]
254
282
  if not rows:
255
283
  return [], [], set()
256
284
 
@@ -52,13 +52,21 @@ _FRAMEWORK_NAMES = frozenset({
52
52
  _UTILITY_PATH_PATTERNS = (
53
53
  "composables/", "utils/", "services/", "lib/", "helpers/",
54
54
  "shared/", "config/", "core/", "hooks/", "stores/",
55
+ "output/", "db/", "common/", "internal/", "infra/",
56
+ )
57
+
58
+ _UTILITY_FILE_PATTERNS = (
59
+ "resolve.py", "helpers.py", "common.py", "base.py",
55
60
  )
56
61
 
57
62
 
58
63
  def _is_utility_path(file_path):
59
- """Check if a file is in a utility/infrastructure directory."""
64
+ """Check if a file is in a utility/infrastructure directory or is a known utility file."""
60
65
  p = file_path.replace("\\", "/").lower()
61
- return any(pat in p for pat in _UTILITY_PATH_PATTERNS)
66
+ if any(pat in p for pat in _UTILITY_PATH_PATTERNS):
67
+ return True
68
+ basename = p.rsplit("/", 1)[-1] if "/" in p else p
69
+ return basename in _UTILITY_FILE_PATTERNS
62
70
 
63
71
 
64
72
  def _percentile(sorted_values, pct):
@@ -2,6 +2,12 @@
2
2
  from __future__ import annotations
3
3
  from .base import LanguageExtractor
4
4
 
5
+ # Builtin type names that don't create real reference edges
6
+ _BUILTIN_TYPES = frozenset({
7
+ "int", "str", "float", "bool", "bytes", "None",
8
+ "list", "dict", "set", "tuple", "type", "object",
9
+ })
10
+
5
11
 
6
12
  class PythonExtractor(LanguageExtractor):
7
13
  """Full Python symbol and reference extractor."""
@@ -170,6 +176,15 @@ class PythonExtractor(LanguageExtractor):
170
176
  parent_name=parent_name,
171
177
  ))
172
178
 
179
+ # Extract instance attributes from __init__ body (self.x = ...)
180
+ if name == "__init__" and parent_name:
181
+ body = node.child_by_field_name("body")
182
+ if body:
183
+ self_name = self._detect_self_name(node)
184
+ self._extract_init_attributes(
185
+ body, source, symbols, parent_name, dunder_all, self_name,
186
+ )
187
+
173
188
  def _extract_class(self, node, source, file_path, symbols, parent_name, dunder_all, decorator_node=None):
174
189
  name_node = node.child_by_field_name("name")
175
190
  if name_node is None:
@@ -320,6 +335,88 @@ class PythonExtractor(LanguageExtractor):
320
335
  return text
321
336
  return None
322
337
 
338
+ def _detect_self_name(self, func_node) -> bytes:
339
+ """Detect the self/cls parameter name from a method's first argument (Pyan-inspired)."""
340
+ params = func_node.child_by_field_name("parameters")
341
+ if params:
342
+ for child in params.children:
343
+ if child.type == "identifier":
344
+ return child.text
345
+ # typed_parameter: (self: Self) or typed_default_parameter
346
+ if child.type in ("typed_parameter", "typed_default_parameter"):
347
+ name_node = child.child_by_field_name("name")
348
+ if name_node and name_node.type == "identifier":
349
+ return name_node.text
350
+ return b"self"
351
+
352
+ def _extract_init_attributes(self, body_node, source, symbols, parent_name,
353
+ dunder_all, self_name):
354
+ """Extract instance attributes from __init__ body (self.x = value)."""
355
+ seen = set()
356
+ # Collect existing class-level property names to avoid duplicates
357
+ for sym in symbols:
358
+ if sym.get("parent_name") == parent_name and sym.get("kind") == "property":
359
+ seen.add(sym["name"])
360
+
361
+ self._collect_self_assignments(body_node, source, symbols, parent_name,
362
+ dunder_all, self_name, seen)
363
+
364
+ def _collect_self_assignments(self, node, source, symbols, parent_name,
365
+ dunder_all, self_name, seen):
366
+ """Walk __init__ body collecting self.x = ... assignments."""
367
+ for child in node.children:
368
+ if child.type == "expression_statement":
369
+ for sub in child.children:
370
+ if sub.type == "assignment":
371
+ self._try_extract_self_attr(
372
+ sub, source, symbols, parent_name, dunder_all,
373
+ self_name, seen,
374
+ )
375
+ elif child.type == "assignment":
376
+ self._try_extract_self_attr(
377
+ child, source, symbols, parent_name, dunder_all,
378
+ self_name, seen,
379
+ )
380
+ # Recurse into conditional/try/with blocks (common in __init__)
381
+ elif child.type in ("if_statement", "try_statement", "with_statement",
382
+ "for_statement", "block"):
383
+ self._collect_self_assignments(
384
+ child, source, symbols, parent_name, dunder_all,
385
+ self_name, seen,
386
+ )
387
+
388
+ def _try_extract_self_attr(self, assign_node, source, symbols, parent_name,
389
+ dunder_all, self_name, seen):
390
+ """If assign_node is `self.x = value`, extract x as a property symbol."""
391
+ left = assign_node.child_by_field_name("left")
392
+ if left is None or left.type != "attribute":
393
+ return
394
+ obj = left.child_by_field_name("object")
395
+ if obj is None or obj.type != "identifier" or obj.text != self_name:
396
+ return
397
+ attr_node = left.child_by_field_name("attribute")
398
+ if attr_node is None:
399
+ return
400
+ attr_name = self.node_text(attr_node, source)
401
+ if attr_name in seen:
402
+ return
403
+ seen.add(attr_name)
404
+
405
+ right = assign_node.child_by_field_name("right")
406
+ default_value = self._extract_literal_value(right, source) if right else None
407
+
408
+ qualified = f"{parent_name}.{attr_name}"
409
+ symbols.append(self._make_symbol(
410
+ name=attr_name,
411
+ kind="property",
412
+ line_start=assign_node.start_point[0] + 1,
413
+ line_end=assign_node.end_point[0] + 1,
414
+ qualified_name=qualified,
415
+ visibility=self._visibility(attr_name),
416
+ parent_name=parent_name,
417
+ default_value=default_value,
418
+ ))
419
+
323
420
  def _is_exported(self, name: str, dunder_all: set[str] | None) -> bool:
324
421
  if dunder_all is not None:
325
422
  return name in dunder_all
@@ -333,6 +430,15 @@ class PythonExtractor(LanguageExtractor):
333
430
  self._extract_from_import(child, source, refs, scope_name)
334
431
  elif child.type == "call":
335
432
  self._extract_call(child, source, refs, scope_name)
433
+ elif child.type == "decorated_definition":
434
+ self._extract_decorator_refs(child, source, refs, scope_name)
435
+ self._walk_refs(child, source, file_path, refs, scope_name)
436
+ elif child.type in ("assignment", "expression_statement"):
437
+ # Walk type annotations on assignments: x: Path = ..., self.x: int = ...
438
+ # Note: type aliases (PathList = List[Path]) are NOT handled here —
439
+ # the RHS is a subscript expression, not a type annotation field.
440
+ self._extract_assignment_type_refs(child, source, refs, scope_name)
441
+ self._walk_refs(child, source, file_path, refs, scope_name)
336
442
  else:
337
443
  # Recurse, updating scope for classes/functions
338
444
  new_scope = scope_name
@@ -341,8 +447,110 @@ class PythonExtractor(LanguageExtractor):
341
447
  if n:
342
448
  fname = self.node_text(n, source)
343
449
  new_scope = f"{scope_name}.{fname}" if scope_name else fname
450
+ # Extract type annotation refs from function parameters and return
451
+ if child.type == "function_definition":
452
+ self._extract_type_refs(child, source, refs, new_scope)
344
453
  self._walk_refs(child, source, file_path, refs, new_scope)
345
454
 
455
+ def _extract_decorator_refs(self, decorated_node, source, refs, scope_name):
456
+ """Extract references from decorators (e.g., @cache, @app.route)."""
457
+ for child in decorated_node.children:
458
+ if child.type == "decorator":
459
+ # The decorator content is after the '@'
460
+ for sub in child.children:
461
+ if sub.type == "identifier":
462
+ name = self.node_text(sub, source)
463
+ refs.append(self._make_reference(
464
+ target_name=name,
465
+ kind="call",
466
+ line=sub.start_point[0] + 1,
467
+ source_name=scope_name,
468
+ ))
469
+ elif sub.type == "attribute":
470
+ name = self.node_text(sub, source)
471
+ refs.append(self._make_reference(
472
+ target_name=name,
473
+ kind="call",
474
+ line=sub.start_point[0] + 1,
475
+ source_name=scope_name,
476
+ ))
477
+ elif sub.type == "call":
478
+ self._extract_call(sub, source, refs, scope_name)
479
+
480
+ def _extract_type_refs(self, func_node, source, refs, scope_name):
481
+ """Extract references from type annotations in function signatures."""
482
+ # Parameter type annotations
483
+ params = func_node.child_by_field_name("parameters")
484
+ if params:
485
+ for param in params.children:
486
+ type_node = param.child_by_field_name("type")
487
+ if type_node:
488
+ self._walk_type_node(type_node, source, refs, scope_name)
489
+
490
+ # Return type annotation
491
+ ret = func_node.child_by_field_name("return_type")
492
+ if ret:
493
+ self._walk_type_node(ret, source, refs, scope_name)
494
+
495
+ def _extract_assignment_type_refs(self, node, source, refs, scope_name):
496
+ """Extract type_ref edges from annotated assignments (class fields, module vars)."""
497
+ targets = [node] if node.type == "assignment" else []
498
+ if node.type == "expression_statement":
499
+ for sub in node.children:
500
+ if sub.type == "assignment":
501
+ targets.append(sub)
502
+ for assign in targets:
503
+ type_node = assign.child_by_field_name("type")
504
+ if type_node:
505
+ self._walk_type_node(type_node, source, refs, scope_name)
506
+
507
+ def _walk_type_node(self, node, source, refs, scope_name):
508
+ """Walk a type annotation node and extract type references."""
509
+ if node.type == "identifier":
510
+ name = self.node_text(node, source)
511
+ if name not in _BUILTIN_TYPES:
512
+ refs.append(self._make_reference(
513
+ target_name=name,
514
+ kind="type_ref",
515
+ line=node.start_point[0] + 1,
516
+ source_name=scope_name,
517
+ ))
518
+ elif node.type == "attribute":
519
+ name = self.node_text(node, source)
520
+ refs.append(self._make_reference(
521
+ target_name=name,
522
+ kind="type_ref",
523
+ line=node.start_point[0] + 1,
524
+ source_name=scope_name,
525
+ ))
526
+ elif node.type == "string":
527
+ # Forward reference: Optional["Config"] -> extract "Config"
528
+ for child in node.children:
529
+ if child.type == "string_content":
530
+ name = self.node_text(child, source).strip()
531
+ # Simple identifier forward ref: "Config"
532
+ if name.isidentifier() and name not in _BUILTIN_TYPES:
533
+ refs.append(self._make_reference(
534
+ target_name=name,
535
+ kind="type_ref",
536
+ line=child.start_point[0] + 1,
537
+ source_name=scope_name,
538
+ ))
539
+ # Dotted forward ref: "module.ClassName"
540
+ elif "." in name and all(
541
+ p.isidentifier() for p in name.split(".")
542
+ ):
543
+ refs.append(self._make_reference(
544
+ target_name=name,
545
+ kind="type_ref",
546
+ line=child.start_point[0] + 1,
547
+ source_name=scope_name,
548
+ ))
549
+ else:
550
+ # Recurse into generic types like List[Item], Optional[str], etc.
551
+ for child in node.children:
552
+ self._walk_type_node(child, source, refs, scope_name)
553
+
346
554
  def _extract_import(self, node, source, refs, scope_name):
347
555
  # import x, import x.y, import x as y
348
556
  for child in node.children:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: roam-code
3
- Version: 8.0.1
3
+ Version: 8.1.1
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/ # 1691 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/ # 1691 tests across 29 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 1691 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.
@@ -145,6 +145,7 @@ tests/test_json_contracts.py
145
145
  tests/test_languages.py
146
146
  tests/test_performance.py
147
147
  tests/test_pr_risk_author.py
148
+ tests/test_python_extractor_v2.py
148
149
  tests/test_resolve.py
149
150
  tests/test_salesforce.py
150
151
  tests/test_smoke.py