@tyroneross/build-loop 0.36.0 → 0.43.0

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 (270) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor/rules/build-loop-surface.mdc +12 -11
  6. package/.cursor/rules/skill-index.mdc +33 -0
  7. package/AGENTS.md +213 -34
  8. package/README.md +99 -31
  9. package/agents/advisor.md +4 -4
  10. package/agents/alignment-checker.md +2 -2
  11. package/agents/architecture-scout.md +4 -4
  12. package/agents/build-orchestrator.md +38 -36
  13. package/agents/database-assessor.md +11 -5
  14. package/agents/design-contract-specialist.md +8 -8
  15. package/agents/fact-checker.md +13 -3
  16. package/agents/fix-critique.md +2 -2
  17. package/agents/independent-auditor.md +60 -7
  18. package/agents/leak-scanner.md +82 -0
  19. package/agents/overfitting-reviewer.md +2 -2
  20. package/agents/plan-critic.md +1 -1
  21. package/agents/promotion-reviewer.md +5 -5
  22. package/agents/retrospective-synthesizer.md +138 -35
  23. package/agents/scope-auditor.md +82 -11
  24. package/agents/security-reviewer.md +56 -2
  25. package/agents/self-improvement-architect.md +17 -3
  26. package/agents/transcript-pattern-miner.md +5 -5
  27. package/agents/ui-validator.md +1 -1
  28. package/bin/build-loop-debugger.js +143 -0
  29. package/bin/build-loop-install.js +1 -4
  30. package/bin/build-loop-load-probe.js +345 -0
  31. package/codex-skills/build-loop/SKILL.md +28 -6
  32. package/commands/feedback.md +37 -0
  33. package/dist/src/interactive-verifier.d.ts +1 -14
  34. package/dist/src/interactive-verifier.d.ts.map +1 -1
  35. package/dist/src/interactive-verifier.js +6 -113
  36. package/dist/src/interactive-verifier.js.map +1 -1
  37. package/dist/src/quality.d.ts +5 -0
  38. package/dist/src/quality.d.ts.map +1 -0
  39. package/dist/src/quality.js +81 -0
  40. package/dist/src/quality.js.map +1 -0
  41. package/dist/src/storage.d.ts.map +1 -1
  42. package/dist/src/storage.js +37 -3
  43. package/dist/src/storage.js.map +1 -1
  44. package/docs/agent-surface-policy.md +35 -31
  45. package/docs/memory-setup.md +19 -0
  46. package/hooks/git/pre-push +65 -4
  47. package/hooks/hooks.json +95 -38
  48. package/hooks/pre-commit +20 -1
  49. package/hooks/pre-edit-rally-point.sh +10 -3
  50. package/hooks/session-start-codex-hook-trust.sh +30 -0
  51. package/hooks/session-start-git-hooks.sh +3 -1
  52. package/hooks/session-start-rally-point.sh +52 -4
  53. package/hooks/session-start-worktree-gc.sh +47 -94
  54. package/hooks/stop-transcript-sweep.sh +173 -0
  55. package/hooks/test_closeout.sh +14 -2
  56. package/package.json +8 -7
  57. package/scripts/README.md +1 -1
  58. package/scripts/_paths.py +65 -0
  59. package/scripts/groundwork_exchange.py +1012 -0
  60. package/scripts/install_memory.py +33 -1
  61. package/scripts/lessons_index/ingest.py +13 -2
  62. package/scripts/lessons_index/query.py +36 -13
  63. package/scripts/memory_context/__init__.py +108 -14
  64. package/scripts/memory_graph/__init__.py +5 -1
  65. package/scripts/project_resolver.py +42 -36
  66. package/scripts/sync_plugin_cache.py +37 -2
  67. package/skills/agent-rally-point/SKILL.md +46 -0
  68. package/skills/api-registry-bridge/SKILL.md +1 -1
  69. package/skills/architecture/dead/SKILL.md +1 -1
  70. package/skills/architecture/impact/SKILL.md +1 -1
  71. package/skills/architecture/review/SKILL.md +1 -1
  72. package/skills/architecture/rules/SKILL.md +3 -3
  73. package/skills/architecture/scan/SKILL.md +1 -1
  74. package/skills/architecture/trace/SKILL.md +1 -1
  75. package/skills/attribution-standard/SKILL.md +6 -6
  76. package/skills/auto-decision-capture/SKILL.md +31 -2
  77. package/skills/auto-finding-capture/SKILL.md +28 -1
  78. package/skills/build-loop/SKILL.md +131 -23
  79. package/skills/build-loop/fallbacks.md +16 -21
  80. package/skills/build-loop/phases/ui-validation.md +2 -2
  81. package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
  82. package/skills/build-loop/references/apple-native-planning.md +1 -1
  83. package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
  84. package/skills/build-loop/references/autonomy-dashboard.md +115 -0
  85. package/skills/build-loop/references/capability-routing.md +24 -2
  86. package/skills/build-loop/references/coordination.md +24 -6
  87. package/skills/build-loop/references/experiment-results-template.md +15 -3
  88. package/skills/build-loop/references/leadership.md +1 -1
  89. package/skills/build-loop/references/memory.md +14 -3
  90. package/skills/build-loop/references/modular-systems-pack.md +8 -0
  91. package/skills/build-loop/references/output-style.md +86 -0
  92. package/skills/build-loop/references/phase-1-assess.md +102 -2
  93. package/skills/build-loop/references/phase-2-plan.md +9 -1
  94. package/skills/build-loop/references/phase-3-execute.md +5 -2
  95. package/skills/build-loop/references/phase-4-review.md +85 -8
  96. package/skills/build-loop/references/phase-5-iterate.md +76 -8
  97. package/skills/build-loop/references/phase-6-learn.md +10 -17
  98. package/skills/build-loop/references/privileged-request-broker.md +254 -0
  99. package/skills/build-loop/references/resource-aware-execution.md +183 -0
  100. package/skills/build-loop/references/self-recursive-dev.md +2 -2
  101. package/skills/build-loop/references/status-output-format.md +207 -0
  102. package/skills/build-loop/references/verify-dispatch.md +56 -2
  103. package/skills/building-with-deepagents/SKILL.md +1 -1
  104. package/skills/claim-scope/SKILL.md +185 -0
  105. package/skills/color-engine/SKILL.md +103 -0
  106. package/skills/color-engine/_core.py +464 -0
  107. package/skills/color-engine/color_engine.py +175 -0
  108. package/skills/cost-rca/SKILL.md +61 -0
  109. package/skills/data-plane-worktrees/SKILL.md +139 -0
  110. package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
  111. package/skills/database-practice/SKILL.md +200 -0
  112. package/skills/database-practice/references/diagnostic-queries.sql +126 -0
  113. package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
  114. package/skills/database-practice/scripts/db_table_map.py +1244 -0
  115. package/skills/database-practice/scripts/test_db_table_map.py +514 -0
  116. package/skills/debug-loop/SKILL.md +36 -6
  117. package/skills/debugging-memory/SKILL.md +32 -430
  118. package/skills/debugging-memory/references/pattern-extraction.md +4 -4
  119. package/skills/debugging-memory/references/search.md +32 -120
  120. package/skills/debugging-memory/references/store.md +32 -126
  121. package/skills/debugging-memory/references/subagent-integration.md +1 -1
  122. package/skills/decision-queue/SKILL.md +251 -0
  123. package/skills/decision-queue/assets/template.html +1242 -0
  124. package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
  125. package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
  126. package/skills/defenseclaw-bridge/SKILL.md +2 -2
  127. package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
  128. package/skills/drain-proposals/SKILL.md +53 -0
  129. package/skills/focused-loop-builder/SKILL.md +31 -0
  130. package/skills/focused-loop-builder/references/spec-format.md +27 -0
  131. package/skills/handoff/SKILL.md +169 -8
  132. package/skills/ibr-bridge/SKILL.md +4 -1
  133. package/skills/knowledge/SKILL.md +26 -14
  134. package/skills/knowledge/references/review-mode.md +2 -3
  135. package/skills/knowledge/templates/madr-minimal.md +1 -1
  136. package/skills/mcp-builder/SKILL.md +1 -1
  137. package/skills/model-bakeoff/SKILL.md +48 -10
  138. package/skills/model-tiering/SKILL.md +92 -31
  139. package/skills/native-ax-driver/SKILL.md +38 -5
  140. package/skills/native-ax-driver/scripts/native_driver.py +278 -22
  141. package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
  142. package/skills/optimize/SKILL.md +1 -1
  143. package/skills/plugin-builder/SKILL.md +48 -1
  144. package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
  145. package/skills/plugin-builder/references/distribution.md +13 -2
  146. package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
  147. package/skills/plugin-tests/SKILL.md +2 -2
  148. package/skills/recursive-retrospective/SKILL.md +1 -1
  149. package/skills/repo-closeout/SKILL.md +17 -0
  150. package/skills/repo-closeout/agents/openai.yaml +4 -0
  151. package/skills/repo-maintenance/SKILL.md +179 -0
  152. package/skills/repo-maintenance/agents/openai.yaml +4 -0
  153. package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
  154. package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
  155. package/skills/repo-maintenance/references/safety-protocol.md +106 -0
  156. package/skills/repo-maintenance/references/stack-profiles.md +138 -0
  157. package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
  158. package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
  159. package/skills/repository-intelligence/SKILL.md +189 -0
  160. package/skills/repository-intelligence/agents/openai.yaml +4 -0
  161. package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
  162. package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
  163. package/skills/research/SKILL.md +12 -2
  164. package/skills/root-cause-analysis/SKILL.md +1 -1
  165. package/skills/runtime-parity-verification/SKILL.md +36 -1
  166. package/skills/security-methodology/SKILL.md +23 -10
  167. package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
  168. package/skills/security-methodology/references/cross-source-matrix.md +1 -1
  169. package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
  170. package/skills/security-scan/SKILL.md +55 -15
  171. package/skills/self-improve/SKILL.md +70 -50
  172. package/skills/silent-assumptions/SKILL.md +341 -0
  173. package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
  174. package/skills/spec-writing/SKILL.md +128 -24
  175. package/skills/spec-writing/scripts/check_checklist.py +114 -15
  176. package/skills/ui-design/SKILL.md +6 -4
  177. package/skills/ui-design/references/color-engine.md +132 -0
  178. package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
  179. package/skills/ui-design/references/ui-guidance-sources.md +1 -1
  180. package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
  181. package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
  182. package/plugin-artifacts/codex/AGENTS.md +0 -560
  183. package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
  184. package/plugin-artifacts/codex/LICENSE +0 -202
  185. package/plugin-artifacts/codex/README.md +0 -313
  186. package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
  187. package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
  188. package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
  189. package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
  190. package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
  191. package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
  192. package/plugin-artifacts/codex/references/backlog-system.md +0 -285
  193. package/plugin-artifacts/codex/references/capability-routing.md +0 -231
  194. package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
  195. package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
  196. package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
  197. package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
  198. package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
  199. package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
  200. package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
  201. package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
  202. package/plugin-artifacts/codex/references/leadership.md +0 -72
  203. package/plugin-artifacts/codex/references/memory-systems.md +0 -261
  204. package/plugin-artifacts/codex/references/memory.md +0 -313
  205. package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
  206. package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
  207. package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
  208. package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
  209. package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
  210. package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
  211. package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
  212. package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
  213. package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
  214. package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
  215. package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
  216. package/plugin-artifacts/codex/references/self-review.md +0 -234
  217. package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
  218. package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
  219. package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
  220. package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
  221. package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
  222. package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
  223. package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
  224. package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
  225. package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
  226. package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
  227. package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
  228. package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
  229. package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
  230. package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
  231. package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
  232. package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
  233. package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
  234. package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
  235. package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
  236. package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
  237. package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
  238. package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
  239. package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
  240. package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
  241. package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
  242. package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
  243. package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
  244. package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
  245. package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
  246. package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
  247. package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
  248. package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
  249. package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
  250. package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
  251. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
  252. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
  253. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
  254. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
  255. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
  256. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
  257. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
  258. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
  259. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
  260. package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
  261. package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
  262. package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
  263. package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
  264. package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
  265. package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
  266. package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
  267. package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
  268. package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
  269. package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
  270. package/scripts/build_codex_plugin_artifact.py +0 -321
@@ -0,0 +1,1244 @@
1
+ #!/usr/bin/env python3
2
+ """db_table_map.py — read-only PostgreSQL table map, tracked over time.
3
+
4
+ Produces a per-table map (domain, size, counters, indexes, FKs, liveness) plus
5
+ the global sections a cost claim needs (counter window, function GUCs, vector
6
+ index cost vs benefit, top statements, temp spill), as JSON and as Markdown.
7
+
8
+ The JSON is the tracking artifact: re-run with ``--prev <old.json>`` and the
9
+ map reports what changed — new and removed tables, size moves over 10%, indexes
10
+ whose ``idx_scan`` stayed flat while their table kept taking inserts, and
11
+ liveness verdicts that flipped.
12
+
13
+ Every statement runs inside ``BEGIN READ ONLY`` with a ``SET LOCAL
14
+ statement_timeout``, and the generated script is whitelist-checked before it is
15
+ handed to ``psql``: only ``select``, ``with``, ``begin``, ``set local
16
+ statement_timeout`` and ``rollback`` may start a statement. The script cannot
17
+ issue DDL or DML.
18
+
19
+ Usage::
20
+
21
+ python3 db_table_map.py --dsn "$DIRECT_URL" \
22
+ --out-json docs/04-operations/database-audits/2026-09-05-database-map.json \
23
+ --out-md docs/04-operations/database-audits/2026-09-05-database-map.md \
24
+ --prev docs/04-operations/database-audits/2026-08-01-database-map.json
25
+
26
+ With no ``--dsn`` the DSN is read from ``DATABASE_URL`` or ``DIRECT_URL``.
27
+ With no ``--out-md`` the Markdown map is written to stdout.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import subprocess
37
+ import sys
38
+ import time
39
+ from pathlib import Path
40
+ from typing import Any
41
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
42
+
43
+ # psql refuses to connect when the URI carries client-library-only parameters.
44
+ # Prisma and Supabase pooler URLs routinely carry all three.
45
+ UNSUPPORTED_URI_PARAMS = ("pgbouncer", "connection_limit", "pool_timeout")
46
+
47
+ SECTION_MARK = "@@bl-section:"
48
+ SECTION_END = "@@"
49
+
50
+ # Only these tokens may begin a statement in the generated script.
51
+ ALLOWED_STATEMENT_STARTS = ("select", "with", "begin", "rollback", "set")
52
+
53
+ DEFAULT_DOMAIN_RULES: list[list[str]] = [
54
+ ["auth", r"^(auth|users?|accounts?|sessions?|roles?|permissions?|api_keys?)"],
55
+ ["queue", r"(queue|job|task|worker|outbox|dead_letter|retry)"],
56
+ ["embedding", r"(embedding|vector|chunk)"],
57
+ ["graph", r"(entit|node|edge|relation|graph|mention|pair|triple)"],
58
+ ["audit", r"(audit|event|log|history|snapshot|ledger)"],
59
+ ["cache", r"(cache|summary|rollup|agg|_mv$|materialized|pack)"],
60
+ ["taxonomy", r"(categor|topic|taxonom|classif|(^|_)(tag|label)s?($|_))"],
61
+ ["content", r"(article|post|document|content|feed|source|podcast|story|page)"],
62
+ ]
63
+ DEFAULT_DOMAIN = "other"
64
+
65
+ VECTOR_ACCESS_METHODS = ("hnsw", "ivfflat")
66
+ SPECIAL_ACCESS_METHODS = VECTOR_ACCESS_METHODS + ("gin", "gist", "brin")
67
+
68
+ # An index is "cold" when the workload almost never reads it but every insert
69
+ # maintains it. 100 scans over a multi-month window is noise, not a read path.
70
+ COLD_INDEX_SCAN_CEILING = 100
71
+ COLD_INDEX_INSERT_FLOOR = 10_000
72
+ # Size delta that counts as a real move between two maps.
73
+ SIZE_DELTA_PCT = 10.0
74
+
75
+ SHAPES: dict[str, str] = {
76
+ "vector-insert-above-cache": "Insert against a vector index larger than cache",
77
+ "toast-predicate": "Predicate on a TOASTed column",
78
+ "per-row-jsonb-trigram": "Per-row lookup through jsonb + trigram",
79
+ "vector-read-above-cache": "Vector similarity read",
80
+ "index-maintenance-on-writes": "Index maintenance charged to writes",
81
+ }
82
+
83
+ MD_SECTIONS = (
84
+ "Counter window",
85
+ "Server settings",
86
+ "Domain rollup",
87
+ "Tables",
88
+ "Liveness",
89
+ "Vector, text, and GIN indexes",
90
+ "Function GUCs",
91
+ "Top statements by total_exec_time",
92
+ "Temp spill",
93
+ "Shapes",
94
+ )
95
+ MD_DIFF_SECTION = "Diff vs previous map"
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # DSN handling
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def sanitize_dsn(dsn: str) -> str:
103
+ """Drop client-library-only URI parameters psql rejects.
104
+
105
+ ``pgbouncer``, ``connection_limit`` and ``pool_timeout`` are Prisma/pooler
106
+ parameters. libpq treats an unknown URI parameter as a fatal connection
107
+ error, so a DSN copied out of ``.env`` fails before it reaches the server.
108
+ Non-URI (keyword/value) DSNs are returned untouched.
109
+ """
110
+ if "://" not in dsn:
111
+ return dsn
112
+ parts = urlsplit(dsn)
113
+ if not parts.query:
114
+ return dsn
115
+ kept = [
116
+ (key, value)
117
+ for key, value in parse_qsl(parts.query, keep_blank_values=True)
118
+ if key.lower() not in UNSUPPORTED_URI_PARAMS
119
+ ]
120
+ query = urlencode(kept)
121
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, query, parts.fragment))
122
+
123
+
124
+ def redact_dsn(dsn: str) -> str:
125
+ """Describe the connection for a report as host[:port]/database only.
126
+
127
+ No scheme, no user, no password. A report line shaped like
128
+ ``postgresql://user:***@host/db`` still trips secret scanners (measured
129
+ 2026-09-05: the in-house scanner in a consuming repo failed CI on the
130
+ redacted form), and the user segment of a hosted DSN carries the
131
+ project identifier, which is not the report's business either.
132
+ """
133
+ if "://" not in dsn:
134
+ host = re.search(r"host=(\S+)", dsn)
135
+ port = re.search(r"port=(\S+)", dsn)
136
+ dbname = re.search(r"dbname=(\S+)", dsn)
137
+ out = host.group(1) if host else "?"
138
+ if port:
139
+ out += f":{port.group(1)}"
140
+ if dbname:
141
+ out += f"/{dbname.group(1)}"
142
+ return out
143
+ parts = urlsplit(dsn)
144
+ host = parts.hostname or "?"
145
+ if parts.port:
146
+ host += f":{parts.port}"
147
+ return f"{host}{parts.path}"
148
+
149
+
150
+ def resolve_dsn(explicit: str | None, env: dict[str, str] | None = None) -> str:
151
+ env = os.environ if env is None else env
152
+ for candidate in (explicit, env.get("DATABASE_URL"), env.get("DIRECT_URL")):
153
+ if candidate:
154
+ return candidate
155
+ raise SystemExit(
156
+ "no DSN: pass --dsn, or set DATABASE_URL or DIRECT_URL in the environment"
157
+ )
158
+
159
+
160
+ # ---------------------------------------------------------------------------
161
+ # SQL
162
+ # ---------------------------------------------------------------------------
163
+
164
+ USER_SCHEMA_FILTER = "n.nspname not in ('pg_catalog', 'information_schema') and n.nspname !~ '^pg_'"
165
+
166
+ # `<+>` as a bare regex means "one or more '<' then '>'", which matches the
167
+ # plpgsql not-equals operator `<>` and floods the map with every platform
168
+ # function. The plus is escaped; the standard_conforming_strings default keeps
169
+ # the backslash literal on its way to the regex engine.
170
+ VECTOR_OPERATOR_REGEX = r"<=>|<->|<#>|<\+>"
171
+
172
+ # Platform-managed schemas. A function here is only interesting when it
173
+ # genuinely touches vectors; its search_path GUC is the provider's business.
174
+ SYSTEM_MANAGED_SCHEMAS = (
175
+ "auth", "cron", "extensions", "graphql", "graphql_public", "net",
176
+ "pgbouncer", "pgsodium", "realtime", "storage", "supabase_functions",
177
+ "supabase_migrations", "vault",
178
+ )
179
+ SYSTEM_MANAGED_SCHEMA_LIST = ", ".join(f"'{name}'" for name in SYSTEM_MANAGED_SCHEMAS)
180
+
181
+ SECTION_SQL: list[tuple[str, str]] = [
182
+ (
183
+ "window",
184
+ """
185
+ select pg_postmaster_start_time()::text,
186
+ now()::text,
187
+ coalesce((select stats_reset::text from pg_stat_database
188
+ where datname = current_database()), ''),
189
+ current_database(),
190
+ version();
191
+ """,
192
+ ),
193
+ (
194
+ "settings",
195
+ """
196
+ select name, setting, coalesce(unit, '')
197
+ from pg_settings
198
+ where name in ('shared_buffers', 'work_mem', 'maintenance_work_mem',
199
+ 'effective_cache_size', 'max_connections',
200
+ 'max_parallel_workers_per_gather', 'server_version')
201
+ order by name;
202
+ """,
203
+ ),
204
+ (
205
+ "tables",
206
+ """
207
+ select s.schemaname,
208
+ s.relname,
209
+ pg_total_relation_size(c.oid),
210
+ pg_relation_size(c.oid),
211
+ pg_indexes_size(c.oid),
212
+ greatest(pg_total_relation_size(c.oid)
213
+ - pg_relation_size(c.oid)
214
+ - pg_indexes_size(c.oid), 0),
215
+ s.n_live_tup, s.n_dead_tup,
216
+ s.n_tup_ins, s.n_tup_upd, s.n_tup_del,
217
+ s.seq_scan, coalesce(s.idx_scan, 0),
218
+ coalesce(s.last_autovacuum::text, ''),
219
+ coalesce(s.last_autoanalyze::text, ''),
220
+ coalesce(array_to_string(c.reloptions, ' '), '')
221
+ from pg_stat_user_tables s
222
+ join pg_class c on c.oid = s.relid
223
+ order by pg_total_relation_size(c.oid) desc;
224
+ """,
225
+ ),
226
+ (
227
+ "indexes",
228
+ f"""
229
+ select n.nspname,
230
+ t.relname,
231
+ i.relname,
232
+ am.amname,
233
+ pg_relation_size(i.oid),
234
+ coalesce(si.idx_scan, 0),
235
+ replace(replace(pg_get_indexdef(i.oid), chr(9), ' '), chr(10), ' ')
236
+ from pg_class i
237
+ join pg_index x on x.indexrelid = i.oid
238
+ join pg_class t on t.oid = x.indrelid
239
+ join pg_namespace n on n.oid = i.relnamespace
240
+ join pg_am am on am.oid = i.relam
241
+ left join pg_stat_user_indexes si on si.indexrelid = i.oid
242
+ where {USER_SCHEMA_FILTER}
243
+ order by pg_relation_size(i.oid) desc;
244
+ """,
245
+ ),
246
+ (
247
+ "special_columns",
248
+ """
249
+ select table_schema, table_name, column_name, udt_name
250
+ from information_schema.columns
251
+ where udt_name in ('vector', 'halfvec', 'sparsevec', 'tsvector')
252
+ and table_schema not in ('pg_catalog', 'information_schema')
253
+ order by table_schema, table_name, ordinal_position;
254
+ """,
255
+ ),
256
+ (
257
+ "foreign_keys",
258
+ f"""
259
+ select con.conname,
260
+ sn.nspname, src.relname,
261
+ tn.nspname, tgt.relname
262
+ from pg_constraint con
263
+ join pg_class src on src.oid = con.conrelid
264
+ join pg_namespace sn on sn.oid = src.relnamespace
265
+ join pg_class tgt on tgt.oid = con.confrelid
266
+ join pg_namespace tn on tn.oid = tgt.relnamespace
267
+ join pg_namespace n on n.oid = src.relnamespace
268
+ where con.contype = 'f' and {USER_SCHEMA_FILTER}
269
+ order by src.relname, con.conname;
270
+ """,
271
+ ),
272
+ (
273
+ "functions",
274
+ f"""
275
+ with candidate as (
276
+ select n.nspname as schema_name,
277
+ p.proname as function_name,
278
+ coalesce(array_to_string(p.proconfig, ' '), '') as proconfig,
279
+ (p.prosrc ~ '{VECTOR_OPERATOR_REGEX}'
280
+ or exists (select 1 from pg_type ty
281
+ where ty.typname in ('vector', 'halfvec', 'sparsevec')
282
+ and (ty.oid = p.prorettype
283
+ or ty.oid = any(coalesce(p.proallargtypes,
284
+ p.proargtypes::oid[]))))) as uses_vector
285
+ from pg_proc p
286
+ join pg_namespace n on n.oid = p.pronamespace
287
+ join pg_language l on l.oid = p.prolang
288
+ where {USER_SCHEMA_FILTER}
289
+ -- only functions someone wrote: extension internals (pgvector's own
290
+ -- vector_add, halfvec_in, ...) are C code that no ALTER FUNCTION should touch
291
+ and l.lanname in ('sql', 'plpgsql')
292
+ and not exists (select 1 from pg_depend d
293
+ where d.classid = 'pg_proc'::regclass
294
+ and d.objid = p.oid and d.deptype = 'e')
295
+ )
296
+ select distinct schema_name,
297
+ function_name,
298
+ proconfig,
299
+ case when uses_vector then 'vector' else '' end
300
+ from candidate
301
+ where uses_vector
302
+ or (proconfig <> '' and schema_name not in ({SYSTEM_MANAGED_SCHEMA_LIST}))
303
+ order by schema_name, function_name;
304
+ """,
305
+ ),
306
+ (
307
+ "database_temp",
308
+ """
309
+ select temp_files, temp_bytes, blks_read, blks_hit, deadlocks
310
+ from pg_stat_database
311
+ where datname = current_database();
312
+ """,
313
+ ),
314
+ ]
315
+
316
+ STATEMENTS_SQL = """
317
+ select calls,
318
+ round(total_exec_time::numeric, 0),
319
+ round(mean_exec_time::numeric, 1),
320
+ round((100.0 * total_exec_time / nullif(sum(total_exec_time) over (), 0))::numeric, 2),
321
+ round((rows::numeric / nullif(calls, 0)), 2),
322
+ temp_blks_written,
323
+ left(regexp_replace(query, '\\s+', ' ', 'g'), 200)
324
+ from pg_stat_statements
325
+ where dbid = (select oid from pg_database where datname = current_database())
326
+ order by total_exec_time desc
327
+ limit 25;
328
+ """
329
+
330
+ STATEMENT_TEMP_SQL = """
331
+ select calls,
332
+ temp_blks_written,
333
+ left(regexp_replace(query, '\\s+', ' ', 'g'), 160)
334
+ from pg_stat_statements
335
+ where temp_blks_written > 0
336
+ and dbid = (select oid from pg_database where datname = current_database())
337
+ order by temp_blks_written desc
338
+ limit 10;
339
+ """
340
+
341
+ PROBE_SQL = """
342
+ select count(*) from pg_extension where extname = 'pg_stat_statements';
343
+ """
344
+
345
+
346
+ def build_script(sections: list[tuple[str, str]], statement_timeout_s: int) -> str:
347
+ """Wrap the section queries in a read-only, timeout-bounded transaction."""
348
+ parts = [
349
+ "BEGIN READ ONLY;",
350
+ f"SET LOCAL statement_timeout = '{statement_timeout_s}s';",
351
+ ]
352
+ for name, sql in sections:
353
+ parts.append(f"select '{SECTION_MARK}{name}{SECTION_END}';")
354
+ parts.append(sql.strip())
355
+ parts.append("ROLLBACK;")
356
+ return "\n".join(parts) + "\n"
357
+
358
+
359
+ def assert_read_only(script: str) -> None:
360
+ """Reject any statement that is not a read.
361
+
362
+ Whitelist, not blacklist: a statement may only begin with ``select``,
363
+ ``with``, ``begin``, ``rollback``, or ``set local statement_timeout``.
364
+ """
365
+ for raw in script.split(";"):
366
+ stripped = "\n".join(
367
+ line for line in raw.splitlines() if not line.strip().startswith("--")
368
+ ).strip()
369
+ if not stripped:
370
+ continue
371
+ head = stripped.lower().split(None, 1)[0]
372
+ if head not in ALLOWED_STATEMENT_STARTS:
373
+ raise ValueError(f"refusing to run a non-read statement: {stripped[:60]!r}")
374
+ if head == "set" and not stripped.lower().startswith("set local statement_timeout"):
375
+ raise ValueError(f"refusing to run a non-read statement: {stripped[:60]!r}")
376
+
377
+
378
+ def run_psql(dsn: str, script: str, timeout_s: int, psql: str = "psql") -> str:
379
+ """Execute a read-only script through psql and return raw tab-separated rows."""
380
+ assert_read_only(script)
381
+ cmd = [
382
+ psql,
383
+ sanitize_dsn(dsn),
384
+ "-X",
385
+ "-A",
386
+ "-t",
387
+ "-F",
388
+ "\t",
389
+ "-v",
390
+ "ON_ERROR_STOP=1",
391
+ "-f",
392
+ "-",
393
+ ]
394
+ env = dict(os.environ)
395
+ env.setdefault("PGCONNECT_TIMEOUT", "10")
396
+ try:
397
+ proc = subprocess.run(
398
+ cmd,
399
+ input=script,
400
+ text=True,
401
+ capture_output=True,
402
+ timeout=timeout_s + 30,
403
+ env=env,
404
+ )
405
+ except FileNotFoundError as exc: # pragma: no cover - environment dependent
406
+ raise SystemExit(f"psql not found on PATH: {exc}") from exc
407
+ except subprocess.TimeoutExpired as exc: # pragma: no cover - timing dependent
408
+ raise SystemExit(f"psql timed out after {timeout_s + 30}s") from exc
409
+ if proc.returncode != 0:
410
+ raise SystemExit(f"psql failed ({proc.returncode}): {proc.stderr.strip()[:800]}")
411
+ return proc.stdout
412
+
413
+
414
+ def parse_sections(output: str) -> dict[str, list[list[str]]]:
415
+ """Split marker-delimited psql output into ``{section: [row, ...]}``."""
416
+ sections: dict[str, list[list[str]]] = {}
417
+ current: str | None = None
418
+ for line in output.splitlines():
419
+ if line.startswith(SECTION_MARK) and line.endswith(SECTION_END):
420
+ current = line[len(SECTION_MARK) : -len(SECTION_END)]
421
+ sections.setdefault(current, [])
422
+ continue
423
+ if current is None or not line.strip():
424
+ continue
425
+ sections[current].append(line.split("\t"))
426
+ return sections
427
+
428
+
429
+ # ---------------------------------------------------------------------------
430
+ # Derivation
431
+ # ---------------------------------------------------------------------------
432
+
433
+ def _int(value: str) -> int:
434
+ try:
435
+ return int(float(value))
436
+ except (TypeError, ValueError):
437
+ return 0
438
+
439
+
440
+ def _float(value: str) -> float:
441
+ try:
442
+ return float(value)
443
+ except (TypeError, ValueError):
444
+ return 0.0
445
+
446
+
447
+ def classify_domain(name: str, rules: list[list[str]]) -> str:
448
+ for domain, pattern in rules:
449
+ if re.search(pattern, name, re.IGNORECASE):
450
+ return domain
451
+ return DEFAULT_DOMAIN
452
+
453
+
454
+ def liveness(table: dict[str, Any]) -> tuple[str, str]:
455
+ """Return ``(verdict, reason)`` from the runtime counters alone.
456
+
457
+ Per the constitution: ``idx_scan > 0`` means an application issued a
458
+ filtered query (audit scripts produce ``seq_scan`` only), and ``n_live_tup``
459
+ is a stale planner estimate that must never decide emptiness.
460
+ """
461
+ idx_scan = table["idx_scan"]
462
+ ins = table["n_tup_ins"]
463
+ upd = table["n_tup_upd"]
464
+ dele = table["n_tup_del"]
465
+ writes = ins + upd + dele
466
+ live_est = table["n_live_tup"]
467
+
468
+ if idx_scan > 0:
469
+ return "live", f"{idx_scan:,} index scans in the window"
470
+ if ins > 0:
471
+ if live_est <= 0:
472
+ return "written-only", f"{ins:,} inserts, no index reads, ~0 live rows (drained queue)"
473
+ return "written-only", f"{ins:,} inserts, no index reads (write-only sink)"
474
+ if writes > 0 or live_est > 0 or table["seq_scan"] > 0:
475
+ return "idle", (
476
+ f"no index reads and no inserts; {table['seq_scan']:,} seq scans, "
477
+ f"~{live_est:,} live rows (estimate)"
478
+ )
479
+ return "never-written", "no reads and no writes since the counters started"
480
+
481
+
482
+ def build_map(
483
+ sections: dict[str, list[list[str]]],
484
+ domain_rules: list[list[str]],
485
+ dsn_label: str,
486
+ has_pg_stat_statements: bool,
487
+ ) -> dict[str, Any]:
488
+ window_rows = sections.get("window", [[]])
489
+ window_row = window_rows[0] if window_rows and window_rows[0] else ["", "", "", "", ""]
490
+ window = {
491
+ "postmaster_start_time": window_row[0] if len(window_row) > 0 else "",
492
+ "collected_at": window_row[1] if len(window_row) > 1 else "",
493
+ "stats_reset": window_row[2] if len(window_row) > 2 else "",
494
+ "database": window_row[3] if len(window_row) > 3 else "",
495
+ "server_version": window_row[4] if len(window_row) > 4 else "",
496
+ }
497
+
498
+ settings = {row[0]: {"setting": row[1], "unit": row[2] if len(row) > 2 else ""}
499
+ for row in sections.get("settings", []) if len(row) >= 2}
500
+ shared_buffers_bytes = _setting_bytes(settings.get("shared_buffers"))
501
+
502
+ tables: dict[str, dict[str, Any]] = {}
503
+ for row in sections.get("tables", []):
504
+ if len(row) < 16:
505
+ continue
506
+ schema, name = row[0], row[1]
507
+ key = name if schema == "public" else f"{schema}.{name}"
508
+ table = {
509
+ "key": key,
510
+ "schema": schema,
511
+ "name": name,
512
+ "domain": classify_domain(name, domain_rules),
513
+ "total_bytes": _int(row[2]),
514
+ "heap_bytes": _int(row[3]),
515
+ "index_bytes": _int(row[4]),
516
+ "toast_bytes": _int(row[5]),
517
+ "n_live_tup": _int(row[6]),
518
+ "n_dead_tup": _int(row[7]),
519
+ "n_tup_ins": _int(row[8]),
520
+ "n_tup_upd": _int(row[9]),
521
+ "n_tup_del": _int(row[10]),
522
+ "seq_scan": _int(row[11]),
523
+ "idx_scan": _int(row[12]),
524
+ "last_autovacuum": row[13],
525
+ "last_autoanalyze": row[14],
526
+ "reloptions": row[15],
527
+ "indexes": [],
528
+ "vector_columns": [],
529
+ "tsvector_columns": [],
530
+ "fk_out": [],
531
+ "fk_in": [],
532
+ "estimates_note": "n_live_tup / n_dead_tup are planner estimates, not counts",
533
+ }
534
+ verdict, reason = liveness(table)
535
+ table["liveness"] = verdict
536
+ table["liveness_reason"] = reason
537
+ tables[key] = table
538
+
539
+ indexes: list[dict[str, Any]] = []
540
+ for row in sections.get("indexes", []):
541
+ if len(row) < 7:
542
+ continue
543
+ schema, table_name, index_name = row[0], row[1], row[2]
544
+ key = table_name if schema == "public" else f"{schema}.{table_name}"
545
+ entry = {
546
+ "table": key,
547
+ "name": index_name,
548
+ "access_method": row[3],
549
+ "size_bytes": _int(row[4]),
550
+ "idx_scan": _int(row[5]),
551
+ "definition": row[6],
552
+ }
553
+ indexes.append(entry)
554
+ if key in tables:
555
+ tables[key]["indexes"].append(entry)
556
+
557
+ for row in sections.get("special_columns", []):
558
+ if len(row) < 4:
559
+ continue
560
+ schema, table_name, column, udt = row
561
+ key = table_name if schema == "public" else f"{schema}.{table_name}"
562
+ if key not in tables:
563
+ continue
564
+ bucket = "tsvector_columns" if udt == "tsvector" else "vector_columns"
565
+ tables[key][bucket].append({"column": column, "type": udt})
566
+
567
+ for row in sections.get("foreign_keys", []):
568
+ if len(row) < 5:
569
+ continue
570
+ conname, src_schema, src, tgt_schema, tgt = row
571
+ src_key = src if src_schema == "public" else f"{src_schema}.{src}"
572
+ tgt_key = tgt if tgt_schema == "public" else f"{tgt_schema}.{tgt}"
573
+ if src_key in tables:
574
+ tables[src_key]["fk_out"].append({"constraint": conname, "references": tgt_key})
575
+ if tgt_key in tables:
576
+ tables[tgt_key]["fk_in"].append({"constraint": conname, "from": src_key})
577
+
578
+ functions = []
579
+ for row in sections.get("functions", []):
580
+ if len(row) < 4:
581
+ continue
582
+ proconfig = row[2]
583
+ functions.append(
584
+ {
585
+ "schema": row[0],
586
+ "name": row[1],
587
+ "proconfig": [item for item in proconfig.split(" ") if item],
588
+ "uses_vector_ops": row[3] == "vector",
589
+ }
590
+ )
591
+
592
+ statements = []
593
+ for row in sections.get("statements", []):
594
+ if len(row) < 7:
595
+ continue
596
+ statements.append(
597
+ {
598
+ "calls": _int(row[0]),
599
+ "total_exec_ms": _float(row[1]),
600
+ "mean_exec_ms": _float(row[2]),
601
+ "pct_of_db_time": _float(row[3]),
602
+ "rows_per_call": _float(row[4]),
603
+ "temp_blks_written": _int(row[5]),
604
+ "statement": row[6],
605
+ }
606
+ )
607
+
608
+ temp_rows = sections.get("database_temp", [])
609
+ temp = {}
610
+ if temp_rows and len(temp_rows[0]) >= 5:
611
+ row = temp_rows[0]
612
+ blks_read, blks_hit = _int(row[2]), _int(row[3])
613
+ total = blks_read + blks_hit
614
+ temp = {
615
+ "temp_files": _int(row[0]),
616
+ "temp_bytes": _int(row[1]),
617
+ "blks_read": blks_read,
618
+ "blks_hit": blks_hit,
619
+ "cache_hit_pct": round(100.0 * blks_hit / total, 2) if total else None,
620
+ "deadlocks": _int(row[4]),
621
+ }
622
+ temp["top_spilling_statements"] = [
623
+ {"calls": _int(r[0]), "temp_blks_written": _int(r[1]), "statement": r[2]}
624
+ for r in sections.get("statement_temp", [])
625
+ if len(r) >= 3
626
+ ]
627
+
628
+ domains: dict[str, dict[str, Any]] = {}
629
+ for table in tables.values():
630
+ bucket = domains.setdefault(
631
+ table["domain"],
632
+ {"tables": 0, "total_bytes": 0, "idx_scan": 0, "writes": 0},
633
+ )
634
+ bucket["tables"] += 1
635
+ bucket["total_bytes"] += table["total_bytes"]
636
+ bucket["idx_scan"] += table["idx_scan"]
637
+ bucket["writes"] += table["n_tup_ins"] + table["n_tup_upd"] + table["n_tup_del"]
638
+
639
+ data: dict[str, Any] = {
640
+ "schema_version": 1,
641
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
642
+ "dsn": dsn_label,
643
+ "window": window,
644
+ "settings": settings,
645
+ "shared_buffers_bytes": shared_buffers_bytes,
646
+ "pg_stat_statements": has_pg_stat_statements,
647
+ "table_count": len(tables),
648
+ "domains": domains,
649
+ "tables": [tables[key] for key in sorted(tables)],
650
+ "indexes": indexes,
651
+ "functions": functions,
652
+ "statements": statements if has_pg_stat_statements else None,
653
+ "temp": temp,
654
+ }
655
+ data["shapes"] = detect_shapes(data)
656
+ return data
657
+
658
+
659
+ def _setting_bytes(setting: dict[str, str] | None) -> int | None:
660
+ """Convert a pg_settings row to bytes. Blocks are 8kB unless stated."""
661
+ if not setting:
662
+ return None
663
+ try:
664
+ value = int(setting["setting"])
665
+ except (KeyError, TypeError, ValueError):
666
+ return None
667
+ unit = (setting.get("unit") or "").strip()
668
+ factors = {"": 1, "B": 1, "kB": 1024, "8kB": 8192, "MB": 1024**2, "GB": 1024**3}
669
+ if unit not in factors:
670
+ return None
671
+ return value * factors[unit]
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Shape matching
676
+ # ---------------------------------------------------------------------------
677
+
678
+ def detect_shapes(data: dict[str, Any]) -> list[dict[str, Any]]:
679
+ """Match collected facts against the five cost shapes in the skill body."""
680
+ findings: list[dict[str, Any]] = []
681
+ shared_buffers = data.get("shared_buffers_bytes")
682
+ tables = {table["key"]: table for table in data["tables"]}
683
+
684
+ for index in sorted(data["indexes"], key=lambda i: -i["size_bytes"]):
685
+ table = tables.get(index["table"])
686
+ if table is None:
687
+ continue
688
+ am = index["access_method"]
689
+ ins = table["n_tup_ins"]
690
+ oversized = shared_buffers is not None and index["size_bytes"] > shared_buffers
691
+
692
+ cold = (
693
+ am in SPECIAL_ACCESS_METHODS
694
+ and index["idx_scan"] <= COLD_INDEX_SCAN_CEILING
695
+ and ins >= COLD_INDEX_INSERT_FLOOR
696
+ )
697
+ if cold:
698
+ findings.append(
699
+ {
700
+ "shape": "index-maintenance-on-writes",
701
+ "title": SHAPES["index-maintenance-on-writes"],
702
+ "object": f"{index['table']}.{index['name']}",
703
+ "evidence": (
704
+ f"{am} index of {human_bytes(index['size_bytes'])} with "
705
+ f"{index['idx_scan']:,} lifetime scans against "
706
+ f"{ins:,} table inserts"
707
+ ),
708
+ "action": "Drop it, or accept the write cost explicitly and record why",
709
+ }
710
+ )
711
+ if am in VECTOR_ACCESS_METHODS and oversized and ins >= COLD_INDEX_INSERT_FLOOR:
712
+ findings.append(
713
+ {
714
+ "shape": "vector-insert-above-cache",
715
+ "title": SHAPES["vector-insert-above-cache"],
716
+ "object": f"{index['table']}.{index['name']}",
717
+ "evidence": (
718
+ f"{human_bytes(index['size_bytes'])} {am} graph against "
719
+ f"shared_buffers {human_bytes(shared_buffers)}; "
720
+ f"{ins:,} inserts each maintain it as random I/O"
721
+ ),
722
+ "action": (
723
+ "Split fixed from marginal insert cost, then batch harder "
724
+ "or shrink the graph (m / dimensions) to fit cache"
725
+ ),
726
+ }
727
+ )
728
+ if am in VECTOR_ACCESS_METHODS and oversized and index["idx_scan"] > COLD_INDEX_SCAN_CEILING:
729
+ findings.append(
730
+ {
731
+ "shape": "vector-read-above-cache",
732
+ "title": SHAPES["vector-read-above-cache"],
733
+ "object": f"{index['table']}.{index['name']}",
734
+ "evidence": (
735
+ f"{index['idx_scan']:,} scans against a "
736
+ f"{human_bytes(index['size_bytes'])} graph that cannot fit "
737
+ f"shared_buffers ({human_bytes(shared_buffers)})"
738
+ ),
739
+ "action": "Size the graph to cache, or raise the cache",
740
+ }
741
+ )
742
+
743
+ for table in data["tables"]:
744
+ if (
745
+ table["toast_bytes"] > table["heap_bytes"]
746
+ and table["toast_bytes"] > 64 * 1024 * 1024
747
+ and (table["seq_scan"] > 0 or table["idx_scan"] > 0)
748
+ ):
749
+ findings.append(
750
+ {
751
+ "shape": "toast-predicate",
752
+ "title": SHAPES["toast-predicate"],
753
+ "object": table["key"],
754
+ "evidence": (
755
+ f"{human_bytes(table['toast_bytes'])} TOAST against "
756
+ f"{human_bytes(table['heap_bytes'])} heap on "
757
+ f"~{table['n_live_tup']:,} rows (estimate)"
758
+ ),
759
+ "action": (
760
+ "Check every predicate and sort touching the wide column; "
761
+ "maintain a derived scalar beside it and filter on that"
762
+ ),
763
+ }
764
+ )
765
+
766
+ for statement in data.get("statements") or []:
767
+ text = statement["statement"].lower()
768
+ jsonb = "->>" in text or "->" in text or "@>" in text or "jsonb" in text
769
+ fuzzy = "similarity(" in text or " ilike " in text or "%" in text or "trgm" in text
770
+ if jsonb and fuzzy and statement["calls"] > 1000 and statement["mean_exec_ms"] >= 50:
771
+ findings.append(
772
+ {
773
+ "shape": "per-row-jsonb-trigram",
774
+ "title": SHAPES["per-row-jsonb-trigram"],
775
+ "object": statement["statement"][:80],
776
+ "evidence": (
777
+ f"{statement['calls']:,} calls at "
778
+ f"{statement['mean_exec_ms']:.0f} ms mean "
779
+ f"({statement['pct_of_db_time']:.1f}% of DB time)"
780
+ ),
781
+ "action": (
782
+ "Composite index on the real filter columns; move the fuzzy "
783
+ "match behind an exact one"
784
+ ),
785
+ }
786
+ )
787
+
788
+ vector_functions = [f for f in data["functions"] if f["uses_vector_ops"]]
789
+ for function in vector_functions:
790
+ config = " ".join(function["proconfig"])
791
+ if "hnsw.ef_search" not in config and "hnsw.iterative_scan" not in config:
792
+ findings.append(
793
+ {
794
+ "shape": "vector-read-above-cache",
795
+ "title": "Vector function carries no HNSW GUCs",
796
+ "object": f"{function['schema']}.{function['name']}",
797
+ "evidence": (
798
+ f"proconfig = {config or '(none)'} — no ef_search and no "
799
+ "iterative_scan, so the function runs at the pgvector defaults"
800
+ ),
801
+ "action": (
802
+ "ALTER FUNCTION ... SET hnsw.ef_search / hnsw.iterative_scan; "
803
+ "session SET is lost under a transaction-mode pooler"
804
+ ),
805
+ }
806
+ )
807
+ return findings
808
+
809
+
810
+ # ---------------------------------------------------------------------------
811
+ # Diff
812
+ # ---------------------------------------------------------------------------
813
+
814
+ def diff_maps(previous: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
815
+ """Compare two maps: new/removed tables, size moves, stalled indexes, liveness."""
816
+ prev_tables = {t["key"]: t for t in previous.get("tables", [])}
817
+ curr_tables = {t["key"]: t for t in current.get("tables", [])}
818
+
819
+ counters_reset = (
820
+ previous.get("window", {}).get("stats_reset")
821
+ != current.get("window", {}).get("stats_reset")
822
+ ) or (
823
+ previous.get("window", {}).get("postmaster_start_time")
824
+ != current.get("window", {}).get("postmaster_start_time")
825
+ )
826
+
827
+ size_deltas = []
828
+ liveness_changes = []
829
+ for key, curr in curr_tables.items():
830
+ prev = prev_tables.get(key)
831
+ if prev is None:
832
+ continue
833
+ before, after = prev["total_bytes"], curr["total_bytes"]
834
+ if before > 0:
835
+ pct = 100.0 * (after - before) / before
836
+ if abs(pct) >= SIZE_DELTA_PCT:
837
+ size_deltas.append(
838
+ {
839
+ "table": key,
840
+ "before_bytes": before,
841
+ "after_bytes": after,
842
+ "pct": round(pct, 1),
843
+ }
844
+ )
845
+ if prev.get("liveness") != curr.get("liveness"):
846
+ liveness_changes.append(
847
+ {
848
+ "table": key,
849
+ "before": prev.get("liveness"),
850
+ "after": curr.get("liveness"),
851
+ "reason": curr.get("liveness_reason", ""),
852
+ }
853
+ )
854
+
855
+ prev_indexes = {(i["table"], i["name"]): i for i in previous.get("indexes", [])}
856
+ stalled = []
857
+ for index in current.get("indexes", []):
858
+ prev_index = prev_indexes.get((index["table"], index["name"]))
859
+ if prev_index is None:
860
+ continue
861
+ scan_delta = index["idx_scan"] - prev_index["idx_scan"]
862
+ prev_table = prev_tables.get(index["table"])
863
+ curr_table = curr_tables.get(index["table"])
864
+ if prev_table is None or curr_table is None:
865
+ continue
866
+ ins_delta = curr_table["n_tup_ins"] - prev_table["n_tup_ins"]
867
+ if scan_delta <= 0 and ins_delta > 0:
868
+ stalled.append(
869
+ {
870
+ "index": index["name"],
871
+ "table": index["table"],
872
+ "access_method": index["access_method"],
873
+ "scan_delta": scan_delta,
874
+ "insert_delta": ins_delta,
875
+ "size_bytes": index["size_bytes"],
876
+ }
877
+ )
878
+
879
+ return {
880
+ "previous_generated_at": previous.get("generated_at"),
881
+ "counters_reset_between_maps": counters_reset,
882
+ "new_tables": sorted(set(curr_tables) - set(prev_tables)),
883
+ "removed_tables": sorted(set(prev_tables) - set(curr_tables)),
884
+ "size_deltas": sorted(size_deltas, key=lambda d: -abs(d["pct"])),
885
+ "stalled_indexes": sorted(stalled, key=lambda d: -d["insert_delta"]),
886
+ "liveness_changes": sorted(liveness_changes, key=lambda d: d["table"]),
887
+ }
888
+
889
+
890
+ # ---------------------------------------------------------------------------
891
+ # Rendering
892
+ # ---------------------------------------------------------------------------
893
+
894
+ def human_bytes(value: int | None) -> str:
895
+ if value is None:
896
+ return "n/a"
897
+ step = 1024.0
898
+ amount = float(value)
899
+ for unit in ("B", "kB", "MB", "GB", "TB"):
900
+ if abs(amount) < step or unit == "TB":
901
+ return f"{amount:.0f} {unit}" if unit == "B" else f"{amount:.1f} {unit}"
902
+ amount /= step
903
+ return f"{amount:.1f} TB"
904
+
905
+
906
+ def render_markdown(data: dict[str, Any], diff: dict[str, Any] | None = None) -> str:
907
+ window = data["window"]
908
+ out: list[str] = []
909
+ out.append(f"# Database map — {window.get('database') or 'database'}")
910
+ out.append("")
911
+ out.append(
912
+ f"Generated {data['generated_at']} from `{data['dsn']}`. "
913
+ f"{data['table_count']} user tables. Read-only collection; no DDL or DML was issued."
914
+ )
915
+ out.append("")
916
+
917
+ out.append("## Counter window")
918
+ out.append("")
919
+ out.append("Every counter below is *since* these timestamps. State the window with any claim.")
920
+ out.append("")
921
+ out.append("| Field | Value |")
922
+ out.append("|---|---|")
923
+ out.append(f"| postmaster start | {window.get('postmaster_start_time', '')} |")
924
+ out.append(f"| stats_reset | {window.get('stats_reset') or 'never reset'} |")
925
+ out.append(f"| collected at | {window.get('collected_at', '')} |")
926
+ out.append(f"| server | {window.get('server_version', '')[:80]} |")
927
+ out.append(
928
+ f"| pg_stat_statements | {'available' if data['pg_stat_statements'] else 'NOT INSTALLED — time attribution skipped'} |"
929
+ )
930
+ out.append("")
931
+
932
+ out.append("## Server settings")
933
+ out.append("")
934
+ out.append("| Setting | Value |")
935
+ out.append("|---|---|")
936
+ for name in sorted(data["settings"]):
937
+ entry = data["settings"][name]
938
+ unit = entry.get("unit") or ""
939
+ out.append(f"| {name} | {entry['setting']}{(' ' + unit) if unit else ''} |")
940
+ out.append("")
941
+ if data.get("shared_buffers_bytes"):
942
+ out.append(
943
+ f"`shared_buffers` resolves to {human_bytes(data['shared_buffers_bytes'])}. "
944
+ "Any index larger than that is traversed from disk on every maintenance write."
945
+ )
946
+ out.append("")
947
+
948
+ out.append("## Domain rollup")
949
+ out.append("")
950
+ out.append("| Domain | Tables | Total size | Index scans | Writes |")
951
+ out.append("|---|---:|---:|---:|---:|")
952
+ for domain in sorted(data["domains"], key=lambda d: -data["domains"][d]["total_bytes"]):
953
+ bucket = data["domains"][domain]
954
+ out.append(
955
+ f"| {domain} | {bucket['tables']} | {human_bytes(bucket['total_bytes'])} "
956
+ f"| {bucket['idx_scan']:,} | {bucket['writes']:,} |"
957
+ )
958
+ out.append("")
959
+
960
+ out.append("## Tables")
961
+ out.append("")
962
+ out.append(
963
+ "`live~` and `dead~` are planner estimates and never decide emptiness "
964
+ "(constitution, Cost Attribution rule 7). Use `count(*)` for that."
965
+ )
966
+ out.append("")
967
+ out.append("| Table | Domain | Total | Heap | Idx | TOAST | live~ | ins/upd/del | seq/idx scan | Liveness |")
968
+ out.append("|---|---|---:|---:|---:|---:|---:|---|---|---|")
969
+ for table in sorted(data["tables"], key=lambda t: -t["total_bytes"]):
970
+ out.append(
971
+ "| {key} | {domain} | {total} | {heap} | {idx} | {toast} | {live:,} "
972
+ "| {ins:,}/{upd:,}/{dele:,} | {seq:,}/{idx_scan:,} | {liveness} |".format(
973
+ key=table["key"],
974
+ domain=table["domain"],
975
+ total=human_bytes(table["total_bytes"]),
976
+ heap=human_bytes(table["heap_bytes"]),
977
+ idx=human_bytes(table["index_bytes"]),
978
+ toast=human_bytes(table["toast_bytes"]),
979
+ live=table["n_live_tup"],
980
+ ins=table["n_tup_ins"],
981
+ upd=table["n_tup_upd"],
982
+ dele=table["n_tup_del"],
983
+ seq=table["seq_scan"],
984
+ idx_scan=table["idx_scan"],
985
+ liveness=table["liveness"],
986
+ )
987
+ )
988
+ out.append("")
989
+
990
+ out.append("## Liveness")
991
+ out.append("")
992
+ buckets: dict[str, list[dict[str, Any]]] = {}
993
+ for table in data["tables"]:
994
+ buckets.setdefault(table["liveness"], []).append(table)
995
+ for verdict in ("live", "written-only", "idle", "never-written"):
996
+ entries = buckets.get(verdict, [])
997
+ out.append(f"**{verdict}** — {len(entries)} table(s)")
998
+ out.append("")
999
+ if verdict != "live":
1000
+ for table in sorted(entries, key=lambda t: t["key"]):
1001
+ out.append(f"- `{table['key']}` — {table['liveness_reason']}")
1002
+ if entries:
1003
+ out.append("")
1004
+ out.append(
1005
+ "No verdict here retires anything. A retirement still needs the full gate in the skill body."
1006
+ )
1007
+ out.append("")
1008
+
1009
+ out.append("## Vector, text, and GIN indexes")
1010
+ out.append("")
1011
+ out.append("| Index | Table | AM | Size | Scans | Table inserts |")
1012
+ out.append("|---|---|---|---:|---:|---:|")
1013
+ tables_by_key = {t["key"]: t for t in data["tables"]}
1014
+ special = [i for i in data["indexes"] if i["access_method"] in SPECIAL_ACCESS_METHODS]
1015
+ for index in sorted(special, key=lambda i: -i["size_bytes"]):
1016
+ table = tables_by_key.get(index["table"], {})
1017
+ out.append(
1018
+ f"| {index['name']} | {index['table']} | {index['access_method']} "
1019
+ f"| {human_bytes(index['size_bytes'])} | {index['idx_scan']:,} "
1020
+ f"| {table.get('n_tup_ins', 0):,} |"
1021
+ )
1022
+ if not special:
1023
+ out.append("| _none_ | | | | | |")
1024
+ out.append("")
1025
+
1026
+ out.append("## Function GUCs")
1027
+ out.append("")
1028
+ out.append("| Function | proconfig | Uses vector ops |")
1029
+ out.append("|---|---|---|")
1030
+ for function in data["functions"]:
1031
+ config = " ".join(function["proconfig"]) or "_none_"
1032
+ out.append(
1033
+ f"| {function['schema']}.{function['name']} | {config} "
1034
+ f"| {'yes' if function['uses_vector_ops'] else 'no'} |"
1035
+ )
1036
+ if not data["functions"]:
1037
+ out.append("| _none_ | | |")
1038
+ out.append("")
1039
+ out.append(
1040
+ "A vector-search function with no `hnsw.ef_search` / `hnsw.iterative_scan` in "
1041
+ "`proconfig` runs at the pgvector defaults regardless of what the repo's SQL "
1042
+ "file says. That gap is drift, and only `ALTER FUNCTION ... SET` survives a "
1043
+ "transaction-mode pooler."
1044
+ )
1045
+ out.append("")
1046
+
1047
+ out.append("## Top statements by total_exec_time")
1048
+ out.append("")
1049
+ if data["pg_stat_statements"]:
1050
+ out.append("| % DB time | Calls | Mean ms | rows/call | temp blks | Statement |")
1051
+ out.append("|---:|---:|---:|---:|---:|---|")
1052
+ for statement in data["statements"] or []:
1053
+ out.append(
1054
+ f"| {statement['pct_of_db_time']:.2f} | {statement['calls']:,} "
1055
+ f"| {statement['mean_exec_ms']:.1f} | {statement['rows_per_call']:.2f} "
1056
+ f"| {statement['temp_blks_written']:,} | `{statement['statement'][:120]}` |"
1057
+ )
1058
+ else:
1059
+ out.append(
1060
+ "`pg_stat_statements` is not installed, so no time attribution was collected. "
1061
+ "Every cost claim in a plan needs a measured share — install the extension "
1062
+ "before proposing a performance fix."
1063
+ )
1064
+ out.append("")
1065
+
1066
+ out.append("## Temp spill")
1067
+ out.append("")
1068
+ temp = data["temp"]
1069
+ out.append("| Field | Value |")
1070
+ out.append("|---|---|")
1071
+ out.append(f"| temp_files | {temp.get('temp_files', 0):,} |")
1072
+ out.append(f"| temp_bytes | {human_bytes(temp.get('temp_bytes', 0))} |")
1073
+ out.append(f"| cache hit | {temp.get('cache_hit_pct')}% |")
1074
+ out.append(f"| deadlocks | {temp.get('deadlocks', 0):,} |")
1075
+ out.append("")
1076
+ for entry in temp.get("top_spilling_statements", []):
1077
+ out.append(
1078
+ f"- {entry['temp_blks_written']:,} temp blocks over {entry['calls']:,} calls: "
1079
+ f"`{entry['statement'][:100]}`"
1080
+ )
1081
+ if temp.get("top_spilling_statements"):
1082
+ out.append("")
1083
+
1084
+ out.append("## Shapes")
1085
+ out.append("")
1086
+ out.append(
1087
+ "Each finding names which of the five cost shapes it matches. The shape picks "
1088
+ "the first action; it does not authorize the change."
1089
+ )
1090
+ out.append("")
1091
+ if data["shapes"]:
1092
+ out.append("| Shape | Object | Evidence | First action |")
1093
+ out.append("|---|---|---|---|")
1094
+ for finding in data["shapes"]:
1095
+ out.append(
1096
+ f"| {finding['title']} | `{finding['object']}` | {finding['evidence']} "
1097
+ f"| {finding['action']} |"
1098
+ )
1099
+ else:
1100
+ out.append("No shape matched the collected counters.")
1101
+ out.append("")
1102
+
1103
+ if diff is not None:
1104
+ out.extend(_render_diff(diff))
1105
+ return "\n".join(out) + "\n"
1106
+
1107
+
1108
+ def _render_diff(diff: dict[str, Any]) -> list[str]:
1109
+ out = [f"## {MD_DIFF_SECTION}", ""]
1110
+ out.append(f"Previous map generated {diff.get('previous_generated_at')}.")
1111
+ out.append("")
1112
+ if diff.get("counters_reset_between_maps"):
1113
+ out.append(
1114
+ "**The counters reset between the two maps.** Every delta below is a floor, "
1115
+ "not a measurement — the server restarted or statistics were reset."
1116
+ )
1117
+ out.append("")
1118
+ out.append(f"- New tables: {', '.join(diff['new_tables']) or 'none'}")
1119
+ out.append(f"- Removed tables: {', '.join(diff['removed_tables']) or 'none'}")
1120
+ out.append("")
1121
+ out.append(f"### Size moves over {SIZE_DELTA_PCT:.0f}%")
1122
+ out.append("")
1123
+ if diff["size_deltas"]:
1124
+ out.append("| Table | Before | After | Change |")
1125
+ out.append("|---|---:|---:|---:|")
1126
+ for entry in diff["size_deltas"]:
1127
+ out.append(
1128
+ f"| {entry['table']} | {human_bytes(entry['before_bytes'])} "
1129
+ f"| {human_bytes(entry['after_bytes'])} | {entry['pct']:+.1f}% |"
1130
+ )
1131
+ else:
1132
+ out.append("None.")
1133
+ out.append("")
1134
+ out.append("### Indexes maintained but not read since the last map")
1135
+ out.append("")
1136
+ if diff["stalled_indexes"]:
1137
+ out.append("| Index | Table | AM | New scans | New inserts | Size |")
1138
+ out.append("|---|---|---|---:|---:|---:|")
1139
+ for entry in diff["stalled_indexes"]:
1140
+ out.append(
1141
+ f"| {entry['index']} | {entry['table']} | {entry['access_method']} "
1142
+ f"| {entry['scan_delta']:,} | {entry['insert_delta']:,} "
1143
+ f"| {human_bytes(entry['size_bytes'])} |"
1144
+ )
1145
+ else:
1146
+ out.append("None.")
1147
+ out.append("")
1148
+ out.append("### Liveness changes")
1149
+ out.append("")
1150
+ if diff["liveness_changes"]:
1151
+ for entry in diff["liveness_changes"]:
1152
+ out.append(
1153
+ f"- `{entry['table']}`: {entry['before']} → {entry['after']} ({entry['reason']})"
1154
+ )
1155
+ else:
1156
+ out.append("None.")
1157
+ out.append("")
1158
+ return out
1159
+
1160
+
1161
+ # ---------------------------------------------------------------------------
1162
+ # Collection
1163
+ # ---------------------------------------------------------------------------
1164
+
1165
+ def collect(dsn: str, statement_timeout_s: int, psql: str = "psql") -> tuple[dict[str, list[list[str]]], bool]:
1166
+ """Run the probe, then the main script. Returns parsed sections + extension flag."""
1167
+ probe_out = run_psql(
1168
+ dsn, build_script([("probe", PROBE_SQL)], statement_timeout_s), statement_timeout_s, psql
1169
+ )
1170
+ probe = parse_sections(probe_out).get("probe", [])
1171
+ has_pgss = bool(probe and probe[0] and _int(probe[0][0]) > 0)
1172
+
1173
+ sections = list(SECTION_SQL)
1174
+ if has_pgss:
1175
+ sections.append(("statements", STATEMENTS_SQL))
1176
+ sections.append(("statement_temp", STATEMENT_TEMP_SQL))
1177
+ output = run_psql(dsn, build_script(sections, statement_timeout_s), statement_timeout_s, psql)
1178
+ return parse_sections(output), has_pgss
1179
+
1180
+
1181
+ def load_domain_rules(path: str | None) -> list[list[str]]:
1182
+ if not path:
1183
+ return DEFAULT_DOMAIN_RULES
1184
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
1185
+ if not isinstance(raw, list) or any(len(item) != 2 for item in raw):
1186
+ raise SystemExit("--domain-rules must be a JSON list of [domain, regex] pairs")
1187
+ for _, pattern in raw:
1188
+ re.compile(pattern)
1189
+ return [[str(domain), str(pattern)] for domain, pattern in raw]
1190
+
1191
+
1192
+ def parse_args(argv: list[str]) -> argparse.Namespace:
1193
+ parser = argparse.ArgumentParser(
1194
+ description="Read-only PostgreSQL table map for build-loop:database-practice.",
1195
+ )
1196
+ parser.add_argument("--dsn", help="Postgres DSN (default: $DATABASE_URL, then $DIRECT_URL)")
1197
+ parser.add_argument("--out-json", help="Write the machine-readable map here")
1198
+ parser.add_argument("--out-md", help="Write the Markdown map here (default: stdout)")
1199
+ parser.add_argument("--domain-rules", help="JSON file: list of [domain, regex] pairs")
1200
+ parser.add_argument("--prev", help="Previous --out-json map to diff against")
1201
+ parser.add_argument(
1202
+ "--statement-timeout",
1203
+ type=int,
1204
+ default=20,
1205
+ help="SET LOCAL statement_timeout, in seconds (default 20)",
1206
+ )
1207
+ parser.add_argument("--psql", default="psql", help="psql binary to use (default: psql)")
1208
+ return parser.parse_args(argv)
1209
+
1210
+
1211
+ def main(argv: list[str] | None = None) -> int:
1212
+ args = parse_args(list(sys.argv[1:] if argv is None else argv))
1213
+ dsn = resolve_dsn(args.dsn)
1214
+ rules = load_domain_rules(args.domain_rules)
1215
+
1216
+ sections, has_pgss = collect(dsn, args.statement_timeout, args.psql)
1217
+ data = build_map(sections, rules, redact_dsn(dsn), has_pgss)
1218
+
1219
+ diff = None
1220
+ if args.prev:
1221
+ previous = json.loads(Path(args.prev).read_text(encoding="utf-8"))
1222
+ diff = diff_maps(previous, data)
1223
+ data["diff"] = diff
1224
+
1225
+ markdown = render_markdown(data, diff)
1226
+ if args.out_json:
1227
+ Path(args.out_json).parent.mkdir(parents=True, exist_ok=True)
1228
+ Path(args.out_json).write_text(json.dumps(data, indent=2, sort_keys=False) + "\n", encoding="utf-8")
1229
+ if args.out_md:
1230
+ Path(args.out_md).parent.mkdir(parents=True, exist_ok=True)
1231
+ Path(args.out_md).write_text(markdown, encoding="utf-8")
1232
+ else:
1233
+ sys.stdout.write(markdown)
1234
+
1235
+ print(
1236
+ f"[db_table_map] {data['table_count']} tables, {len(data['indexes'])} indexes, "
1237
+ f"{len(data['shapes'])} shape findings",
1238
+ file=sys.stderr,
1239
+ )
1240
+ return 0
1241
+
1242
+
1243
+ if __name__ == "__main__": # pragma: no cover
1244
+ raise SystemExit(main())