src2sink 3.0.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 (180) hide show
  1. {src2sink-3.0.0/src2sink.egg-info → src2sink-3.2.0}/PKG-INFO +1 -1
  2. {src2sink-3.0.0 → src2sink-3.2.0}/pyproject.toml +1 -1
  3. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/api_client_discovery.py +118 -18
  4. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/auth_cards.py +6 -3
  5. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/crypto_cards.py +7 -2
  6. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/data_stores.py +55 -18
  7. src2sink-3.2.0/src2sink/aggregators/fleet_pass.py +120 -0
  8. src2sink-3.2.0/src2sink/aggregators/graphs.py +121 -0
  9. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/index_v2.py +15 -5
  10. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/payload_producers.py +5 -1
  11. src2sink-3.2.0/src2sink/aggregators/phase3.py +48 -0
  12. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_cross_repo.py +24 -6
  13. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_flow_v2.py +40 -9
  14. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/queues.py +64 -15
  15. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/ropa.py +3 -1
  16. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_report.py +12 -4
  17. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/build_metabase_v2.py +188 -30
  18. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/constants.py +11 -0
  19. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/dependencies.py +104 -29
  20. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/derive.py +1 -1
  21. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/ast_walk.py +280 -13
  22. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/file_context.py +4 -0
  23. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/ts_extractors.py +42 -10
  24. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/unified.py +12 -1
  25. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/resolve.py +21 -4
  26. src2sink-3.2.0/src2sink/run_timing.py +166 -0
  27. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/schema.py +31 -1
  28. {src2sink-3.0.0 → src2sink-3.2.0/src2sink.egg-info}/PKG-INFO +1 -1
  29. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink.egg-info/SOURCES.txt +8 -0
  30. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_cross_repo_caller_coverage.py +1 -1
  31. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_detection_input_coverage.py +20 -2
  32. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_fleet_index.py +3 -1
  33. src2sink-3.2.0/tests/test_fleet_pass.py +222 -0
  34. src2sink-3.2.0/tests/test_import_style.py +88 -0
  35. src2sink-3.2.0/tests/test_language_support_matrix.py +481 -0
  36. src2sink-3.2.0/tests/test_oi36_parse_failures.py +241 -0
  37. src2sink-3.2.0/tests/test_oi42_promote_validation.py +241 -0
  38. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase4_taint_caps.py +2 -1
  39. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_run_manifest.py +15 -3
  40. src2sink-3.2.0/tests/test_run_timing.py +221 -0
  41. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_silent_failure_gate.py +8 -9
  42. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_type_declarations.py +206 -0
  43. src2sink-3.0.0/src2sink/aggregators/graphs.py +0 -89
  44. src2sink-3.0.0/src2sink/aggregators/phase3.py +0 -30
  45. {src2sink-3.0.0 → src2sink-3.2.0}/LICENSE +0 -0
  46. {src2sink-3.0.0 → src2sink-3.2.0}/README.md +0 -0
  47. {src2sink-3.0.0 → src2sink-3.2.0}/setup.cfg +0 -0
  48. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/__init__.py +0 -0
  49. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/__init__.py +0 -0
  50. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/library_source_map.py +0 -0
  51. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_discovery.py +0 -0
  52. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_edges.py +0 -0
  53. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_match.py +0 -0
  54. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_models.py +0 -0
  55. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/openapi_report.py +0 -0
  56. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_lifecycle.py +0 -0
  57. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_lifecycle_report.py +0 -0
  58. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_touchpoint_collect.py +0 -0
  59. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/pii_touchpoint_utils.py +0 -0
  60. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_collect.py +0 -0
  61. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_index.py +0 -0
  62. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/service_call_models.py +0 -0
  63. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/service_calls.py +0 -0
  64. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/taint_buckets.py +0 -0
  65. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/taint_catalogs.py +0 -0
  66. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/taint_writers.py +0 -0
  67. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/aggregators/traces_index.py +0 -0
  68. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/checkout_scan.py +0 -0
  69. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/curate_internal_libraries.py +0 -0
  70. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/__init__.py +0 -0
  71. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/base.py +0 -0
  72. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/config.py +0 -0
  73. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/go.py +0 -0
  74. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/http_out.py +0 -0
  75. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/java.py +0 -0
  76. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/javascript.py +0 -0
  77. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/kotlin.py +0 -0
  78. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/node_factory.py +0 -0
  79. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/patterns.py +0 -0
  80. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/python_extractor.py +0 -0
  81. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/regex_extractors.py +0 -0
  82. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/extractors/symbols.py +0 -0
  83. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/graph_common.py +0 -0
  84. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/index_store.py +0 -0
  85. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/internal_groups.py +0 -0
  86. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/known_api_clients.py +0 -0
  87. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/library_taint_java.py +0 -0
  88. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/limits.py +0 -0
  89. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/maven.py +0 -0
  90. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/models/__init__.py +0 -0
  91. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/models/auth_model.py +0 -0
  92. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/models/crypto_agility.py +0 -0
  93. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/models/pii_lifecycle.py +0 -0
  94. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/models/ropa.py +0 -0
  95. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/paths.py +0 -0
  96. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/prescreen.py +0 -0
  97. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/py.typed +0 -0
  98. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/record_fleet_baseline.py +0 -0
  99. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/renderers/__init__.py +0 -0
  100. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/renderers/markdown.py +0 -0
  101. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/renderers/mermaid.py +0 -0
  102. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/repo_utils.py +0 -0
  103. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/safe_paths.py +0 -0
  104. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/sanitize.py +0 -0
  105. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/trace.py +0 -0
  106. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/trace_batch.py +0 -0
  107. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink/vocabulary.py +0 -0
  108. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink.egg-info/dependency_links.txt +0 -0
  109. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink.egg-info/entry_points.txt +0 -0
  110. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink.egg-info/requires.txt +0 -0
  111. {src2sink-3.0.0 → src2sink-3.2.0}/src2sink.egg-info/top_level.txt +0 -0
  112. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_aggregate_output_golden.py +0 -0
  113. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_aggregators_extra.py +0 -0
  114. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_api_client_discovery.py +0 -0
  115. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_ast_walk.py +0 -0
  116. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_build_internals.py +0 -0
  117. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_build_main.py +0 -0
  118. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_call_observations.py +0 -0
  119. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_call_resolution.py +0 -0
  120. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_characterization.py +0 -0
  121. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_checkout_scan.py +0 -0
  122. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_config_hygiene.py +0 -0
  123. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_curate.py +0 -0
  124. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_demand_side_discovery.py +0 -0
  125. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_dependency_pinning.py +0 -0
  126. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_derive_pass.py +0 -0
  127. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_detection_fingerprint_gate.py +0 -0
  128. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_detection_version.py +0 -0
  129. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_discovery_scan_cost.py +0 -0
  130. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_entry_points.py +0 -0
  131. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_field_precision.py +0 -0
  132. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_gradle_version_catalogs.py +0 -0
  133. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_graph_common.py +0 -0
  134. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_http_guard_evidence.py +0 -0
  135. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_import_graph.py +0 -0
  136. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_internal_groups.py +0 -0
  137. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_limits.py +0 -0
  138. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_maven_resolution.py +0 -0
  139. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_method_structure.py +0 -0
  140. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_module_imports.py +0 -0
  141. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi13_kotlin_parity.py +0 -0
  142. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi26_receiver_scope.py +0 -0
  143. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi28_index_fast_path.py +0 -0
  144. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi33_canonical_repo_id.py +0 -0
  145. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi37_express_anchor.py +0 -0
  146. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi38_trace_index_freshness.py +0 -0
  147. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi39_test_path_classification.py +0 -0
  148. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_oi40_client_repo_target.py +0 -0
  149. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_openapi.py +0 -0
  150. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_output_neutralisation.py +0 -0
  151. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_path_match_significance.py +0 -0
  152. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase0_smoke.py +0 -0
  153. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase1_config.py +0 -0
  154. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase1_extractors.py +0 -0
  155. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase2_graphs.py +0 -0
  156. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase3_pii.py +0 -0
  157. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase4_aggregators.py +0 -0
  158. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase4_extractor_snapshots.py +0 -0
  159. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase4_fleet_regression.py +0 -0
  160. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_phase4_regression.py +0 -0
  161. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_polyglot_dependencies.py +0 -0
  162. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_post_phase4_backlog.py +0 -0
  163. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_prescreen.py +0 -0
  164. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_producer_scan_single_pass.py +0 -0
  165. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_redos_bounds.py +0 -0
  166. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_renderers.py +0 -0
  167. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_repo_utils_helpers.py +0 -0
  168. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_sanitize.py +0 -0
  169. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_security_input_handling.py +0 -0
  170. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_source_map_fix.py +0 -0
  171. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_sql_classifier.py +0 -0
  172. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_sql_payload_out.py +0 -0
  173. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_sql_sink_evidence.py +0 -0
  174. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_sql_source_construction.py +0 -0
  175. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_tainted_paths.py +0 -0
  176. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_trace_batch.py +0 -0
  177. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_trace_fleet_scaling.py +0 -0
  178. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_trace_render.py +0 -0
  179. {src2sink-3.0.0 → src2sink-3.2.0}/tests/test_zz_security_coverage_gate.py +0 -0
  180. {src2sink-3.0.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.0.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.0.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"
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
 
28
28
  import json
29
29
  import re
30
+ import sys
30
31
  from collections import defaultdict
31
32
  from pathlib import Path
32
33
  from typing import Any
@@ -609,16 +610,108 @@ def _write_discovered(metabase_root: Path, entries: list[dict[str, Any]]) -> Non
609
610
  )
610
611
 
611
612
 
612
- def _load_bindings(path: Path) -> list[dict[str, Any]]:
613
- """Return the authoritative bindings list, or [] if the file is missing/invalid."""
613
+ def _load_bindings_file(path: Path) -> dict[str, Any]:
614
+ """Return the whole authoritative file, or an empty document if unreadable.
615
+
616
+ The *whole* document, not just its bindings. `promote` used to rewrite the
617
+ file as `{"bindings": [...]}` and drop every other top-level key — including
618
+ the `_comment` carrying the "never commit — internal topology" handling
619
+ notice on a gitignored, sensitivity-marked file. The marker disappeared
620
+ silently and the file still looked correct (`OI-42`).
621
+ """
614
622
  try:
615
623
  data = json.loads(path.read_text(encoding="utf-8"))
616
- except (OSError, json.JSONDecodeError):
617
- return []
618
- bindings = data.get("bindings")
624
+ except OSError:
625
+ # Absent is ordinary — the first promote creates it.
626
+ if path.exists():
627
+ print(f"WARNING: could not read {path}; promoting into an empty file",
628
+ file=sys.stderr)
629
+ return {}
630
+ except json.JSONDecodeError as exc:
631
+ # Malformed is not ordinary. Silently treating it as empty would discard
632
+ # every existing binding on the next write, which is exactly why
633
+ # `--allow-empty-api-clients` exists for the *load* path (`OI-36`).
634
+ print(f"WARNING: {path} is not valid JSON ({exc}); its existing bindings "
635
+ "will not be preserved by this promote", file=sys.stderr)
636
+ return {}
637
+ return data if isinstance(data, dict) else {}
638
+
639
+
640
+ def _load_bindings(path: Path) -> list[dict[str, Any]]:
641
+ """Return the authoritative bindings list, or [] if the file is missing/invalid."""
642
+ bindings = _load_bindings_file(path).get("bindings")
619
643
  return [b for b in bindings if isinstance(b, dict)] if isinstance(bindings, list) else []
620
644
 
621
645
 
646
+ def _promotion_rejections(
647
+ candidate: dict[str, Any],
648
+ known_repos: frozenset[str],
649
+ endpoints: dict[str, set[str]],
650
+ ) -> list[str]:
651
+ """Why this candidate must not be promoted, or an empty list.
652
+
653
+ `promote` merged on trust and validated nothing, so every check was the
654
+ reviewer's to perform by hand. Two of the five documented gates are pure
655
+ computation the tool already does at discovery time — 50 of 191 candidates
656
+ in the first fleet review failed one of them (`OI-42`).
657
+
658
+ The reviewer keeps the judgement calls. This takes the arithmetic.
659
+ """
660
+ target = str(candidate.get("target_repo") or "")
661
+ artifact = candidate.get("maven_artifact") or "(none)"
662
+
663
+ if not target:
664
+ return [f"{artifact}: target_repo is empty — it resolved to no repo at all"]
665
+ if target not in known_repos:
666
+ return [
667
+ f"{artifact}: target_repo {target!r} is not a repo in this metabase; "
668
+ "promoting it would create an edge to a node that does not exist"
669
+ ]
670
+ service = _service_for_client_repo(target, endpoints)
671
+ if service:
672
+ return [
673
+ f"{artifact}: target_repo {target!r} declares no inbound endpoints and "
674
+ f"looks like a client library fronting {service!r} — a binding's "
675
+ "target is the service that receives the calls, not the library"
676
+ ]
677
+ return []
678
+
679
+
680
+ def _screen_for_promotion(
681
+ metabase_root: Path, accepted: list[dict[str, Any]]
682
+ ) -> list[dict[str, Any]]:
683
+ """Return the candidates that pass the mechanical gates, naming the rest."""
684
+ records = load_v2_repo_records(metabase_root)
685
+ known_repos = frozenset(repo_id(d) for d in records)
686
+ endpoints = _http_in_paths_by_repo(records)
687
+
688
+ mergeable: list[dict[str, Any]] = []
689
+ for cand in accepted:
690
+ reasons = _promotion_rejections(cand, known_repos, endpoints)
691
+ for reason in reasons:
692
+ print(f"REFUSED: {reason}", file=sys.stderr)
693
+ if not reasons:
694
+ mergeable.append(cand)
695
+ return mergeable
696
+
697
+
698
+ def _merge_bindings(
699
+ bindings: list[dict[str, Any]],
700
+ index: dict[tuple[Any, Any], list[dict[str, Any]]],
701
+ mergeable: list[dict[str, Any]],
702
+ ) -> None:
703
+ """Merge candidates into ``bindings`` in place, refreshing every duplicate."""
704
+ for c in mergeable:
705
+ binding = {k: c[k] for k in _TUNABLE_FIELDS if k in c}
706
+ key = (binding.get("target_repo"), binding.get("maven_artifact"))
707
+ if key in index:
708
+ for existing in index[key]:
709
+ existing.update(binding)
710
+ else:
711
+ bindings.append(binding)
712
+ index[key].append(binding)
713
+
714
+
622
715
  def promote_api_clients(metabase_root: Path, target_path: Path) -> int:
623
716
  """Merge ``accepted`` candidates into the authoritative ``api-clients.json``.
624
717
 
@@ -626,25 +719,32 @@ def promote_api_clients(metabase_root: Path, target_path: Path) -> int:
626
719
  (by ``(target_repo, maven_artifact)``): a new coordinate is appended, an
627
720
  existing one is updated in place. Idempotent — re-running merges the same set
628
721
  without creating duplicates. Returns the number of accepted candidates merged.
722
+
723
+ Candidates failing a mechanically-checkable gate are **refused and named**
724
+ rather than merged, and the file's other top-level keys survive the rewrite
725
+ (`OI-42`).
629
726
  """
630
727
  candidates = _load_discovered(metabase_root / DISCOVERED_FILE).values()
631
728
  accepted = [c for c in candidates if c.get("status") == STATUS_ACCEPTED]
632
729
  if not accepted:
633
730
  return 0
634
731
 
635
- bindings = _load_bindings(target_path)
636
- index = {(b.get("target_repo"), b.get("maven_artifact")): b for b in bindings}
637
- for c in accepted:
638
- binding = {k: c[k] for k in _TUNABLE_FIELDS if k in c}
639
- key = (binding.get("target_repo"), binding.get("maven_artifact"))
640
- if key in index:
641
- index[key].update(binding)
642
- else:
643
- bindings.append(binding)
644
- index[key] = binding
732
+ mergeable = _screen_for_promotion(metabase_root, accepted)
645
733
 
734
+ document = _load_bindings_file(target_path)
735
+ bindings = _load_bindings(target_path)
736
+ # Every copy of a key, not just the last. Indexing a list that may hold
737
+ # duplicates into a dict keeps only the final one, so earlier copies were
738
+ # left at their old values — still present, still loaded, now inconsistent.
739
+ index: dict[tuple[Any, Any], list[dict[str, Any]]] = defaultdict(list)
740
+ for b in bindings:
741
+ index[(b.get("target_repo"), b.get("maven_artifact"))].append(b)
742
+ _merge_bindings(bindings, index, mergeable)
743
+
744
+ # The whole document, so `_comment` — which carries the "never commit"
745
+ # handling notice on a gitignored, sensitivity-marked file — survives.
746
+ document["bindings"] = bindings
646
747
  target_path.write_text(
647
- json.dumps({"bindings": bindings}, indent=2, sort_keys=False) + "\n",
648
- encoding="utf-8",
748
+ json.dumps(document, indent=2, sort_keys=False) + "\n", encoding="utf-8",
649
749
  )
650
- return len(accepted)
750
+ return len(mergeable)
@@ -7,7 +7,8 @@ from collections import Counter
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
- from ..graph_common import iter_nodes, load_v2_repo_records, repo_id
10
+ from .fleet_pass import records_or_load
11
+ from ..graph_common import iter_nodes, repo_id
11
12
  from ..renderers.markdown import md_table
12
13
  from ..sanitize import UNTRUSTED_CONTENT_NOTICE
13
14
 
@@ -60,10 +61,12 @@ def _collect_repo_auth(data: dict[str, Any]) -> dict[str, Any]:
60
61
  def write_auth_models_catalog(
61
62
  metabase_root: Path,
62
63
  repo_jsons: list[Path] | None = None,
64
+ *,
65
+ cards: list[dict[str, Any]] | None = None,
63
66
  ) -> list[dict[str, Any]]:
64
67
  """Write auth-model cards (JSONL + markdown) to conventions/ and return them."""
65
- records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
66
- cards = [_collect_repo_auth(data) for data in records]
68
+ if cards is None:
69
+ cards = [_collect_repo_auth(d) for d in records_or_load(None, metabase_root, repo_jsons)]
67
70
 
68
71
  conv_dir = metabase_root / "conventions"
69
72
  conv_dir.mkdir(parents=True, exist_ok=True)
@@ -68,10 +68,15 @@ def _collect_repo_crypto(data: dict[str, Any]) -> dict[str, Any]:
68
68
  def write_crypto_agility_catalog(
69
69
  metabase_root: Path,
70
70
  repo_jsons: list[Path] | None = None,
71
+ *,
72
+ cards: list[dict[str, Any]] | None = None,
71
73
  ) -> list[dict[str, Any]]:
72
74
  """Write crypto-agility cards (JSONL + markdown) to conventions/ and return them."""
73
- records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
74
- cards = [_collect_repo_crypto(data) for data in records]
75
+ if cards is None:
76
+ cards = [
77
+ _collect_repo_crypto(data)
78
+ for data in load_v2_repo_records(metabase_root, json_paths=repo_jsons)
79
+ ]
75
80
 
76
81
  conv_dir = metabase_root / "conventions"
77
82
  conv_dir.mkdir(parents=True, exist_ok=True)
@@ -12,31 +12,56 @@ from ..sanitize import UNTRUSTED_CONTENT_NOTICE, for_markdown
12
12
  from ..renderers.markdown import md_table
13
13
 
14
14
 
15
- def _collect_stores(
16
- records: list[dict[str, Any]],
17
- ) -> tuple[dict[str, set[str]], dict[str, dict[str, Any]], "Counter[str]"]:
18
- """Collect store->repos, store metadata, and per-repo SQL execution sink counts."""
19
- store_repos: dict[str, set[str]] = defaultdict(set)
20
- store_meta: dict[str, dict[str, Any]] = {}
21
- sql_execution_by_repo: Counter[str] = Counter()
22
- for data in records:
23
- rid = repo_id(data)
24
- for node in iter_nodes(data):
15
+ class StoreCollector:
16
+ """Store->repos, store metadata, and per-repo SQL execution sink counts.
17
+
18
+ The reduction `_collect_stores` performed, turned inside out so it can run
19
+ inside a shared streamed pass (`OI-41`). It retains only what it derives —
20
+ a few thousand store keys — never the records it derived them from.
21
+ """
22
+
23
+ def __init__(self) -> None:
24
+ """Start an empty reduction."""
25
+ self.store_repos: dict[str, set[str]] = defaultdict(set)
26
+ self.store_meta: dict[str, dict[str, Any]] = {}
27
+ self.sql_execution_by_repo: Counter[str] = Counter()
28
+
29
+ def consume(self, record: dict[str, Any]) -> None:
30
+ """Fold one repo record in."""
31
+ rid = repo_id(record)
32
+ for node in iter_nodes(record):
25
33
  family = node.get("family", "")
26
34
  kind = node.get("kind")
27
35
  if family == "data-store" and kind == "store":
28
36
  key = store_key_from_node(node)
29
37
  if not key:
30
38
  continue
31
- store_repos[key].add(rid)
39
+ self.store_repos[key].add(rid)
32
40
  detail = node.get("detail") or {}
33
- store_meta.setdefault(key, {
41
+ self.store_meta.setdefault(key, {
34
42
  "vendor": detail.get("vendor", "?"),
35
43
  "sample_file": f"{node.get('file')}:{node.get('line')}",
36
44
  })
37
45
  elif family == "sql" and kind == "sink" and (node.get("detail") or {}).get("execution", True):
38
- sql_execution_by_repo[rid] += 1
39
- return store_repos, store_meta, sql_execution_by_repo
46
+ self.sql_execution_by_repo[rid] += 1
47
+
48
+ def result(self) -> tuple[dict[str, set[str]], dict[str, dict[str, Any]], "Counter[str]"]:
49
+ """The finished reduction, in the shape the renderer expects."""
50
+ return self.store_repos, self.store_meta, self.sql_execution_by_repo
51
+
52
+
53
+ def _collect_stores(
54
+ records: list[dict[str, Any]],
55
+ ) -> tuple[dict[str, set[str]], dict[str, dict[str, Any]], "Counter[str]"]:
56
+ """Collect store->repos, store metadata, and per-repo SQL execution sink counts.
57
+
58
+ Retained as the non-streaming entry point; the reduction itself lives in
59
+ :class:`StoreCollector` so one pass can drive it alongside the others.
60
+ """
61
+ collector = StoreCollector()
62
+ for data in records:
63
+ collector.consume(data)
64
+ return collector.result()
40
65
 
41
66
 
42
67
  def _stores_by_vendor(
@@ -65,10 +90,22 @@ def _write_data_store_jsonl(
65
90
  }, ensure_ascii=False) + "\n")
66
91
 
67
92
 
68
- def write_data_store_graph(metabase_root: Path, repo_jsons: list[Path]) -> None:
69
- """Write the bipartite data-store graph markdown + jsonl from v2 store nodes."""
70
- records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
71
- store_repos, store_meta, sql_execution_by_repo = _collect_stores(records)
93
+ def write_data_store_graph(
94
+ metabase_root: Path,
95
+ repo_jsons: list[Path],
96
+ *,
97
+ collected: tuple[dict[str, set[str]], dict[str, dict[str, Any]], "Counter[str]"] | None = None,
98
+ ) -> None:
99
+ """Write the bipartite data-store graph markdown + jsonl from v2 store nodes.
100
+
101
+ ``collected`` lets a caller that has already streamed the fleet hand the
102
+ reduction in, rather than this parsing the metabase again (`OI-41`).
103
+ """
104
+ if collected is None:
105
+ collected = _collect_stores(
106
+ load_v2_repo_records(metabase_root, json_paths=repo_jsons)
107
+ )
108
+ store_repos, store_meta, sql_execution_by_repo = collected
72
109
 
73
110
  out_dir = metabase_root / "graphs"
74
111
  out_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,120 @@
1
+ """One streamed pass over the metabase, shared by every aggregator (`OI-41`).
2
+
3
+ Aggregation parsed the whole metabase **fourteen times per run** — 2.2 GB each on
4
+ the observed fleet, so ~31 GB of JSON decoded to read the same bytes over and
5
+ over. Measured A/B, that repetition is **67% of aggregation time**, and
6
+ aggregation is 78% of the run.
7
+
8
+ The one-line fix is the wrong line. Memoising the load removes the repetition and
9
+ keeps one *held* copy of the fleet where re-parsing created and discarded:
10
+
11
+ ```
12
+ peak RSS, 14 parses : 148 MB
13
+ peak RSS, 1 parse : 266 MB (+118 MB on a 29 MB metabase)
14
+ ```
15
+
16
+ At the measured ~6.5x expansion that is several more GB resident on a fleet whose
17
+ aggregation already peaks at 5.75 GiB, on a host observed swapping — `OI-15`'s
18
+ ceiling, reached through the fix.
19
+
20
+ So: **stream once, and reduce as you go.** Each collector sees every record in
21
+ turn and retains only its own reduced state, which is orders smaller than the
22
+ records it came from. Time is one parse; memory is one record plus the results.
23
+
24
+ Every aggregator's `_collect_*` was already a pure `for data in records:`
25
+ reduction, so a collector is that loop turned inside out — accumulators in
26
+ `__init__`, body in `consume`, return in `result`. The conversion is mechanical,
27
+ and `tests/test_aggregate_output_golden.py` pins all 27 generated artefacts, so
28
+ one that changes any output fails immediately.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Callable, Iterable
34
+ from pathlib import Path
35
+ from typing import Any, Generic, Protocol, TypeVar
36
+
37
+ from ..graph_common import iter_v2_repo_records, load_v2_repo_records
38
+
39
+ # Covariant: a collector only ever *produces* its result type, so a
40
+ # `RecordCollector[QueueGraph]` is usable wherever a `RecordCollector[object]`
41
+ # is expected.
42
+ T_co = TypeVar("T_co", covariant=True)
43
+
44
+
45
+ class RecordCollector(Protocol[T_co]):
46
+ """A reduction over the fleet that sees one record at a time.
47
+
48
+ The contract is deliberately narrow: `consume` may not retain the record it
49
+ is given, because retaining records is the memory cost this exists to avoid.
50
+ It retains whatever it derives from them instead.
51
+ """
52
+
53
+ def consume(self, record: dict[str, Any]) -> None:
54
+ """Fold one repo record into the accumulating result.
55
+
56
+ Must not retain ``record``: holding records is the memory cost this
57
+ whole mechanism exists to avoid.
58
+ """
59
+
60
+ def result(self) -> T_co:
61
+ """The finished reduction. Called once, after the pass."""
62
+
63
+
64
+ def run_fleet_pass(
65
+ metabase_root: Path,
66
+ collectors: Iterable[RecordCollector[Any]],
67
+ *,
68
+ json_paths: list[Path] | None = None,
69
+ ) -> None:
70
+ """Stream every record once, offering it to every collector.
71
+
72
+ The ordering guarantee matters: records arrive in the same order
73
+ `load_v2_repo_records` produced them, so a collector whose output depends on
74
+ record order — several sort only at the end — is unaffected by the move.
75
+ """
76
+ live = list(collectors)
77
+ if not live:
78
+ return
79
+ for record in iter_v2_repo_records(metabase_root, json_paths=json_paths):
80
+ for collector in live:
81
+ collector.consume(record)
82
+
83
+
84
+ class MapCollector(Generic[T_co]):
85
+ """Applies a per-record function and keeps the results, in record order.
86
+
87
+ Several aggregators reduce with a plain `[fn(data) for data in records]` —
88
+ one card per repo. They share this rather than each restating the loop, and
89
+ it keeps the derived cards while the records that produced them are released.
90
+ """
91
+
92
+ def __init__(self, fn: Callable[[dict[str, Any]], T_co]) -> None:
93
+ """Collect ``fn`` applied to every record."""
94
+ self._fn = fn
95
+ self._results: list[T_co] = []
96
+
97
+ def consume(self, record: dict[str, Any]) -> None:
98
+ """Map one record and keep only what came back."""
99
+ self._results.append(self._fn(record))
100
+
101
+ def result(self) -> list[T_co]:
102
+ """The mapped results, in the order the records arrived."""
103
+ return self._results
104
+
105
+
106
+ def records_or_load(
107
+ records: list[dict[str, Any]] | None,
108
+ metabase_root: Path,
109
+ json_paths: list[Path] | None = None,
110
+ ) -> list[dict[str, Any]]:
111
+ """Return the caller's records, loading the fleet only if it supplied none.
112
+
113
+ Every converted aggregator keeps a non-streaming entry point so it still
114
+ works when called directly. Writing that fallback as a guard clause inside
115
+ each `write_*` added a branch to functions already at the complexity limit,
116
+ for no reader benefit — the interesting code is below it.
117
+ """
118
+ if records is not None:
119
+ return records
120
+ return load_v2_repo_records(metabase_root, json_paths=json_paths)
@@ -0,0 +1,121 @@
1
+ """Orchestrate v2 cross-repo graph generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..graph_common import (
9
+ iter_v2_repo_records,
10
+ load_v2_repo_records,
11
+ v2_record_paths,
12
+ )
13
+ from ..index_store import build_index
14
+ from .. import run_timing
15
+ from .data_stores import StoreCollector, write_data_store_graph
16
+ from .index_v2 import _row_from_record, write_index_v2
17
+ from .openapi_edges import write_openapi_artifacts
18
+ from .payload_producers import build_producer_indices, write_payload_producer_index
19
+ from .phase3 import aggregate_phase3_v2
20
+ from .pii_flow_v2 import PiiFlowCollector, write_pii_flow_v2
21
+ from .fleet_pass import MapCollector, run_fleet_pass
22
+ from .queues import QueueCollector, write_queue_graph
23
+ from .service_calls import collect_service_edges, write_service_call_graph
24
+ from .traces_index import write_traces_index
25
+
26
+
27
+ def write_fleet_index(
28
+ metabase_root: Path,
29
+ repo_jsons: list[Path],
30
+ *,
31
+ repos_root: Path | None = None,
32
+ producer_indices: list[Any] | None = None,
33
+ call_edges: list[Any] | None = None,
34
+ ) -> Path:
35
+ """Persist the four things a trace consults, keyed by target repo (`OI-15`).
36
+
37
+ Built here because aggregation already walks every repo, so the index is one
38
+ more pass over data that is being read anyway rather than a second crawl.
39
+
40
+ The two fleet-wide derivations are computed the same way `trace` computed
41
+ them, from the same functions — the index is a cache of this code's output,
42
+ not a reimplementation of it, which is what keeps the two from drifting.
43
+
44
+ ``producer_indices`` is taken from the caller when aggregation has already
45
+ built it. Rebuilding it here meant the fleet's source tree was scanned a
46
+ second time, which doubled the slowest step of the whole run (`OI-30`).
47
+ """
48
+ paths = v2_record_paths(metabase_root, json_paths=repo_jsons)
49
+ records: list[dict[str, Any]] | None = None
50
+ if call_edges is None:
51
+ # Fleet-wide and target-independent, and three consumers each recomputed
52
+ # it — the derivation `OI-14` identified as dominating cost (`OI-41`).
53
+ records = load_v2_repo_records(metabase_root, json_paths=paths)
54
+ call_edges, _unmatched = collect_service_edges(records)
55
+ if producer_indices is None:
56
+ producer_indices = build_producer_indices(
57
+ metabase_root, repos_root=repos_root, json_paths=paths,
58
+ )
59
+ del records # the streamed pass below must not be served from this list
60
+
61
+ return build_index(
62
+ metabase_root,
63
+ paths,
64
+ call_edges=call_edges,
65
+ producer_indices=producer_indices,
66
+ records=iter_v2_repo_records(metabase_root, json_paths=paths),
67
+ )
68
+
69
+
70
+ def aggregate_graphs_v2(
71
+ metabase_root: Path,
72
+ repo_jsons: list[Path],
73
+ *,
74
+ repos_root: Path | None = None,
75
+ phase3: bool = True,
76
+ fleet_index: bool = True,
77
+ ) -> None:
78
+ """Run all v2 cross-repo graph writers (service, queue, data-store, PII, phase 3)."""
79
+ # One load serves the report and the index, and the edges it computes are
80
+ # handed on rather than recomputed (`OI-41`).
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
+ )
87
+ if repos_root:
88
+ with run_timing.phase("openapi"):
89
+ write_openapi_artifacts(metabase_root, repos_root, repo_jsons)
90
+ # One streamed pass drives every converted aggregator, instead of each
91
+ # parsing the whole metabase for itself (`OI-41`). Collectors retain their
92
+ # reductions, never the records — so this buys the time without the resident
93
+ # copy that memoising would cost (`OI-15`).
94
+ queues, stores = QueueCollector(), StoreCollector()
95
+ index_rows = MapCollector(_row_from_record)
96
+ pii_flow = PiiFlowCollector()
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
+ )
108
+ del shared # released before the streamed pass below
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)
113
+ if phase3:
114
+ with run_timing.phase("phase3"):
115
+ aggregate_phase3_v2(metabase_root, repo_jsons, edges=call_edges)
116
+ if fleet_index:
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
+ )
@@ -8,7 +8,8 @@ from collections import Counter, defaultdict
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
11
- from ..graph_common import iter_nodes, load_v2_repo_records
11
+ from .fleet_pass import records_or_load
12
+ from ..graph_common import iter_nodes
12
13
  from ..renderers.markdown import md_table
13
14
 
14
15
 
@@ -43,10 +44,19 @@ def _row_from_record(data: dict[str, Any]) -> dict[str, Any]:
43
44
  }
44
45
 
45
46
 
46
- def write_index_v2(metabase_root: Path, repo_jsons: list[Path]) -> None:
47
- """Write the v2 repo index JSON and the all/by-group/by-language markdown pages."""
48
- records = load_v2_repo_records(metabase_root, json_paths=repo_jsons)
49
- rows = [_row_from_record(data) for data in records]
47
+ def write_index_v2(
48
+ metabase_root: Path,
49
+ repo_jsons: list[Path],
50
+ *,
51
+ rows: list[dict[str, Any]] | None = None,
52
+ ) -> None:
53
+ """Write the v2 repo index JSON and the all/by-group/by-language markdown pages.
54
+
55
+ ``rows`` lets a caller that has already streamed the fleet hand the mapped
56
+ rows in, rather than this parsing the metabase again (`OI-41`).
57
+ """
58
+ if rows is None:
59
+ rows = [_row_from_record(d) for d in records_or_load(None, metabase_root, repo_jsons)]
50
60
  index_dir = metabase_root / "index"
51
61
  index_dir.mkdir(parents=True, exist_ok=True)
52
62
 
@@ -329,6 +329,7 @@ def build_producer_indices(
329
329
  *,
330
330
  repos_root: Path | None = None,
331
331
  json_paths: list[Path] | None = None,
332
+ records: list[dict[str, Any]] | None = None,
332
333
  ) -> list[ProducerIndex]:
333
334
  """Build a producer index per known API-client binding.
334
335
 
@@ -343,7 +344,8 @@ def build_producer_indices(
343
344
  Returns:
344
345
  One ProducerIndex per binding, hits sorted by (source_repo, kind).
345
346
  """
346
- records = load_v2_repo_records(metabase_root, json_paths=json_paths)
347
+ if records is None:
348
+ records = load_v2_repo_records(metabase_root, json_paths=json_paths)
347
349
  bindings = list(get_bindings())
348
350
 
349
351
  # One walk of the fleet for every binding, rather than one walk each. The
@@ -403,6 +405,7 @@ def write_payload_producer_index(
403
405
  repo_jsons: list[Path],
404
406
  *,
405
407
  repos_root: Path | None = None,
408
+ records: list[dict[str, Any]] | None = None,
406
409
  ) -> list[ProducerIndex]:
407
410
  """Write the payload-endpoint-producers markdown + jsonl catalogue.
408
411
 
@@ -419,6 +422,7 @@ def write_payload_producer_index(
419
422
  metabase_root,
420
423
  repos_root=root,
421
424
  json_paths=repo_jsons,
425
+ records=records,
422
426
  )
423
427
 
424
428
  out_dir = metabase_root / "graphs"