src2sink 3.1.0__tar.gz → 3.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 (178) hide show
  1. {src2sink-3.1.0/src2sink.egg-info → src2sink-3.2.0}/PKG-INFO +1 -1
  2. {src2sink-3.1.0 → src2sink-3.2.0}/pyproject.toml +1 -1
  3. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/graphs.py +31 -21
  4. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/build_metabase_v2.py +188 -30
  5. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/constants.py +11 -0
  6. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/dependencies.py +104 -29
  7. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/derive.py +1 -1
  8. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/ast_walk.py +280 -13
  9. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/file_context.py +4 -0
  10. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/ts_extractors.py +42 -10
  11. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/unified.py +12 -1
  12. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/resolve.py +21 -4
  13. src2sink-3.2.0/src2sink/run_timing.py +166 -0
  14. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/schema.py +31 -1
  15. {src2sink-3.1.0 → src2sink-3.2.0/src2sink.egg-info}/PKG-INFO +1 -1
  16. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink.egg-info/SOURCES.txt +4 -0
  17. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_cross_repo_caller_coverage.py +1 -1
  18. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_detection_input_coverage.py +20 -2
  19. src2sink-3.2.0/tests/test_language_support_matrix.py +481 -0
  20. src2sink-3.2.0/tests/test_oi36_parse_failures.py +241 -0
  21. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_run_manifest.py +15 -3
  22. src2sink-3.2.0/tests/test_run_timing.py +221 -0
  23. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_silent_failure_gate.py +8 -8
  24. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_type_declarations.py +206 -0
  25. {src2sink-3.1.0 → src2sink-3.2.0}/LICENSE +0 -0
  26. {src2sink-3.1.0 → src2sink-3.2.0}/README.md +0 -0
  27. {src2sink-3.1.0 → src2sink-3.2.0}/setup.cfg +0 -0
  28. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/__init__.py +0 -0
  29. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/__init__.py +0 -0
  30. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/api_client_discovery.py +0 -0
  31. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/auth_cards.py +0 -0
  32. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/crypto_cards.py +0 -0
  33. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/data_stores.py +0 -0
  34. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/fleet_pass.py +0 -0
  35. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/index_v2.py +0 -0
  36. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/library_source_map.py +0 -0
  37. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_discovery.py +0 -0
  38. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_edges.py +0 -0
  39. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_match.py +0 -0
  40. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_models.py +0 -0
  41. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_report.py +0 -0
  42. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/payload_producers.py +0 -0
  43. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/phase3.py +0 -0
  44. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_cross_repo.py +0 -0
  45. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_flow_v2.py +0 -0
  46. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
  47. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
  48. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
  49. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
  50. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/queues.py +0 -0
  51. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/ropa.py +0 -0
  52. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_collect.py +0 -0
  53. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_index.py +0 -0
  54. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_models.py +0 -0
  55. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_report.py +0 -0
  56. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/service_calls.py +0 -0
  57. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/taint_buckets.py +0 -0
  58. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/taint_catalogs.py +0 -0
  59. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/taint_writers.py +0 -0
  60. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/aggregators/traces_index.py +0 -0
  61. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/checkout_scan.py +0 -0
  62. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/curate_internal_libraries.py +0 -0
  63. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/__init__.py +0 -0
  64. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/base.py +0 -0
  65. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/config.py +0 -0
  66. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/go.py +0 -0
  67. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/http_out.py +0 -0
  68. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/java.py +0 -0
  69. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/javascript.py +0 -0
  70. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/kotlin.py +0 -0
  71. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/node_factory.py +0 -0
  72. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/patterns.py +0 -0
  73. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/python_extractor.py +0 -0
  74. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/regex_extractors.py +0 -0
  75. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/extractors/symbols.py +0 -0
  76. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/graph_common.py +0 -0
  77. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/index_store.py +0 -0
  78. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/internal_groups.py +0 -0
  79. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/known_api_clients.py +0 -0
  80. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/library_taint_java.py +0 -0
  81. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/limits.py +0 -0
  82. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/maven.py +0 -0
  83. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/models/__init__.py +0 -0
  84. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/models/auth_model.py +0 -0
  85. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/models/crypto_agility.py +0 -0
  86. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/models/pii_lifecycle.py +0 -0
  87. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/models/ropa.py +0 -0
  88. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/paths.py +0 -0
  89. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/prescreen.py +0 -0
  90. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/py.typed +0 -0
  91. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/record_fleet_baseline.py +0 -0
  92. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/renderers/__init__.py +0 -0
  93. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/renderers/markdown.py +0 -0
  94. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/renderers/mermaid.py +0 -0
  95. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/repo_utils.py +0 -0
  96. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/safe_paths.py +0 -0
  97. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/sanitize.py +0 -0
  98. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/trace.py +0 -0
  99. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/trace_batch.py +0 -0
  100. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink/vocabulary.py +0 -0
  101. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink.egg-info/dependency_links.txt +0 -0
  102. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink.egg-info/entry_points.txt +0 -0
  103. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink.egg-info/requires.txt +0 -0
  104. {src2sink-3.1.0 → src2sink-3.2.0}/src2sink.egg-info/top_level.txt +0 -0
  105. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_aggregate_output_golden.py +0 -0
  106. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_aggregators_extra.py +0 -0
  107. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_api_client_discovery.py +0 -0
  108. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_ast_walk.py +0 -0
  109. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_build_internals.py +0 -0
  110. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_build_main.py +0 -0
  111. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_call_observations.py +0 -0
  112. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_call_resolution.py +0 -0
  113. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_characterization.py +0 -0
  114. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_checkout_scan.py +0 -0
  115. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_config_hygiene.py +0 -0
  116. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_curate.py +0 -0
  117. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_demand_side_discovery.py +0 -0
  118. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_dependency_pinning.py +0 -0
  119. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_derive_pass.py +0 -0
  120. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_detection_fingerprint_gate.py +0 -0
  121. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_detection_version.py +0 -0
  122. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_discovery_scan_cost.py +0 -0
  123. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_entry_points.py +0 -0
  124. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_field_precision.py +0 -0
  125. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_fleet_index.py +0 -0
  126. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_fleet_pass.py +0 -0
  127. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_gradle_version_catalogs.py +0 -0
  128. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_graph_common.py +0 -0
  129. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_http_guard_evidence.py +0 -0
  130. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_import_graph.py +0 -0
  131. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_import_style.py +0 -0
  132. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_internal_groups.py +0 -0
  133. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_limits.py +0 -0
  134. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_maven_resolution.py +0 -0
  135. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_method_structure.py +0 -0
  136. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_module_imports.py +0 -0
  137. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi13_kotlin_parity.py +0 -0
  138. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi26_receiver_scope.py +0 -0
  139. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi28_index_fast_path.py +0 -0
  140. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi33_canonical_repo_id.py +0 -0
  141. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi37_express_anchor.py +0 -0
  142. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi38_trace_index_freshness.py +0 -0
  143. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi39_test_path_classification.py +0 -0
  144. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi40_client_repo_target.py +0 -0
  145. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_oi42_promote_validation.py +0 -0
  146. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_openapi.py +0 -0
  147. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_output_neutralisation.py +0 -0
  148. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_path_match_significance.py +0 -0
  149. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase0_smoke.py +0 -0
  150. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase1_config.py +0 -0
  151. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase1_extractors.py +0 -0
  152. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase2_graphs.py +0 -0
  153. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase3_pii.py +0 -0
  154. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase4_aggregators.py +0 -0
  155. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase4_extractor_snapshots.py +0 -0
  156. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase4_fleet_regression.py +0 -0
  157. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase4_regression.py +0 -0
  158. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_phase4_taint_caps.py +0 -0
  159. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_polyglot_dependencies.py +0 -0
  160. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_post_phase4_backlog.py +0 -0
  161. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_prescreen.py +0 -0
  162. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_producer_scan_single_pass.py +0 -0
  163. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_redos_bounds.py +0 -0
  164. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_renderers.py +0 -0
  165. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_repo_utils_helpers.py +0 -0
  166. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_sanitize.py +0 -0
  167. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_security_input_handling.py +0 -0
  168. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_source_map_fix.py +0 -0
  169. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_sql_classifier.py +0 -0
  170. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_sql_payload_out.py +0 -0
  171. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_sql_sink_evidence.py +0 -0
  172. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_sql_source_construction.py +0 -0
  173. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_tainted_paths.py +0 -0
  174. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_trace_batch.py +0 -0
  175. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_trace_fleet_scaling.py +0 -0
  176. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_trace_render.py +0 -0
  177. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_zz_security_coverage_gate.py +0 -0
  178. {src2sink-3.1.0 → src2sink-3.2.0}/tests/test_zz_untrusted_interpolation_guard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: src2sink
3
- Version: 3.1.0
3
+ Version: 3.2.0
4
4
  Summary: Cross-repository source-to-sink metabase for LLM-assisted SAST
5
5
  Author: Brett Crawley
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "src2sink"
3
- version = "3.1.0"
3
+ version = "3.2.0"
4
4
  description = "Cross-repository source-to-sink metabase for LLM-assisted SAST"
5
5
  authors = [{ name = "Brett Crawley" }]
6
6
  readme = "README.md"
@@ -11,6 +11,7 @@ from ..graph_common import (
11
11
  v2_record_paths,
12
12
  )
13
13
  from ..index_store import build_index
14
+ from .. import run_timing
14
15
  from .data_stores import StoreCollector, write_data_store_graph
15
16
  from .index_v2 import _row_from_record, write_index_v2
16
17
  from .openapi_edges import write_openapi_artifacts
@@ -77,12 +78,15 @@ def aggregate_graphs_v2(
77
78
  """Run all v2 cross-repo graph writers (service, queue, data-store, PII, phase 3)."""
78
79
  # One load serves the report and the index, and the edges it computes are
79
80
  # handed on rather than recomputed (`OI-41`).
80
- shared = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
81
- call_edges, _unmatched = write_service_call_graph(
82
- metabase_root, repo_jsons, repos_root=repos_root, records=shared,
83
- )
81
+ with run_timing.phase("shared-load"):
82
+ shared = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
83
+ with run_timing.phase("service-call-graph"):
84
+ call_edges, _unmatched = write_service_call_graph(
85
+ metabase_root, repo_jsons, repos_root=repos_root, records=shared,
86
+ )
84
87
  if repos_root:
85
- write_openapi_artifacts(metabase_root, repos_root, repo_jsons)
88
+ with run_timing.phase("openapi"):
89
+ write_openapi_artifacts(metabase_root, repos_root, repo_jsons)
86
90
  # One streamed pass drives every converted aggregator, instead of each
87
91
  # parsing the whole metabase for itself (`OI-41`). Collectors retain their
88
92
  # reductions, never the records — so this buys the time without the resident
@@ -90,22 +94,28 @@ def aggregate_graphs_v2(
90
94
  queues, stores = QueueCollector(), StoreCollector()
91
95
  index_rows = MapCollector(_row_from_record)
92
96
  pii_flow = PiiFlowCollector()
93
- run_fleet_pass(
94
- metabase_root, (queues, stores, index_rows, pii_flow), json_paths=repo_jsons,
95
- )
96
- write_queue_graph(metabase_root, repo_jsons, graph=queues.result())
97
- write_data_store_graph(metabase_root, repo_jsons, collected=stores.result())
98
- producer_indices = write_payload_producer_index(
99
- metabase_root, repo_jsons, repos_root=repos_root, records=shared,
100
- )
97
+ with run_timing.phase("streamed-pass"):
98
+ run_fleet_pass(
99
+ metabase_root, (queues, stores, index_rows, pii_flow), json_paths=repo_jsons,
100
+ )
101
+ with run_timing.phase("queue-and-store-graphs"):
102
+ write_queue_graph(metabase_root, repo_jsons, graph=queues.result())
103
+ write_data_store_graph(metabase_root, repo_jsons, collected=stores.result())
104
+ with run_timing.phase("payload-producers"):
105
+ producer_indices = write_payload_producer_index(
106
+ metabase_root, repo_jsons, repos_root=repos_root, records=shared,
107
+ )
101
108
  del shared # released before the streamed pass below
102
- write_index_v2(metabase_root, repo_jsons, rows=index_rows.result())
103
- write_pii_flow_v2(metabase_root, repo_jsons, counts=pii_flow.result())
104
- write_traces_index(metabase_root)
109
+ with run_timing.phase("index-and-pii"):
110
+ write_index_v2(metabase_root, repo_jsons, rows=index_rows.result())
111
+ write_pii_flow_v2(metabase_root, repo_jsons, counts=pii_flow.result())
112
+ write_traces_index(metabase_root)
105
113
  if phase3:
106
- aggregate_phase3_v2(metabase_root, repo_jsons, edges=call_edges)
114
+ with run_timing.phase("phase3"):
115
+ aggregate_phase3_v2(metabase_root, repo_jsons, edges=call_edges)
107
116
  if fleet_index:
108
- write_fleet_index(
109
- metabase_root, repo_jsons, repos_root=repos_root,
110
- producer_indices=producer_indices, call_edges=call_edges,
111
- )
117
+ with run_timing.phase("fleet-index"):
118
+ write_fleet_index(
119
+ metabase_root, repo_jsons, repos_root=repos_root,
120
+ producer_indices=producer_indices, call_edges=call_edges,
121
+ )
@@ -11,13 +11,14 @@ import multiprocessing as mp
11
11
  import os
12
12
  import re
13
13
  import sys
14
+ import time
14
15
  from collections import Counter
15
16
  from collections.abc import Iterator
16
17
  from importlib.metadata import PackageNotFoundError, version
17
18
  from pathlib import Path
18
19
  from typing import Any
19
20
 
20
- from . import checkout_scan, prescreen, repo_utils
21
+ from . import checkout_scan, prescreen, repo_utils, run_timing
21
22
  from .aggregators.api_client_discovery import (
22
23
  DISCOVERED_FILE,
23
24
  discover_api_clients,
@@ -32,7 +33,14 @@ from .aggregators.library_source_map import (
32
33
  from .aggregators.phase3 import aggregate_phase3_v2
33
34
  from .aggregators.pii_flow_v2 import write_pii_flow_v2
34
35
  from .aggregators.taint_catalogs import aggregate_taint_catalogs_v2
35
- from .constants import MAX_FILE_BYTES, SKIP_DIRS, SOURCE_EXTENSIONS
36
+ from .constants import (
37
+ MAX_FILE_BYTES,
38
+ NOTE_LANGUAGE_COVERAGE,
39
+ NOTE_PARSE_FAILED,
40
+ NOTE_UNPARSED_MANIFEST,
41
+ SKIP_DIRS,
42
+ SOURCE_EXTENSIONS,
43
+ )
36
44
  from .maven import resolve_pom_dependencies
37
45
  from .dependencies import (
38
46
  parse_go_mod,
@@ -41,6 +49,7 @@ from .dependencies import (
41
49
  unparsed_ecosystem_notes,
42
50
  )
43
51
  from .extractors.config import extract_from_config, is_config_path
52
+ from .extractors.ast_walk import coverage_gaps
44
53
  from .extractors.unified import extract_from_file
45
54
  from .internal_groups import (
46
55
  add_internal_groups_arguments,
@@ -293,22 +302,36 @@ def _collect_dependencies(repo_root: Path) -> tuple[list[dict[str, str]], list[s
293
302
  "in build.gradle* matched no catalog entry; dependencies may be "
294
303
  "incomplete (looked for *.versions.toml and settings.gradle*)"
295
304
  )
296
- deps.extend(_collect_non_jvm_dependencies(repo_root))
305
+ non_jvm_deps, non_jvm_notes = _collect_non_jvm_dependencies(repo_root)
306
+ deps.extend(non_jvm_deps)
307
+ notes.extend(non_jvm_notes)
297
308
  notes.extend(unparsed_ecosystem_notes(repo_root))
298
309
  return deps, notes
299
310
 
300
311
 
301
- def _collect_non_jvm_dependencies(repo_root: Path) -> list[dict[str, str]]:
302
- """Gather npm, Go and Python dependencies, preferring lockfiles (OI-19)."""
312
+ def _collect_non_jvm_dependencies(
313
+ repo_root: Path,
314
+ ) -> tuple[list[dict[str, str]], list[str]]:
315
+ """Gather npm, Go and Python dependencies, preferring lockfiles (OI-19).
316
+
317
+ Returns notes alongside: a manifest that will not parse yields no
318
+ dependencies, which is the same value as a repo that declares none
319
+ (`OI-36`).
320
+ """
303
321
  deps: list[dict[str, str]] = []
322
+ notes: list[str] = []
304
323
  for pkg in repo_root.rglob("package.json"):
305
324
  if not is_skipped_path(pkg, repo_root):
306
- deps.extend(parse_npm_dependencies(repo_root, pkg))
325
+ pkg_deps, pkg_notes = parse_npm_dependencies(repo_root, pkg)
326
+ deps.extend(pkg_deps)
327
+ notes.extend(pkg_notes)
307
328
  for gomod in repo_root.rglob("go.mod"):
308
329
  if not is_skipped_path(gomod, repo_root):
309
330
  deps.extend(parse_go_mod(gomod))
310
- deps.extend(parse_python_dependencies(repo_root))
311
- return deps
331
+ py_deps, py_notes = parse_python_dependencies(repo_root)
332
+ deps.extend(py_deps)
333
+ notes.extend(py_notes)
334
+ return deps, notes
312
335
 
313
336
 
314
337
  def _record_dependencies(summary: RepoSummaryV2, deps: list[dict[str, str]]) -> None:
@@ -325,6 +348,28 @@ def _record_dependencies(summary: RepoSummaryV2, deps: list[dict[str, str]]) ->
325
348
  summary.dependencies_external_count += 1
326
349
 
327
350
 
351
+ def _note_language_coverage(summary: RepoSummaryV2, lang_counts: Counter[str]) -> None:
352
+ """Say, once per language, what this repo's scan could not extract (`OI-43`).
353
+
354
+ Per repo per language, never per file: Scala alone would otherwise put a note
355
+ on every Scala file in the estate, and a signal that loud stops being read —
356
+ the noise question that kept this out of `OI-36` phase 1.
357
+
358
+ The gaps are computed from the grammar tables, so a note cannot claim a
359
+ limitation that has since been fixed, and cannot stay silent about one that
360
+ has not. A language with full coverage produces nothing.
361
+ """
362
+ for language in sorted(lang_counts):
363
+ gaps = coverage_gaps(language)
364
+ if not gaps:
365
+ continue
366
+ summary.notes.append(
367
+ f"{language}: {NOTE_LANGUAGE_COVERAGE} across {lang_counts[language]} "
368
+ f"file(s) — {'; '.join(gaps)}. Findings for this language are "
369
+ "incomplete rather than absent; see OI-43."
370
+ )
371
+
372
+
328
373
  def _scan_repo_files(
329
374
  repo_root: Path, repo_id: str, summary: RepoSummaryV2
330
375
  ) -> tuple[Counter[str], list[FlowNode], list[FlowEdge]]:
@@ -375,7 +420,8 @@ def _scan_repo_files(
375
420
  continue
376
421
  lang_counts[lang] += 1
377
422
  nodes, edges = extract_from_file(
378
- repo_id=repo_id, rel_path=rel, language=lang, source=text
423
+ repo_id=repo_id, rel_path=rel, language=lang, source=text,
424
+ notes=summary.notes
379
425
  )
380
426
  all_nodes.extend(nodes)
381
427
  all_edges.extend(edges)
@@ -444,6 +490,7 @@ def analyse_repo_v2(repo_root: Path, group: str, name: str, path_rel: str) -> Re
444
490
  lang_counts, all_nodes, all_edges = _scan_repo_files(repo_root, repo_id, summary)
445
491
  all_nodes = prune_unresolvable_calls(all_nodes)
446
492
  summary.language_breakdown = dict(lang_counts)
493
+ _note_language_coverage(summary, lang_counts)
447
494
  if lang_counts:
448
495
  summary.primary_language = lang_counts.most_common(1)[0][0]
449
496
  summary.file_counts = dict(lang_counts)
@@ -686,6 +733,62 @@ def _prewalk_checkout(repos_root: Path) -> None:
686
733
  )
687
734
 
688
735
 
736
+ def _fold_record(
737
+ data: dict[str, Any], unparsed: dict[str, int], gaps: Counter[str]
738
+ ) -> None:
739
+ """Fold one repo record's notes and languages into the running counts."""
740
+ before = unparsed["source_files"] + unparsed["manifests"]
741
+ for note in data.get("notes", []):
742
+ if NOTE_PARSE_FAILED in note:
743
+ unparsed["source_files"] += 1
744
+ elif NOTE_UNPARSED_MANIFEST in note:
745
+ unparsed["manifests"] += 1
746
+ if unparsed["source_files"] + unparsed["manifests"] > before:
747
+ unparsed["repos_affected"] += 1
748
+ for language in data.get("language_breakdown") or {}:
749
+ if coverage_gaps(language):
750
+ gaps[language] += 1
751
+
752
+
753
+ def _record_counts(json_paths: list[Path]) -> dict[str, Any]:
754
+ """What the fleet could not read, and where resolution is limited.
755
+
756
+ Both numbers in **one pass**. The notes that feed them (`OI-36` phase 1,
757
+ `OI-43` step 4) are only half of each fix: a note on one repo among 746 is
758
+ not something anyone reads, and the count is what makes it visible on a run
759
+ that otherwise reports success. Reading the fleet twice to produce two
760
+ numbers would be `OI-41`'s defect reintroduced for the sake of tidiness.
761
+
762
+ `unparsed` counts from the notes, whose markers are shared constants so the
763
+ wording cannot drift out from under the counter. `resolution_gaps` is
764
+ recomputed from each record's `language_breakdown` instead, because deriving
765
+ it from the live grammar tables cannot go stale the way parsing prose can.
766
+ """
767
+ unparsed = {
768
+ "source_files": 0, "manifests": 0, "repos_affected": 0, "records_unreadable": 0,
769
+ }
770
+ gaps: Counter[str] = Counter()
771
+ unreadable: list[str] = []
772
+ for jp in json_paths:
773
+ try:
774
+ data = json.loads(jp.read_text(encoding="utf-8"))
775
+ except (OSError, json.JSONDecodeError) as exc:
776
+ # A record this cannot read is a repo whose failures go uncounted, so
777
+ # the number would understate the problem while looking authoritative
778
+ # — the defect this function exists to report, in the reporter.
779
+ unreadable.append(f"{jp.name}: {type(exc).__name__}")
780
+ continue
781
+ _fold_record(data, unparsed, gaps)
782
+ unparsed["records_unreadable"] = len(unreadable)
783
+ if unreadable:
784
+ print(
785
+ f" ⚠ {len(unreadable)} repo record(s) could not be read while "
786
+ "counting parse failures; the unparsed counts below are a lower bound",
787
+ file=sys.stderr,
788
+ )
789
+ return {"unparsed": unparsed, "resolution_gaps": dict(sorted(gaps.items()))}
790
+
791
+
689
792
  def _write_run_manifest(
690
793
  metabase_root: Path,
691
794
  args: argparse.Namespace,
@@ -695,6 +798,8 @@ def _write_run_manifest(
695
798
  timed_out: int,
696
799
  started_at: str,
697
800
  finished_at: str,
801
+ total_seconds: float,
802
+ json_paths: list[Path] | None = None,
698
803
  ) -> None:
699
804
  """Record run provenance for reproducibility / GDPR Art. 30 (finding R-1).
700
805
 
@@ -710,6 +815,15 @@ def _write_run_manifest(
710
815
  "detection_version": DETECTION_VERSION,
711
816
  "started_at": started_at,
712
817
  "finished_at": finished_at,
818
+ # `OI-32`, 4.0 phase 0. Wall clock alone answered only "how long", and
819
+ # every 3.0.0 performance number had to be gathered by instrumenting a
820
+ # build by hand — including that aggregation was 78% of the run. Shares
821
+ # are of the whole run at every depth; time no phase claimed is reported
822
+ # as `unattributed` rather than dropped. See `src2sink/run_timing.py`.
823
+ "timing": {
824
+ "total_seconds": round(total_seconds, 2),
825
+ "phases": run_timing.timings(total_seconds),
826
+ },
713
827
  "invocation": {
714
828
  "repos_root": Path(args.repos_root).name,
715
829
  "metabase_root": Path(args.metabase_root).name,
@@ -733,6 +847,13 @@ def _write_run_manifest(
733
847
  "updated": len(rows),
734
848
  "skipped": skipped,
735
849
  "timed_out": timed_out,
850
+ # `OI-36` phase 1 and `OI-43` step 4. A detection path that failed
851
+ # to empty now records why, and a language whose resolution is
852
+ # limited says so — but a note buried in one repo record among 746
853
+ # is not something anyone reads. These are the fleet-level numbers:
854
+ # a run that reports success while a tenth of the estate would not
855
+ # parse should say so in the same breath.
856
+ **_record_counts(json_paths or []),
736
857
  },
737
858
  "updated_repos": sorted(
738
859
  (
@@ -923,6 +1044,35 @@ def _generate_source_map(
923
1044
  print(f"{prefix}resolved {fixed} flagged source-map entries from pom.xml")
924
1045
 
925
1046
 
1047
+ def _aggregate_all(
1048
+ metabase_root: Path,
1049
+ jsons: list[Path],
1050
+ repos_root: Path,
1051
+ args: argparse.Namespace,
1052
+ *,
1053
+ taint: bool = True,
1054
+ phase3: bool = True,
1055
+ prefix: str = "",
1056
+ ) -> None:
1057
+ """Run every aggregation step over the written records, timing each.
1058
+
1059
+ Shared by the full scan and `--aggregate-only` so both report the same
1060
+ breakdown — the aggregate-only path is how you would measure aggregation
1061
+ deliberately, and it would be perverse for that one to be the untimed one.
1062
+ """
1063
+ # Before any phase searches the tree, so they all share one traversal.
1064
+ with run_timing.phase("prewalk-checkout"):
1065
+ _prewalk_checkout(repos_root)
1066
+ if taint:
1067
+ with run_timing.phase("taint-catalogs"):
1068
+ aggregate_taint_catalogs_v2(metabase_root, jsons)
1069
+ aggregate_graphs_v2(metabase_root, jsons, repos_root=repos_root, phase3=phase3)
1070
+ with run_timing.phase("source-map"):
1071
+ _generate_source_map(metabase_root, jsons, repos_root, prefix=prefix)
1072
+ with run_timing.phase("api-client-discovery"):
1073
+ _maybe_discover_api_clients(metabase_root, jsons, repos_root, args)
1074
+
1075
+
926
1076
  def _run_aggregate_only(
927
1077
  metabase_root: Path, repos_root: Path, args: argparse.Namespace
928
1078
  ) -> int:
@@ -936,20 +1086,23 @@ def _run_aggregate_only(
936
1086
  )
937
1087
  return 2
938
1088
  print(f"Aggregate-only: {len(jsons)} v2 JSONs")
1089
+ run_timing.reset()
1090
+ started = time.perf_counter()
939
1091
  if args.phase3_only:
940
- write_pii_flow_v2(metabase_root, jsons)
941
- aggregate_phase3_v2(metabase_root, jsons)
1092
+ with run_timing.phase("pii-flow"):
1093
+ write_pii_flow_v2(metabase_root, jsons)
1094
+ with run_timing.phase("phase3"):
1095
+ aggregate_phase3_v2(metabase_root, jsons)
942
1096
  else:
943
- # Before any phase searches the tree, so they all share one traversal.
944
- _prewalk_checkout(repos_root)
945
- if not args.graphs_only:
946
- aggregate_taint_catalogs_v2(metabase_root, jsons)
947
- aggregate_graphs_v2(
948
- metabase_root, jsons, repos_root=repos_root, phase3=not args.no_phase3
1097
+ _aggregate_all(
1098
+ metabase_root, jsons, repos_root, args,
1099
+ taint=not args.graphs_only, phase3=not args.no_phase3, prefix=" ",
949
1100
  )
950
- _generate_source_map(metabase_root, jsons, repos_root, prefix=" ")
951
- _maybe_discover_api_clients(metabase_root, jsons, repos_root, args)
952
1101
  print("Done.")
1102
+ # No manifest here: this mode re-renders artefacts without scanning, so
1103
+ # writing `run-manifest.json` would replace a full run's provenance with a
1104
+ # partial one that looks like a scan which updated nothing.
1105
+ print("\n".join(run_timing.render_lines(time.perf_counter() - started)))
953
1106
  return 0
954
1107
 
955
1108
 
@@ -1084,8 +1237,11 @@ def main() -> int:
1084
1237
  return _run_aggregate_only(metabase_root, repos_root, args)
1085
1238
 
1086
1239
  started_at = dt.datetime.now(dt.timezone.utc).isoformat()
1240
+ run_timing.reset()
1241
+ run_started = time.perf_counter()
1087
1242
  force = args.force
1088
- discovered = _discover_repos(repos_root, metabase_root, args, force)
1243
+ with run_timing.phase("discovery"):
1244
+ discovered = _discover_repos(repos_root, metabase_root, args, force)
1089
1245
 
1090
1246
  # Load pre-screen indicators once in the parent; pass the list to workers.
1091
1247
  prescreen_indicators: tuple[str, ...] = ()
@@ -1100,18 +1256,17 @@ def main() -> int:
1100
1256
  f"v2: {len(discovered)} repos, workers={args.workers}, "
1101
1257
  f"repo-timeout={args.repo_timeout}s{' (force)' if force else ''}"
1102
1258
  )
1103
- rows, skipped, timed_out = _dispatch_and_collect(
1104
- discovered, args, pattern_strings, prescreen_indicators
1105
- )
1259
+ with run_timing.phase("extraction"):
1260
+ rows, skipped, timed_out = _dispatch_and_collect(
1261
+ discovered, args, pattern_strings, prescreen_indicators
1262
+ )
1106
1263
 
1107
- jsons = _load_v2_jsons(metabase_root)
1264
+ with run_timing.phase("load-records"):
1265
+ jsons = _load_v2_jsons(metabase_root)
1108
1266
  if jsons:
1109
- # Before any phase searches the tree, so they all share one traversal.
1110
- _prewalk_checkout(repos_root)
1111
- aggregate_taint_catalogs_v2(metabase_root, jsons)
1112
- aggregate_graphs_v2(metabase_root, jsons, repos_root=repos_root)
1113
- _generate_source_map(metabase_root, jsons, repos_root, prefix="")
1114
- _maybe_discover_api_clients(metabase_root, jsons, repos_root, args)
1267
+ with run_timing.phase("aggregation"):
1268
+ _aggregate_all(metabase_root, jsons, repos_root, args)
1269
+ total_seconds = time.perf_counter() - run_started
1115
1270
  _write_run_manifest(
1116
1271
  metabase_root,
1117
1272
  args,
@@ -1120,12 +1275,15 @@ def main() -> int:
1120
1275
  timed_out=timed_out,
1121
1276
  started_at=started_at,
1122
1277
  finished_at=dt.datetime.now(dt.timezone.utc).isoformat(),
1278
+ total_seconds=total_seconds,
1279
+ json_paths=jsons,
1123
1280
  )
1124
1281
  print(
1125
1282
  f"Finished {len(rows)} updated, {skipped} skipped, {timed_out} timed out, "
1126
1283
  f"{len(jsons)} v2 JSONs for aggregation."
1127
1284
  f"{_limits_hit_summary(jsons, timed_out)}"
1128
1285
  )
1286
+ print("\n".join(run_timing.render_lines(total_seconds)))
1129
1287
  return 0
1130
1288
 
1131
1289
 
@@ -25,6 +25,17 @@ CONFIG_FILE_NAMES = {
25
25
  "bootstrap.yml", "bootstrap.yaml", "bootstrap.properties",
26
26
  }
27
27
 
28
+ # Stable markers in a repo's `notes`, so the run manifest can count what could
29
+ # not be parsed without re-deriving anyone's wording (`OI-36`, 4.0 phase 1). A
30
+ # note whose text drifts silently stops being counted, and the run then reports
31
+ # fewer failures than happened — which is this issue arriving through its own
32
+ # fix. Producers and `_unparsed_counts` share these two strings.
33
+ NOTE_PARSE_FAILED = "parse failed"
34
+ NOTE_UNPARSED_MANIFEST = "could not be parsed"
35
+ # `OI-43` step 4. Marks a per-repo statement about what a language could
36
+ # contribute, as distinct from a file that broke.
37
+ NOTE_LANGUAGE_COVERAGE = "limited extraction"
38
+
28
39
  MAX_FILE_BYTES = 1_500_000
29
40
 
30
41
  # Paths whose contents are not the deployed service. Matched against whole path