hecks 2.9.0 → 3.0.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 (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,1070 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "json"
5
+ require "optparse"
6
+ require "pathname"
7
+ require "ripper"
8
+ require_relative "../tools"
9
+
10
+ module Hecks
11
+ module Tools
12
+ # Checks a set of Ruby files against docs/COMMENT_STYLE_GUIDE.md and,
13
+ # for the mechanical categories, rewrites them to comply.
14
+ module CommentStyle
15
+ MAX_LINE = 100
16
+ LONG_CLASS_DOC = 25
17
+ MAX_HEADING_WORDS = 6
18
+
19
+ # A run of comment lines longer than this is a `long_block`, unless the baseline holds it.
20
+ # ADR 0075 ticket 01 sets this value, amending ADR 0069's provisional 50.
21
+ MAX_BLOCK = 12
22
+
23
+ # Longest anchor kept in a block's baseline key.
24
+ MAX_ANCHOR = 80
25
+
26
+ CATEGORIES = {
27
+ "undocumented_method" => "public method has no doc comment",
28
+ "missing_param" => "doc comment lacks @param for a parameter",
29
+ "missing_return" => "doc comment lacks @return",
30
+ "missing_raise" => "method raises but doc comment lacks @raise",
31
+ "undocumented_class" => "class or module has no doc comment",
32
+ "unstructured_class_doc" => "long class doc has no ## section headers",
33
+ "missing_summary" => "doc comment has tags but no lead sentence",
34
+ "all_caps" => "all-caps emphasis",
35
+ "long_bold" => "bold heading over #{MAX_HEADING_WORDS} words reads as shouting",
36
+ "bare_constant" => "capitalised word names something in this file's code: backtick or lowercase it",
37
+ "unknown_capitals" => "capitalised token is not an English word: backtick it or list it as an acronym",
38
+ "design_history" => "narrates design history instead of citing an ADR",
39
+ "long_line" => "comment line exceeds #{MAX_LINE} characters",
40
+ "long_block" => "comment block exceeds #{MAX_BLOCK} lines and is new or has grown past its baseline"
41
+ }.freeze
42
+
43
+ FIXABLE = %w[all_caps long_bold long_line].freeze
44
+
45
+ # Initialisms and keywords that are correctly written in capitals.
46
+ ACRONYMS = %w[
47
+ ARGV ARN AZ ECR ES FSM GVL IAM IGW IPC IRB LLM MB MVCC NAT OOM RDS SG SIGKILL SIGTERM STDIO TOML
48
+ UI VCS VM VPC WASI
49
+ ABC ACID ADR API ASC ASCII AST AWS BFS BYPASSRLS CAS CDN CEO CI CLI CPU CRUD CSRF CSS CSV CTE DB DBA DDD DDL
50
+ DELETE DESC DFS DML DNS DSL ENV ERB ERD FIFO FIXME FK FQN GC GET GIN HMAC HTML HTTP HTTPS ID INSERT IO IR ISO
51
+ JS JSON JSONB JSONL JWKS JWT LIFO LRU MCP MDN MRI MVP NAMEDATALEN NIL NULL OIDC OK ORM OS
52
+ PATCH PG PID PK PM POSIX POST PR PRAGMA PRD PUT QA QC RAM RBAC README REPL REST RLS RNG RPC RUBYOPT
53
+ SAM SAVEPOINT SDK SELECT SHA SME SQL SSH SSL SSM SW STDERR STDIN STDOUT TCP TLA TLS TOCTOU TODO TTL UID UL
54
+ URI URL UTC UTF UUID VO WASM XML YAML
55
+ ACL ALB BC GNU OAC WAF
56
+ CLOUDFRONT ECS HSM KMS
57
+ ].to_set.freeze
58
+
59
+ # Names whose correct spelling is neither all-caps nor all-lowercase.
60
+ PROPER_NOUNS = {
61
+ "CLOUDFLARE" => "Cloudflare", "OAUTH" => "OAuth", "RSPEC" => "RSpec", "JAVASCRIPT" => "JavaScript",
62
+ "GITHUB" => "GitHub", "GOOGLE" => "Google", "GRAPHVIZ" => "Graphviz", "HECKS" => "Hecks",
63
+ "POSTGRES" => "Postgres", "POSTGRESQL" => "PostgreSQL", "PUMA" => "Puma", "RAILS" => "Rails",
64
+ "RUBY" => "Ruby", "RUST" => "Rust", "SQLITE" => "SQLite"
65
+ }.freeze
66
+
67
+ # Multi-word SQL is protected as a phrase, because the individual keywords
68
+ # (`FROM`, `WHERE`, `TABLE`) are also ordinary English emphasis targets.
69
+ SQL_PHRASES = Regexp.union(
70
+ /\bINSERT INTO\b/, /\bSELECT\b.*?\bFROM\b/, /\bORDER BY\b/, /\bGROUP BY\b/,
71
+ /\bON CONFLICT\b(?: DO (?:NOTHING|UPDATE))?/, /\bDO NOTHING\b/, /\bFOR (?:UPDATE|SHARE)\b/,
72
+ /\bSKIP LOCKED\b/, /\b(?:PRIMARY|FOREIGN) KEY\b/, /\bIS(?: NOT)? NULL\b/, /\bNOT NULL\b/,
73
+ /\bCREATE (?:UNIQUE )?(?:TABLE|INDEX|POLICY|ROLE|SCHEMA|TRIGGER|FUNCTION)\b/,
74
+ /\bALTER (?:TABLE|ROLE|SCHEMA)\b/, /\bDROP (?:TABLE|COLUMN|INDEX|SCHEMA|POLICY)\b/,
75
+ /\bADD (?:COLUMN|CONSTRAINT)\b/, /\bDELETE FROM\b/, /\bUNION ALL\b/, /\bSET LOCAL\b/,
76
+ /\bBEGIN (?:IMMEDIATE|EXCLUSIVE|DEFERRED)\b/, /\bROW LEVEL SECURITY\b/,
77
+ /\bCREATE \.\.\. PARTITION OF\b/, /\bPARTITION OF\b/, /\bIF (?:NOT )?EXISTS\b/,
78
+ /\b(?:LEFT|INNER|OUTER|CROSS) JOIN\b/, /\bLIMIT \d+\b/, /\bOWNER TO\b/, /\b(?:on|in|onto) PATH\b/
79
+ ).freeze
80
+
81
+ # SQL keywords that are rarely English emphasis. Kept in capitals on a line
82
+ # that reads as SQL, and always kept in a file that talks to a database.
83
+ SQL_STRONG = %w[
84
+ ALTER ATTACH BEGIN CASCADE COMMIT CONCURRENTLY DETACH DISTINCT FORCE GRANT LIMIT MATERIALIZED
85
+ NULLS OFFSET PARTITION RETURNING REVOKE ROLLBACK SERIALIZABLE VACUUM
86
+ ].to_set.freeze
87
+
88
+ # SQL keywords that are also everyday emphasis targets (`FROM`, `FIRST`, `SET`).
89
+ SQL_WEAK = %w[
90
+ COLUMN CONSTRAINT CREATE DEFAULT DROP EXCLUSIVE EXISTS FIRST FROM INDEX JOIN LAST LOCK LOGIN
91
+ NOBYPASSRLS NOCREATEDB NOCREATEROLE NOLOGIN NOSUPERUSER OWNER POLICY
92
+ REFERENCES ROLE SCHEMA SET SHARE TABLE TRANSACTION TRIGGER UNION UNIQUE UPDATE VALUES VIEW WHERE
93
+ ].to_set.freeze
94
+
95
+ SQL_NEUTRAL = %w[ALL AND AS BY FOR IN IS NOT OF ON OR TO].to_set.freeze
96
+
97
+ SQL_PATH = %r{sql|postgres|/d1|_era|/era|lineage|outbox|schema|persistence}
98
+
99
+ PROTECTED_SPANS = Regexp.union(
100
+ /`[^`]*`/, /"[^"]*"/, /'[^'\s]*'/, %r{\b[a-z]+://\S+}, SQL_PHRASES,
101
+ /\b(?:NOTE|OPTIMIZE|HACK|REVIEW):/
102
+ ).freeze
103
+
104
+ CAPS_WORD = /
105
+ (?<![\w:.\#@$'])
106
+ (?:A|[A-Z]{2,}(?:'[A-Z]{1,2})?)
107
+ (?![\w(]|'(?!s\b|\s|$)|-[a-z](?<=POST-.)|::|\.\w|\#\d|\s*=(?![=~>])|\s*\u2190)
108
+ /x
109
+
110
+ HISTORY = [
111
+ /\bused to\b/i, /\bwas originally\b/i, /\boriginally (?:was|were|used|lived|named|called)\b/i,
112
+ /\bpreviously\b/i, /\bformerly\b/i, /\bhistorically\b/i, /\bstale as of\b/i,
113
+ /\b(?:was|were|been|got) renamed\b/i, /\brenamed from\b/i, /\bhas since\b/i,
114
+ /\bbefore (?:this|the) (?:change|fix|refactor|split|rename|pr|commit|patch)\b/i, /\bBefore this, /,
115
+ /\ban? earlier (?:version|revision|draft|pass)\b/i, /\bwhen this was (?:written|added)\b/i,
116
+ /\b(?:in|since|by|after|before|until) (?:PR )?\#\d{2,}/i, /\bPR \#?\d{2,}\b/,
117
+ /\bno longer\b/i, /\bany more\b/i, /\banymore\b/i
118
+ ].freeze
119
+
120
+ DIRECTIVE = /\A#\s*(?:rubocop:|frozen_string_literal:|encoding:|coding:|shareable_constant|typed:|!)/
121
+
122
+ SELF_EVIDENT = %w[
123
+ to_s to_str to_sym to_h to_a to_proc inspect hash eql? == === <=> method_missing
124
+ respond_to_missing? pretty_print
125
+ ].to_set.freeze
126
+
127
+ # Stands in for a protected span so it still reads as a word, not a gap.
128
+ FILLER = "\u00A7"
129
+
130
+ # What may sit between the words of one all-caps heading run.
131
+ GAP = %r{\A(?:[\s,/&'\-\d]|#{FILLER}|[A-Z]{2,})*\z}
132
+
133
+ # A bold phrase opening a comment line, after an optional bullet.
134
+ BOLD_HEADING = /\A(#+\s*(?:[-*]\s+)?)\*\*([^*]+)\*\*/
135
+
136
+ # What ends a heading run: an em dash, or sentence punctuation.
137
+ HEADING_END = /\A(?:\s+—|[.:!?](?:\s|\z))/
138
+
139
+ CODE_NOISE = %i[on_comment on_sp on_nl on_ignored_nl].freeze
140
+
141
+ Violation = Struct.new(:path, :line, :category, :message)
142
+ MethodDef = Struct.new(:name, :line, :params, :visibility, :raises, :block_param, :type_fqn)
143
+ TypeDef = Struct.new(:fqn, :line, :namespace_only)
144
+ Comment = Struct.new(:line, :col, :text, :full_line)
145
+ Block = Struct.new(:line, :lines, :key)
146
+
147
+ # Decides whether a capitalised token is an English word being shouted;
148
+ # anything else keeps its capitals and is reported for a person to look at.
149
+ module Dictionary
150
+ WORDS = "/usr/share/dict/words"
151
+
152
+ # Technical vocabulary the system word list lacks.
153
+ EXTRA = %w[
154
+ adversarial allowlist anymore app apps arg args arity async backend backtrace baseline
155
+ boolean bootable bootstrapping bluebook byte callee canonicalized changelog checklist
156
+ checkpoint codebase codemod config database dataset dedupe deduped enqueue evaluator
157
+ filename fixpoint formatting frontend glob grep handover hardcoded hecksagon heredoc held
158
+ hoc hostname idempotency idempotent inline keyword landmine lifecycle lookup mapping
159
+ memoised memoized memoizes metadata metaprogrammed monkeypatch monorepo multi mutex
160
+ namespace noop numeric op ops parameterized params pathname payload plugin postcondition
161
+ pre reachability reentrant refactor regex regexes rekey rekeyed repo restartable retrofit
162
+ reusable roadmap rollup runtime shortcut standalone stringified struct subprocess
163
+ subprocesses subroutine substring superuser symlink sync timestamp tmp tmpl touchpoint
164
+ trickiest triggerable tuple undecoded unsanitized untargeted upsert username webhook
165
+ widget workaround worklist worklists
166
+ ].freeze
167
+
168
+ SUFFIXES = [
169
+ ["", ""], ["s", ""], ["es", ""], ["ed", ""], ["d", ""], ["ing", ""], ["ing", "e"], ["ies", "y"],
170
+ ["ied", "y"], ["ly", ""], ["er", ""], ["est", ""], ["ier", "y"], ["ised", "ized"], ["ise", "ize"],
171
+ ["isation", "ization"], ["our", "or"], ["lling", "ling"], ["lled", "led"]
172
+ ].freeze
173
+
174
+ module_function
175
+
176
+ # Loads and memoizes the system word list, once per process.
177
+ def words
178
+ return @words if defined?(@words)
179
+
180
+ @words = File.exist?(WORDS) ? Set.new(File.foreach(WORDS, chomp: true).map(&:downcase)).merge(EXTRA) : nil
181
+ end
182
+
183
+ # Checks a capitalized token against the word list, stripped and stemmed.
184
+ # Always true when this machine has no word list to consult.
185
+ def english?(word)
186
+ return true unless words
187
+
188
+ bare = word.downcase.sub(/n't\z/, "").sub(/'.*\z/, "")
189
+ SUFFIXES.any? do |suffix, replacement|
190
+ next false unless bare.end_with?(suffix)
191
+
192
+ stem = bare.delete_suffix(suffix) + replacement
193
+ words.include?(stem) || words.include?(stem.sub(/(.)\1\z/, '\1'))
194
+ end
195
+ end
196
+ end
197
+
198
+ # Class and module names defined under `lib/`, keyed by their shouted form.
199
+ def self.class_names
200
+ @class_names ||= Dir["lib/**/*.rb"].each_with_object({}) do |file, names|
201
+ File.read(file).scan(/^\s*(?:class|module)\s+([A-Z]\w*)/) do |(name)|
202
+ names[name.upcase] ||= name if name.match?(/[a-z]/)
203
+ end
204
+ end
205
+ end
206
+
207
+ # Lexes `source` and drops every comment and whitespace token.
208
+ def self.code_tokens(source)
209
+ Ripper.lex(source).reject { |token| CODE_NOISE.include?(token[1]) }.map { |token| token[2] }
210
+ end
211
+
212
+ # Bare class/module names on Hecks's documented public surface (ADR 0075 ticket 01):
213
+ # an operator or a client calls these directly. The DSL itself (`aggregate`,
214
+ # `command`, `query`, ...) is invoked through the `Hecks.bluebook` block, not by
215
+ # naming a builder class, so its builder classes stay off this list.
216
+ #
217
+ # A method's structural checks (tags, `undocumented_method`) apply only when its
218
+ # innermost enclosing type's own name is here; everything else is internal and
219
+ # needs no doc unless it carries a real why.
220
+ PUBLIC_SURFACE = Set["Storehouse"].freeze
221
+
222
+ def self.public_surface
223
+ PUBLIC_SURFACE
224
+ end
225
+
226
+ # The checked-in list of comment blocks already over `MAX_BLOCK` lines.
227
+ #
228
+ # Keyed by first-line text, not line number, so edits above a block don't break its key.
229
+ module Baseline
230
+ FILE = ".standardize_comments_baseline.json"
231
+
232
+ # The checkout this file lives in: the baseline's home when no `root:` is given.
233
+ ROOT = File.expand_path("../../..", __dir__)
234
+
235
+ PATH = File.join(ROOT, FILE)
236
+
237
+ module_function
238
+
239
+ # The baseline's location inside a checkout.
240
+ #
241
+ # @param root [String] the checkout's root directory
242
+ # @return [String] the baseline file's path
243
+ def path_in(root) = File.join(root, FILE)
244
+
245
+ # Reads the baseline from disk.
246
+ def load(path = PATH)
247
+ File.exist?(path) ? JSON.parse(File.read(path, encoding: Encoding::UTF_8)) : {}
248
+ end
249
+
250
+ # Writes a baseline in a stable order, so a rewrite shows only real changes in a diff.
251
+ def dump(blocks, path = PATH)
252
+ sorted = blocks.sort.to_h { |file, keys| [file, keys.sort.to_h] }
253
+ File.write(path, "#{JSON.pretty_generate(sorted)}\n")
254
+ end
255
+ end
256
+
257
+ # One parsed source file and the violations found in it.
258
+ class SourceFile
259
+ attr_reader :path, :lines, :comments
260
+
261
+ def initialize(path, source = nil)
262
+ @path = path
263
+ @source = source || File.read(path)
264
+ @lines = @source.lines
265
+ @comments = lex_comments
266
+ @by_line = @comments.to_h { |c| [c.line, c] }
267
+ @open_spans = open_spans
268
+ @constants = code_words
269
+ end
270
+
271
+ # Finds every place this file departs from the style guide.
272
+ def violations(only: nil, baseline: {})
273
+ return [] if generated?
274
+
275
+ found = text_violations + (test_file? ? [] : structure_violations) + long_block_violations(baseline)
276
+ found = found.select { |v| only.include?(v.category) } if only
277
+ found.sort_by { |v| [v.line, v.category] }
278
+ end
279
+
280
+ # Finds every run of consecutive full-line comments longer than `MAX_BLOCK`.
281
+ def long_blocks
282
+ return [] if generated?
283
+
284
+ seen = Hash.new(0)
285
+ runs = @comments.select(&:full_line).slice_when { |above, below| below.line != above.line + 1 }
286
+ runs.select { |run| run.size > MAX_BLOCK }.map { |run| Block.new(run.first.line, run.size, block_key(run, seen)) }
287
+ end
288
+
289
+ # Rewrites the mechanical categories; raises rather than write a change that alters code.
290
+ def fixed(only: nil)
291
+ return @source if generated?
292
+
293
+ wanted = (only || FIXABLE) & FIXABLE
294
+ output = @lines.map.with_index(1) { |line, number| fix_line(line, number, wanted) }.join
295
+ return @source if output == @source
296
+
297
+ unchanged = CommentStyle.code_tokens(@source) == CommentStyle.code_tokens(output)
298
+ raise "#{path}: fix would change code, refusing" unless unchanged
299
+
300
+ output
301
+ end
302
+
303
+ # Parses this file's structure once and caches it.
304
+ def types
305
+ test_file? || generated? ? [] : structure.last
306
+ end
307
+
308
+ # A generated file's comments belong to its generator, and its
309
+ # `GENERATED ... DO NOT EDIT` banner is a convention tools recognise.
310
+ def generated?
311
+ @lines.first(8).join.match?(/\bgenerated\b.*?\bdo not edit\b/im)
312
+ end
313
+
314
+ # Helpers defined inside a spec are test scaffolding, not API, so the
315
+ # doc-tag categories skip them. The prose categories still apply.
316
+ def test_file?
317
+ path.end_with?("_spec.rb")
318
+ end
319
+
320
+ # A `class`/`module` is documented only if a comment block sits directly above it.
321
+ def documented?(type)
322
+ !doc_above(type.line).empty?
323
+ end
324
+
325
+ private
326
+
327
+ # The text of a block's first line, without its `#`, is its key. A second block opened by
328
+ # the same text in this file gets a `#2` suffix, and so on.
329
+ def block_key(run, seen)
330
+ text = run.first.text.sub(/\A#+\s*/, "").strip[0, MAX_ANCHOR]
331
+ text = "(empty)" if text.empty?
332
+ seen[text] += 1
333
+ seen[text] == 1 ? text : "#{text} ##{seen[text]}"
334
+ end
335
+
336
+ # A block fails when it has no baseline entry, or has grown past the length recorded there.
337
+ def long_block_violations(baseline)
338
+ held = baseline.fetch(path.delete_prefix("./"), {})
339
+ long_blocks.filter_map do |block|
340
+ allowed = held[block.key]
341
+ next if allowed && block.lines <= allowed
342
+
343
+ detail = allowed ? "baselined at #{allowed}" : "over #{MAX_BLOCK}"
344
+ Violation.new(path, block.line, "long_block", "#{block.lines} lines, #{detail}: #{block.key}")
345
+ end
346
+ end
347
+
348
+ def lex_comments
349
+ Ripper.lex(@source).filter_map do |(line, col), type, text, _|
350
+ next unless type == :on_comment
351
+
352
+ prefix = @lines[line - 1].byteslice(0, col)
353
+ Comment.new(line, col, text.chomp, prefix.strip.empty?)
354
+ end
355
+ end
356
+
357
+ def text_violations
358
+ @comments.flat_map do |comment|
359
+ next [] if comment.text.match?(DIRECTIVE)
360
+
361
+ caps_violations(comment) + constant_violations(comment) + unknown_violations(comment) +
362
+ bold_violations(comment) + history_violations(comment) + length_violations(comment)
363
+ end
364
+ end
365
+
366
+ # All-caps names in this file's code: constants, heredoc delimiters, and
367
+ # strings that are nothing but one such name (`ENV["PATH"]`).
368
+ def code_words
369
+ Ripper.lex(@source).each_with_object(Set.new) do |(_, type, text, _), words|
370
+ case type
371
+ when :on_const, :on_tstring_content then words << text if text.match?(/\A[A-Z][A-Z0-9_]+\z/)
372
+ when :on_heredoc_beg, :on_heredoc_end then words.merge(text.scan(/[A-Z][A-Z0-9_]+/))
373
+ end
374
+ end
375
+ end
376
+
377
+ def unknown_violations(comment)
378
+ words = mask(comment).scan(CAPS_WORD).uniq - ["A"]
379
+ words.reject { |word| keep_capitals?(word) }.select { |word| unknown?(word) }.map do |word|
380
+ Violation.new(path, comment.line, "unknown_capitals", word)
381
+ end
382
+ end
383
+
384
+ def bold_violations(comment)
385
+ heading = comment.text[BOLD_HEADING, 2]
386
+ return [] unless heading && heading.split.size > MAX_HEADING_WORDS
387
+
388
+ [Violation.new(path, comment.line, "long_bold", "#{heading.split.size} words")]
389
+ end
390
+
391
+ def caps_violations(comment)
392
+ caps_words(comment).map do |_, word, replacement|
393
+ Violation.new(path, comment.line, "all_caps", "#{word} -> #{replacement}")
394
+ end
395
+ end
396
+
397
+ # A capitalised word that is also a constant defined in this file could be
398
+ # either prose or a reference, so it is reported rather than rewritten.
399
+ def constant_violations(comment)
400
+ words = mask(comment).scan(CAPS_WORD).uniq
401
+ words.select { |word| @constants.include?(word) && !ACRONYMS.include?(word) }.map do |word|
402
+ Violation.new(path, comment.line, "bare_constant", word)
403
+ end
404
+ end
405
+
406
+ def history_violations(comment)
407
+ masked = comment.text.gsub(/`[^`]*`/) { |span| " " * span.length }
408
+ pattern = HISTORY.find { |candidate| masked.match?(candidate) }
409
+ return [] unless pattern
410
+
411
+ [Violation.new(path, comment.line, "design_history", %("#{masked[pattern]}"))]
412
+ end
413
+
414
+ def length_violations(comment)
415
+ length = @lines[comment.line - 1].chomp.length
416
+ return [] if length <= MAX_LINE
417
+
418
+ [Violation.new(path, comment.line, "long_line", "#{length} characters")]
419
+ end
420
+
421
+ def caps_words(comment)
422
+ masked = mask(comment)
423
+ matches = []
424
+ masked.scan(CAPS_WORD) { matches << Regexp.last_match }
425
+ sql = sql_keywords(comment, matches.map { |match| match[0] })
426
+ found = matches.filter_map do |match|
427
+ word = match[0]
428
+ next if word != "A" && (keep_capitals?(word) || sql.include?(word) || unknown?(word))
429
+
430
+ [match.begin(0), word, recase(word, sentence_start?(comment, masked, match.begin(0)))]
431
+ end
432
+ (drop_stray_articles(found) + possessives(masked)).sort_by(&:first)
433
+ end
434
+
435
+ # The comment with every span that must keep its capitals blanked out. A
436
+ # backtick span can wrap across lines, so whether this line opens inside
437
+ # one comes from the lines above it.
438
+ def mask(comment)
439
+ text = comment.text
440
+ head = ""
441
+ if @open_spans[comment.line]
442
+ lead = text[/\A#+\s*/].length
443
+ close = text.index("`", lead)
444
+ cut = close ? close + 1 : text.length
445
+ head = text[0, lead] + (FILLER * (cut - lead))
446
+ text = text[cut..]
447
+ end
448
+ body = text.gsub(PROTECTED_SPANS) { |span| quoted_heading?(span) ? span : FILLER * span.length }
449
+ open = body.rindex("`")
450
+ body = body[0, open] + (FILLER * (body.length - open)) if open
451
+ head + body
452
+ end
453
+
454
+ # Which lines begin inside a backtick span opened earlier in the paragraph.
455
+ def open_spans
456
+ inside = false
457
+ @comments.each_with_object({}) do |comment, open|
458
+ above = @by_line[comment.line - 1]
459
+ continues = comment.full_line && above&.full_line && !above.text.match?(/\A#+\s*\z/)
460
+ inside = false unless continues
461
+ open[comment.line] = inside
462
+ inside ^= comment.text.count("`").odd?
463
+ end
464
+ end
465
+
466
+ # Capitalised SQL keywords to leave alone on this line. A keyword such as
467
+ # `FROM` or `SET` is only SQL when the rest of the line reads as SQL too.
468
+ def sql_keywords(comment, words)
469
+ bare = words.map { |word| word.sub(/'[A-Z]{1,2}\z/, "") } - ["A"]
470
+ strong = bare.select { |word| SQL_STRONG.include?(word) }
471
+ weak = bare.select { |word| SQL_WEAK.include?(word) }
472
+ database_file = path.match?(SQL_PATH)
473
+ return bare.to_set if reads_as_sql?(bare, strong, weak, database_file)
474
+ return (strong + weak).to_set if comment.text.match?(SQL_PHRASES)
475
+
476
+ database_file ? strong.to_set : Set.new
477
+ end
478
+
479
+ def reads_as_sql?(bare, strong, weak, database_file)
480
+ only_sql = bare.all? do |word|
481
+ [SQL_STRONG, SQL_WEAK, SQL_NEUTRAL, ACRONYMS].any? { |set| set.include?(word) }
482
+ end
483
+ return false unless only_sql
484
+
485
+ database_file ? (strong.any? || weak.any?) : (strong.any? && (strong + weak).size >= 2)
486
+ end
487
+
488
+ # `Finding`'s: the capital follows a code span, so `CAPS_WORD` never sees it.
489
+ def possessives(masked)
490
+ found = []
491
+ masked.scan(/(?<=#{FILLER})'S\b/) { found << [Regexp.last_match.begin(0) + 1, "S", "s"] }
492
+ found
493
+ end
494
+
495
+ # An "A" is only emphasis when a word beside it is being fixed too.
496
+ def drop_stray_articles(found)
497
+ found.each_with_index.reject do |(offset, word, _), index|
498
+ before = index.positive? ? found[index - 1] : nil
499
+ after = found[index + 1]
500
+ beside = (before && before[0] + before[1].length + 1 == offset && before[1] != "A") ||
501
+ (after && after[0] == offset + 2 && after[1] != "A")
502
+ word == "A" && !beside
503
+ end.map(&:first)
504
+ end
505
+
506
+ # A quoted run of capitals is another comment's heading, not a literal.
507
+ def quoted_heading?(span)
508
+ return false unless span.start_with?('"') && !span.match?(SQL_PHRASES)
509
+
510
+ capitals = span.scan(/\b[A-Z]{2,}\b/).size
511
+ capitals >= 3 || (capitals == 2 && !span.match?(/[a-z]/))
512
+ end
513
+
514
+ def recase(word, sentence_start)
515
+ bare, suffix = word.split("'", 2)
516
+ proper = proper_name(bare)
517
+ return [proper, suffix&.downcase].compact.join("'") if proper
518
+
519
+ sentence_start ? word.capitalize : word.downcase
520
+ end
521
+
522
+ # A shouted product or class name (`POSTGRESERA`) gets its real spelling
523
+ # back. An English word that is also a class name (`VALUE`) stays prose.
524
+ def proper_name(bare)
525
+ PROPER_NOUNS[bare] || (CommentStyle.class_names[bare] unless Dictionary.english?(bare))
526
+ end
527
+
528
+ # Neither English nor a known name, so lowercasing it would be a guess.
529
+ def unknown?(word)
530
+ bare = word.split("'", 2).first
531
+ !Dictionary.english?(word) && !proper_name(bare)
532
+ end
533
+
534
+ def keep_capitals?(word)
535
+ bare = word.sub(/'[A-Z]{1,2}\z/, "")
536
+ return false if PROPER_NOUNS.key?(bare)
537
+
538
+ ACRONYMS.include?(bare) || @constants.include?(bare) || bare.match?(/\A[IVX]+\z/)
539
+ end
540
+
541
+ def sentence_start?(comment, masked, offset)
542
+ before = masked[0, offset].sub(/\A#+\s*/, "")
543
+ return before.match?(/[.!?]["')\]]*\s+\z/) if before.match?(/[[:alnum:]#{FILLER}]/)
544
+ return true if before.match?(/\A\s*(?:[-*•]|─+|\d+[.)])\s+\z/)
545
+ return false if masked[0, offset].match?(/\A#\s{3,}/)
546
+
547
+ previous = comment.full_line ? @by_line[comment.line - 1] : nil
548
+ return true unless previous&.full_line
549
+
550
+ body = previous.text.sub(/\A#+\s*/, "").strip
551
+ body.empty? || body.match?(/[.!?]["')\]]*\z/)
552
+ end
553
+
554
+ def fix_line(line, number, wanted)
555
+ comment = @by_line[number]
556
+ return line if comment.nil? || comment.text.match?(DIRECTIVE)
557
+
558
+ text = wanted.include?("all_caps") ? fix_caps(comment) : comment.text
559
+ text = unbold(text) if wanted.include?("long_bold")
560
+ code = line.byteslice(0, comment.col)
561
+ return wrap(code, text) if wanted.include?("long_line") && comment.full_line
562
+
563
+ "#{code}#{text}#{"\n" if line.end_with?("\n")}"
564
+ end
565
+
566
+ def unbold(text)
567
+ heading = text[BOLD_HEADING, 2]
568
+ return text unless heading && heading.split.size > MAX_HEADING_WORDS
569
+
570
+ text.sub(BOLD_HEADING) { "#{Regexp.last_match(1)}#{heading}" }
571
+ end
572
+
573
+ def fix_caps(comment)
574
+ text = comment.text.dup
575
+ found = caps_words(comment)
576
+ found.reverse_each { |offset, word, replacement| text[offset, word.length] = replacement }
577
+ first, last = heading_run(comment, found)
578
+ return text unless first
579
+
580
+ text.insert(last, "**").insert(first, "**")
581
+ end
582
+
583
+ # An all-caps run that opens a paragraph and stops at a dash or a full stop
584
+ # is that paragraph's heading, so it becomes bold rather than plain text.
585
+ def heading_run(comment, found)
586
+ masked = mask(comment)
587
+ return unless found.size >= 2 && paragraph_start?(comment, masked, found.first[0])
588
+
589
+ run = found.slice_when { |(a, word, _), (b, _, _)| !masked[(a + word.length)...b].match?(GAP) }.first
590
+ finish = run.last[0] + run.last[1].length
591
+ finish += 2 if masked[finish, 2] == "'s"
592
+ return unless run.size >= 2 && masked[finish..].match?(HEADING_END)
593
+ return if masked[run.first[0]...finish].split.size > MAX_HEADING_WORDS || documents_method?(comment)
594
+
595
+ [run.first[0], finish]
596
+ end
597
+
598
+ # The first line above a `def` has to be a sentence about the method, so a
599
+ # heading there stays plain text.
600
+ def documents_method?(comment)
601
+ line = comment.line
602
+ line += 1 while @by_line[line + 1]&.full_line
603
+ @lines[line].to_s.match?(/\A\s*(?:\w+\s+)?def\s/)
604
+ end
605
+
606
+ def paragraph_start?(comment, masked, offset)
607
+ return false if masked[0, offset].sub(/\A#+\s*/, "").match?(/[[:alnum:]#{FILLER}]/)
608
+
609
+ above = comment.full_line ? @by_line[comment.line - 1] : nil
610
+ !above&.full_line || above.text.match?(/\A#+\s*\z/)
611
+ end
612
+
613
+ # Splits one over-long prose comment line in two. Lines with no safe
614
+ # break point (a long URL, an unbroken code span) are left for a person.
615
+ def wrap(indent, text)
616
+ whole = "#{indent}#{text}\n"
617
+ return whole if whole.chomp.length <= MAX_LINE
618
+
619
+ lead = text[/\A#+\s*/]
620
+ lead += " " if text.match?(/\A#+\s*@\w/)
621
+ cut = text[0, MAX_LINE - indent.length + 1].rindex(" ")
622
+ return whole if cut.nil? || cut <= lead.length || text[0, cut].count("`").odd?
623
+
624
+ "#{indent}#{text[0, cut].rstrip}\n#{wrap(indent, "#{lead}#{text[cut..].strip}")}"
625
+ end
626
+
627
+ def structure_violations
628
+ methods, types = structure
629
+ methods.flat_map { |m| method_violations(m) } + types.flat_map { |t| long_doc_violations(t) }
630
+ end
631
+
632
+ def structure
633
+ @structure ||= begin
634
+ walker = Walker.new
635
+ sexp = Ripper.sexp(@source)
636
+ walker.walk(sexp, Walker::Scope.new([], :public)) if sexp
637
+ [walker.methods, walker.types]
638
+ end
639
+ end
640
+
641
+ def doc_above(line)
642
+ block = []
643
+ cursor = line - 1
644
+ while (comment = @by_line[cursor]) && comment.full_line
645
+ block.unshift(comment.text)
646
+ cursor -= 1
647
+ end
648
+ block
649
+ end
650
+
651
+ def method_violations(method)
652
+ return [] unless method.visibility == :public
653
+ return [] unless CommentStyle.public_surface.include?(method.type_fqn.to_s.split("::").last)
654
+
655
+ doc = doc_above(method.line).join("\n")
656
+ if doc.empty?
657
+ return [] if SELF_EVIDENT.include?(method.name)
658
+
659
+ return [Violation.new(path, method.line, "undocumented_method", "##{method.name}")]
660
+ end
661
+ return [] if doc.match?(/@api private|@private|:nodoc:|\(see (?:`[^`]+`|#\w)[^)]*\)\W*\z/m)
662
+
663
+ tag_violations(method, doc)
664
+ end
665
+
666
+ def tag_violations(method, doc)
667
+ param_violations(method, doc) + summary_violations(method, doc) + outcome_violations(method, doc)
668
+ end
669
+
670
+ def param_violations(method, doc)
671
+ found = method.params.reject { |name| doc.match?(/@(?:param|option)\s+(?:\[[^\]\n]*\]\s*)?#{Regexp.escape(name)}\b/) }
672
+ .map { |name| violation(method, "missing_param", "lacks @param #{name}") }
673
+ block = method.block_param
674
+ if block && !doc.match?(/@yield|@param\s+(?:\[[^\]\n]*\]\s*)?#{Regexp.escape(block)}\b/)
675
+ found << violation(method, "missing_param", "lacks @yield for &#{block}")
676
+ end
677
+ found
678
+ end
679
+
680
+ def summary_violations(method, doc)
681
+ found = []
682
+ found << violation(method, "missing_summary", "") if tags_only?(doc) && method.name != "initialize"
683
+ if heading_lead?(doc)
684
+ found << violation(method, "missing_summary",
685
+ "opens with a bold heading: add a verb-first lead sentence above it")
686
+ end
687
+ found
688
+ end
689
+
690
+ def outcome_violations(method, doc)
691
+ found = []
692
+ returns = method.name == "initialize" || method.name.end_with?("=") || doc.include?("@return")
693
+ found << violation(method, "missing_return", "") unless returns
694
+ found << violation(method, "missing_raise", "") if method.raises && !doc.include?("@raise")
695
+ found
696
+ end
697
+
698
+ def tags_only?(doc)
699
+ lead_line(doc).start_with?("@")
700
+ end
701
+
702
+ # A bold heading is a paragraph title, not a sentence about the method.
703
+ def heading_lead?(doc)
704
+ lead_line(doc).start_with?("**")
705
+ end
706
+
707
+ def lead_line(doc)
708
+ doc.lines.map { |line| line.sub(/\A\s*#+\s?/, "").strip }.reject(&:empty?).first.to_s
709
+ end
710
+
711
+ def violation(method, category, detail)
712
+ Violation.new(path, method.line, category, "##{method.name} #{detail}".strip)
713
+ end
714
+
715
+ def long_doc_violations(type)
716
+ doc = doc_above(type.line)
717
+ return [] if doc.length <= LONG_CLASS_DOC || doc.any? { |l| l.match?(/\A#\s*##+ /) }
718
+
719
+ [Violation.new(path, type.line, "unstructured_class_doc", "#{type.fqn} (#{doc.length} lines)")]
720
+ end
721
+ end
722
+
723
+ # Walks a Ripper s-expression collecting method and type definitions
724
+ # along with the visibility each method ends up with.
725
+ class Walker
726
+ Scope = Struct.new(:nesting, :visibility)
727
+
728
+ attr_reader :methods, :types
729
+
730
+ def initialize
731
+ @methods = []
732
+ @types = []
733
+ @private_names = Set.new
734
+ end
735
+
736
+ # Visits one Ripper node, recording any method or type it defines.
737
+ def walk(node, scope)
738
+ return unless node.is_a?(Array)
739
+
740
+ case node[0]
741
+ when :class then open_type(node[1], node[3], scope)
742
+ when :module then open_type(node[1], node[2], scope)
743
+ when :sclass then walk_body(node[2], Scope.new(scope.nesting, :public))
744
+ when :def then record(node[1], node[2], node[3], scope.visibility, scope.nesting)
745
+ when :defs then record(node[3], node[4], node[5], :public, scope.nesting)
746
+ when :vcall, :var_ref then switch_visibility(node, scope)
747
+ when :command, :method_add_arg then visibility_call?(node, scope) || walk_children(node, scope)
748
+ else walk_children(node, scope)
749
+ end
750
+ end
751
+
752
+ private
753
+
754
+ def walk_children(node, scope)
755
+ node.each { |child| walk(child, scope) if child.is_a?(Array) }
756
+ end
757
+
758
+ # `private :name` can follow the definition, so it is applied once the
759
+ # whole body has been seen.
760
+ def walk_body(body, scope)
761
+ first = @methods.length
762
+ outer = @private_names
763
+ @private_names = Set.new
764
+ walk_children(body, scope) if body.is_a?(Array)
765
+ @methods[first..].each { |m| m.visibility = :private if @private_names.include?(m.name) }
766
+ @private_names = outer
767
+ end
768
+
769
+ def open_type(const, body, scope)
770
+ nesting = scope.nesting + [const_name(const)]
771
+ statements = body.is_a?(Array) && body[0] == :bodystmt ? Array(body[1]) : []
772
+ namespace = statements.all? { |s| !s.is_a?(Array) || %i[class module void_stmt].include?(s[0]) }
773
+ @types << TypeDef.new(nesting.join("::"), line_of(const), namespace)
774
+ walk_body(body, Scope.new(nesting, :public))
775
+ end
776
+
777
+ def const_name(node)
778
+ node.flatten.grep(String).join("::")
779
+ end
780
+
781
+ def line_of(node)
782
+ position = node.flatten.each_cons(2).find { |a, b| a.is_a?(Integer) && b.is_a?(Integer) }
783
+ position ? position[0] : 0
784
+ end
785
+
786
+ def switch_visibility(node, scope)
787
+ case node[1].is_a?(Array) ? node[1][1] : nil
788
+ when "private", "protected" then scope.visibility = :private
789
+ when "public", "module_function" then scope.visibility = :public
790
+ end
791
+ end
792
+
793
+ def visibility_call?(node, scope)
794
+ head = node[0] == :command ? node[1] : node.dig(1, 1)
795
+ return false unless head.is_a?(Array) && head[0] == :@ident
796
+ return false unless %w[private protected private_class_method].include?(head[1])
797
+
798
+ arguments = node[0] == :command ? node[2] : node.dig(2, 1)
799
+ Array(arguments.is_a?(Array) ? arguments[1] : nil).each do |argument|
800
+ next unless argument.is_a?(Array)
801
+
802
+ case argument[0]
803
+ when :def then record(argument[1], argument[2], argument[3], :private, scope.nesting)
804
+ when :defs then record(argument[3], argument[4], argument[5], :private, scope.nesting)
805
+ when :symbol_literal, :dyna_symbol
806
+ @private_names << argument.flatten.grep(String).last
807
+ else walk(argument, scope)
808
+ end
809
+ end
810
+ true
811
+ end
812
+
813
+ def record(name_node, params, body, visibility, nesting)
814
+ names, block = parameter_names(params)
815
+ @methods << MethodDef.new(name_node[1], name_node[2][0], names, visibility, raises?(body), block,
816
+ nesting.join("::"))
817
+ end
818
+
819
+ def parameter_names(params)
820
+ params = params[1] if params.is_a?(Array) && params[0] == :paren
821
+ return [[], nil] unless params.is_a?(Array) && params[0] == :params
822
+
823
+ _, required, optional, rest, post, keywords, keyrest, block = params
824
+ names = idents(required) + Array(optional).map { |pair| pair[0][1] } + splat_name(rest) +
825
+ idents(post) + Array(keywords).map { |pair| pair[0][1].chomp(":") } + splat_name(keyrest)
826
+ [names, splat_name(block).first]
827
+ end
828
+
829
+ def idents(list)
830
+ Array(list).select { |item| item.is_a?(Array) && item[0] == :@ident }.map { |item| item[1] }
831
+ end
832
+
833
+ # `rest`/`keyrest`/`block` are each either nil, an unnamed splat (`*`, `**`, `&`), or a
834
+ # one-element `[:@ident, name, pos]` node wrapping the name.
835
+ def splat_name(node)
836
+ node.is_a?(Array) && node[1].is_a?(Array) ? [node[1][1]] : []
837
+ end
838
+
839
+ def raises?(node)
840
+ return false unless node.is_a?(Array)
841
+ return false if %i[def defs].include?(node[0])
842
+
843
+ call = %i[command fcall vcall].include?(node[0]) && node[1].is_a?(Array) && node[1][1] == "raise"
844
+ call || node.any? { |child| raises?(child) }
845
+ end
846
+ end
847
+
848
+ # Lints a set of paths and renders the result.
849
+ class Run
850
+ # @param paths [Array<String>] files or directories
851
+ # @param only [Array<String>, nil] categories to keep
852
+ # @param baseline [Hash{String => Hash{String => Integer}}] the long blocks to tolerate
853
+ def initialize(paths, only: nil, baseline: Baseline.load)
854
+ @files = paths.flat_map { |p| File.directory?(p) ? ruby_files(p) : [p] }.sort.uniq
855
+ @only = only
856
+ @baseline = baseline
857
+ end
858
+
859
+ # Every block over `MAX_BLOCK` lines in these files, in the baseline's own shape.
860
+ #
861
+ # @return [Hash{String => Hash{String => Integer}}] file path to block key to length
862
+ def long_block_baseline
863
+ @files.each_with_object({}) do |path, held|
864
+ blocks = SourceFile.new(path).long_blocks
865
+ held[path.delete_prefix("./")] = blocks.to_h { |block| [block.key, block.lines] } if blocks.any?
866
+ end
867
+ end
868
+
869
+ # Files whose code differs from `ref`, comments and whitespace aside.
870
+ #
871
+ # A file `ref` does not hold (new since `ref`, or outside the repository) counts as
872
+ # changed: what cannot be compared is not vouched for.
873
+ #
874
+ # @param ref [String] revision to compare the working tree against
875
+ # @return [Array<String>] paths whose non-comment token stream changed
876
+ # @raise [ArgumentError] when `ref` names no commit
877
+ def code_changed_since(ref)
878
+ top = vcs_output("rev-parse", "--show-toplevel", chdir: Dir.pwd)&.strip
879
+ raise ArgumentError, "not inside a checkout" if top.nil? || top.empty?
880
+ unless vcs_output("rev-parse", "--verify", "--quiet", "#{ref}^{commit}", chdir: top)
881
+ raise ArgumentError, "unknown ref #{ref.inspect}"
882
+ end
883
+
884
+ @files.select do |path|
885
+ before = committed_source(ref, path, top)
886
+ before.nil? || CommentStyle.code_tokens(before) != CommentStyle.code_tokens(File.read(path))
887
+ end
888
+ end
889
+
890
+ # Every violation across the whole file set, computed once.
891
+ #
892
+ # @return [Array<Violation>]
893
+ def violations
894
+ @violations ||= begin
895
+ sources = @files.map { |path| SourceFile.new(path) }
896
+ found = sources.flat_map { |source| source.violations(only: @only, baseline: @baseline) } +
897
+ undocumented_types(sources)
898
+ @only ? found.select { |v| @only.include?(v.category) } : found
899
+ end
900
+ end
901
+
902
+ # Rewrites every file's fixable categories in place.
903
+ #
904
+ # @return [Integer] number of files rewritten
905
+ def fix!
906
+ wants_caps = @only.nil? || @only.include?("all_caps")
907
+ abort "fixing all_caps needs the word list at #{Dictionary::WORDS}" if wants_caps && !Dictionary.words
908
+
909
+ @files.count do |path|
910
+ original = File.read(path)
911
+ output = SourceFile.new(path, original).fixed(only: @only)
912
+ next false if output == original
913
+
914
+ File.write(path, output)
915
+ true
916
+ end
917
+ end
918
+
919
+ # Renders the summary tables `--report` prints.
920
+ #
921
+ # @param top [Integer] rows to show in the per-file table
922
+ # @return [String]
923
+ def report(top: 20)
924
+ by_file = violations.group_by(&:path)
925
+ [
926
+ "#{violations.size} violations in #{by_file.size} of #{@files.size} files\n",
927
+ "By category:", *category_rows,
928
+ "\nBy directory (top #{top}):", *directory_rows.first(top),
929
+ "\nWorst #{top} files:", *file_rows(by_file, top)
930
+ ].join("\n")
931
+ end
932
+
933
+ private
934
+
935
+ # `path`'s text at `ref`, or nil when the repository at `top` holds none there.
936
+ def committed_source(ref, path, top)
937
+ relative = Pathname.new(File.expand_path(path)).relative_path_from(Pathname.new(top)).to_s
938
+ return nil if relative == ".." || relative.start_with?("../")
939
+
940
+ vcs_output("show", "#{ref}:#{relative}", chdir: top)&.force_encoding(Encoding::UTF_8)
941
+ end
942
+
943
+ # What `git <args>` printed, or nil when it failed.
944
+ def vcs_output(*args, chdir:)
945
+ out = IO.popen(["git", *args], chdir: chdir, err: File::NULL, &:read)
946
+ $CHILD_STATUS.success? ? out : nil
947
+ end
948
+
949
+ # `.rb` files, plus extensionless scripts with a Ruby shebang.
950
+ def ruby_files(dir)
951
+ scripts = Dir[File.join(dir, "**", "*")].select do |path|
952
+ File.file?(path) && File.extname(path).empty? && File.open(path, &:gets).to_s.match?(/\A#!.*ruby/)
953
+ end
954
+ Dir[File.join(dir, "**", "*.rb")] + scripts
955
+ end
956
+
957
+ def category_rows
958
+ counts = violations.group_by(&:category).transform_values(&:size)
959
+ CATEGORIES.map do |name, description|
960
+ suffix = FIXABLE.include?(name) ? " [fixable]" : ""
961
+ " #{counts.fetch(name, 0).to_s.rjust(6)} #{name.ljust(24)} #{description}#{suffix}"
962
+ end
963
+ end
964
+
965
+ def directory_rows
966
+ violations.group_by { |v| v.path.split("/")[0, 3].join("/") }
967
+ .sort_by { |_, list| -list.size }
968
+ .map { |dir, list| " #{list.size.to_s.rjust(6)} #{dir}" }
969
+ end
970
+
971
+ def file_rows(by_file, top)
972
+ by_file.sort_by { |_, list| -list.size }.first(top).map do |file, list|
973
+ category, worst = list.group_by(&:category).max_by { |_, group| group.size }
974
+ " #{list.size.to_s.rjust(6)} #{file} (mostly #{category}: #{worst.size})"
975
+ end
976
+ end
977
+
978
+ # A type reopened across files counts as documented if any opening is.
979
+ def undocumented_types(sources)
980
+ openings = sources.flat_map { |s| s.types.reject(&:namespace_only).map { |t| [s, t] } }
981
+ openings.group_by { |_, type| type.fqn }.filter_map do |fqn, group|
982
+ next unless CommentStyle.public_surface.include?(fqn.to_s.split("::").last)
983
+ next if group.any? { |source, type| source.documented?(type) }
984
+
985
+ source, type = group.first
986
+ Violation.new(source.path, type.line, "undocumented_class", fqn)
987
+ end
988
+ end
989
+ end
990
+
991
+ # Builds the CLI's option parser, filling `options` as flags are seen.
992
+ def self.option_parser(options)
993
+ OptionParser.new do |opts|
994
+ opts.banner = "Usage: hecks check_comments [--report|--check|--fix] [--only a,b] PATH..."
995
+ opts.on("--report", "summary tables (default)") { options[:mode] = :report }
996
+ opts.on("--check", "list every violation, exit 1 if any") { options[:mode] = :check }
997
+ opts.on("--fix", "rewrite fixable categories: #{FIXABLE.join(', ')}") { options[:mode] = :fix }
998
+ opts.on("--write-baseline", "record every block over #{MAX_BLOCK} lines as tolerated") do
999
+ options[:mode] = :write_baseline
1000
+ end
1001
+ opts.on("--only LIST", Array, "limit to: #{CATEGORIES.keys.join(', ')}") { |list| options[:only] = list }
1002
+ opts.on("--code-unchanged REF", "exit 1 if any file's code (not comments) differs from REF") do |ref|
1003
+ options[:mode] = :code_unchanged
1004
+ options[:ref] = ref
1005
+ end
1006
+ opts.on("--json", "machine-readable output") { options[:json] = true }
1007
+ opts.on("--top N", Integer, "rows in the worst-files table") { |n| options[:top] = n }
1008
+ end
1009
+ end
1010
+
1011
+ # Prints `--report`, `--check`, or `--json` output for a finished run.
1012
+ def self.render(run, options)
1013
+ found = run.violations
1014
+ if options[:json]
1015
+ puts JSON.pretty_generate(found.map(&:to_h))
1016
+ elsif options[:mode] == :check
1017
+ found.each { |v| puts "#{v.path}:#{v.line}: [#{v.category}] #{v.message}" }
1018
+ else
1019
+ puts run.report(top: options[:top])
1020
+ end
1021
+ options[:mode] == :check && found.any? ? 1 : 0
1022
+ end
1023
+
1024
+ # The CLI entry point: parses `argv` and runs the requested mode.
1025
+ #
1026
+ # Paths after a `--` are never read as flags, and a path that does not exist is refused.
1027
+ def self.main(argv, root: Baseline::ROOT)
1028
+ options = { mode: :report, top: 20 }
1029
+ parser = option_parser(options)
1030
+ paths = parser.parse(argv)
1031
+ unknown = Array(options[:only]) - CATEGORIES.keys
1032
+ abort "unknown categories: #{unknown.join(', ')}" unless unknown.empty?
1033
+ abort parser.help if paths.empty?
1034
+ missing = paths.reject { |path| File.exist?(path) }
1035
+ abort "no such path: #{missing.join(', ')}" unless missing.empty?
1036
+
1037
+ baseline_path = Baseline.path_in(root)
1038
+ run = Run.new(paths, only: options[:only], baseline: Baseline.load(baseline_path))
1039
+ case options[:mode]
1040
+ when :fix then puts("rewrote #{run.fix!} files") || 0
1041
+ when :write_baseline then write_baseline(run, baseline_path)
1042
+ when :code_unchanged then report_code_changes(changed_since(run, options[:ref]))
1043
+ else render(run, options)
1044
+ end
1045
+ end
1046
+
1047
+ # Rewrites the baseline file from the blocks over `MAX_BLOCK` lines that `run` finds.
1048
+ def self.write_baseline(run, path = Baseline::PATH)
1049
+ held = run.long_block_baseline
1050
+ Baseline.dump(held, path)
1051
+ puts "recorded #{held.values.sum(&:size)} blocks in #{held.size} files at #{path}"
1052
+ 0
1053
+ end
1054
+
1055
+ # The files `run` finds changed since `ref`, refusing a ref that names no commit.
1056
+ def self.changed_since(run, ref)
1057
+ run.code_changed_since(ref)
1058
+ rescue ArgumentError => e
1059
+ abort e.message
1060
+ end
1061
+
1062
+ # Prints `--code-unchanged`'s verdict.
1063
+ def self.report_code_changes(changed)
1064
+ changed.each { |path| puts "#{path}: code changed, not just comments" }
1065
+ puts "code unchanged" if changed.empty?
1066
+ changed.empty? ? 0 : 1
1067
+ end
1068
+ end
1069
+ end
1070
+ end