principles 0.0.1

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 (278) hide show
  1. checksums.yaml +7 -0
  2. data/exe/principles +10 -0
  3. data/lib/principles/after_commit_hook_options.rb +44 -0
  4. data/lib/principles/cli.rb +149 -0
  5. data/lib/principles/command_line.rb +188 -0
  6. data/lib/principles/command_routes.rb +60 -0
  7. data/lib/principles/controllers/acknowledgement.rb +106 -0
  8. data/lib/principles/controllers/answer.rb +172 -0
  9. data/lib/principles/controllers/audit.rb +203 -0
  10. data/lib/principles/controllers/audit_completion.rb +113 -0
  11. data/lib/principles/controllers/command.rb +201 -0
  12. data/lib/principles/controllers/correction.rb +99 -0
  13. data/lib/principles/controllers/help.rb +107 -0
  14. data/lib/principles/controllers/inventory.rb +181 -0
  15. data/lib/principles/controllers/mapping.rb +56 -0
  16. data/lib/principles/controllers/member_entries.rb +149 -0
  17. data/lib/principles/controllers/members_a_commit_changed.rb +43 -0
  18. data/lib/principles/controllers/members_to_assess.rb +160 -0
  19. data/lib/principles/controllers/meta_principle_setup.rb +92 -0
  20. data/lib/principles/controllers/repoint.rb +68 -0
  21. data/lib/principles/controllers/running_tool.rb +41 -0
  22. data/lib/principles/data/audit_files.rb +96 -0
  23. data/lib/principles/data/bounded_file.rb +45 -0
  24. data/lib/principles/data/candidate_files_found.rb +105 -0
  25. data/lib/principles/data/command-steps.json +305 -0
  26. data/lib/principles/data/command_steps_file.rb +50 -0
  27. data/lib/principles/data/expected_type.rb +71 -0
  28. data/lib/principles/data/ignored_by_git.rb +54 -0
  29. data/lib/principles/data/json_text.rb +57 -0
  30. data/lib/principles/data/known_fields.rb +42 -0
  31. data/lib/principles/data/linked_path.rb +102 -0
  32. data/lib/principles/data/map_files.rb +104 -0
  33. data/lib/principles/data/principle_id.rb +32 -0
  34. data/lib/principles/data/principles_subfolder.rb +93 -0
  35. data/lib/principles/data/project_files.rb +109 -0
  36. data/lib/principles/data/repository.rb +165 -0
  37. data/lib/principles/data/running_version.rb +44 -0
  38. data/lib/principles/data/schema_check.rb +139 -0
  39. data/lib/principles/data/schemas/acknowledgement-answer.schema.json +25 -0
  40. data/lib/principles/data/schemas/answer-guide.schema.json +99 -0
  41. data/lib/principles/data/schemas/assessment-answer.schema.json +332 -0
  42. data/lib/principles/data/schemas/audit-record.schema.json +277 -0
  43. data/lib/principles/data/schemas/command-steps.schema.json +83 -0
  44. data/lib/principles/data/schemas/correction-answer-guide.schema.json +68 -0
  45. data/lib/principles/data/schemas/correction-answer.schema.json +306 -0
  46. data/lib/principles/data/schemas/member-map.schema.json +39 -0
  47. data/lib/principles/data/schemas/repointing-answer.schema.json +44 -0
  48. data/lib/principles/error.rb +102 -0
  49. data/lib/principles/invocation_options.rb +77 -0
  50. data/lib/principles/models/accepted_loading.rb +36 -0
  51. data/lib/principles/models/acknowledgement.rb +203 -0
  52. data/lib/principles/models/activation.rb +64 -0
  53. data/lib/principles/models/agent_loads.rb +64 -0
  54. data/lib/principles/models/answer_fields.rb +67 -0
  55. data/lib/principles/models/answer_place.rb +53 -0
  56. data/lib/principles/models/answer_text.rb +37 -0
  57. data/lib/principles/models/answered_time.rb +65 -0
  58. data/lib/principles/models/assessment_answer.rb +131 -0
  59. data/lib/principles/models/assessment_basis.rb +47 -0
  60. data/lib/principles/models/assessment_report.rb +62 -0
  61. data/lib/principles/models/audit.rb +200 -0
  62. data/lib/principles/models/audit_record.rb +249 -0
  63. data/lib/principles/models/audit_records.rb +103 -0
  64. data/lib/principles/models/audit_start.rb +96 -0
  65. data/lib/principles/models/audited_member_lines.rb +45 -0
  66. data/lib/principles/models/candidate_files.rb +66 -0
  67. data/lib/principles/models/candidate_inventory.rb +67 -0
  68. data/lib/principles/models/candidate_listing.rb +32 -0
  69. data/lib/principles/models/carried_assessments.rb +76 -0
  70. data/lib/principles/models/check_since_last_audit.rb +61 -0
  71. data/lib/principles/models/cited_files.rb +39 -0
  72. data/lib/principles/models/command_steps.rb +270 -0
  73. data/lib/principles/models/commits_brought_in.rb +27 -0
  74. data/lib/principles/models/commits_since_base.rb +32 -0
  75. data/lib/principles/models/correction_answer.rb +66 -0
  76. data/lib/principles/models/correction_basis.rb +70 -0
  77. data/lib/principles/models/dated_line.rb +29 -0
  78. data/lib/principles/models/decided_prior_findings.rb +106 -0
  79. data/lib/principles/models/declared_map_changes.rb +156 -0
  80. data/lib/principles/models/disposition.rb +233 -0
  81. data/lib/principles/models/entry_file.rb +65 -0
  82. data/lib/principles/models/entry_files.rb +46 -0
  83. data/lib/principles/models/evidence_item.rb +48 -0
  84. data/lib/principles/models/evidence_lines_since.rb +57 -0
  85. data/lib/principles/models/evidence_replacements.rb +99 -0
  86. data/lib/principles/models/finding_write_ups.rb +108 -0
  87. data/lib/principles/models/findings_left_open.rb +34 -0
  88. data/lib/principles/models/first_raised_claim.rb +128 -0
  89. data/lib/principles/models/frontmatter_fields.rb +128 -0
  90. data/lib/principles/models/hook_commands.rb +100 -0
  91. data/lib/principles/models/ignored_candidates.rb +46 -0
  92. data/lib/principles/models/json_digest.rb +35 -0
  93. data/lib/principles/models/json_path_query.rb +131 -0
  94. data/lib/principles/models/json_path_selectors.rb +64 -0
  95. data/lib/principles/models/json_path_string.rb +76 -0
  96. data/lib/principles/models/kept_prior_finding.rb +70 -0
  97. data/lib/principles/models/last_completed_revision.rb +70 -0
  98. data/lib/principles/models/line_span.rb +49 -0
  99. data/lib/principles/models/loaded_files.rb +60 -0
  100. data/lib/principles/models/loading_decision.rb +45 -0
  101. data/lib/principles/models/loading_facts.rb +45 -0
  102. data/lib/principles/models/loads_to_check.rb +93 -0
  103. data/lib/principles/models/locate_refusal.rb +23 -0
  104. data/lib/principles/models/located_text.rb +43 -0
  105. data/lib/principles/models/locator.rb +52 -0
  106. data/lib/principles/models/map_members.rb +171 -0
  107. data/lib/principles/models/markdown_file.rb +92 -0
  108. data/lib/principles/models/member.rb +171 -0
  109. data/lib/principles/models/member_assessments.rb +144 -0
  110. data/lib/principles/models/member_elsewhere.rb +74 -0
  111. data/lib/principles/models/member_file.rb +156 -0
  112. data/lib/principles/models/member_grounds.rb +32 -0
  113. data/lib/principles/models/member_history.rb +68 -0
  114. data/lib/principles/models/member_lines.rb +95 -0
  115. data/lib/principles/models/member_loads.rb +197 -0
  116. data/lib/principles/models/member_map.rb +219 -0
  117. data/lib/principles/models/member_map_history.rb +143 -0
  118. data/lib/principles/models/member_maps.rb +185 -0
  119. data/lib/principles/models/member_moves.rb +95 -0
  120. data/lib/principles/models/member_problems.rb +253 -0
  121. data/lib/principles/models/member_sighting.rb +176 -0
  122. data/lib/principles/models/member_snapshots.rb +36 -0
  123. data/lib/principles/models/member_state.rb +35 -0
  124. data/lib/principles/models/members_elsewhere.rb +46 -0
  125. data/lib/principles/models/members_sharing_lines.rb +116 -0
  126. data/lib/principles/models/meta_principle.rb +51 -0
  127. data/lib/principles/models/missing_files.rb +85 -0
  128. data/lib/principles/models/misspelt_member_id.rb +45 -0
  129. data/lib/principles/models/open_finding.rb +221 -0
  130. data/lib/principles/models/open_findings_elsewhere.rb +45 -0
  131. data/lib/principles/models/other_principles.rb +47 -0
  132. data/lib/principles/models/placement.rb +42 -0
  133. data/lib/principles/models/plain_text_file.rb +32 -0
  134. data/lib/principles/models/principles_folder_move.rb +59 -0
  135. data/lib/principles/models/principles_package.rb +53 -0
  136. data/lib/principles/models/prior_finding_decision.rb +162 -0
  137. data/lib/principles/models/prior_finding_outcomes.rb +60 -0
  138. data/lib/principles/models/prior_findings.rb +109 -0
  139. data/lib/principles/models/problem_report.rb +110 -0
  140. data/lib/principles/models/project.rb +214 -0
  141. data/lib/principles/models/project_history.rb +203 -0
  142. data/lib/principles/models/record_judged_against.rb +42 -0
  143. data/lib/principles/models/recorded_entry.rb +93 -0
  144. data/lib/principles/models/recorded_finding.rb +97 -0
  145. data/lib/principles/models/recorded_findings.rb +58 -0
  146. data/lib/principles/models/recorded_members.rb +149 -0
  147. data/lib/principles/models/repoint.rb +112 -0
  148. data/lib/principles/models/reported_check.rb +130 -0
  149. data/lib/principles/models/request_id.rb +70 -0
  150. data/lib/principles/models/runs_from.rb +36 -0
  151. data/lib/principles/models/skill_loads.rb +123 -0
  152. data/lib/principles/models/still_holds_restatement.rb +87 -0
  153. data/lib/principles/models/text_size.rb +26 -0
  154. data/lib/principles/models/uncommitted_outside_the_map.rb +30 -0
  155. data/lib/principles/models/waits_on.rb +58 -0
  156. data/lib/principles/models/write_up_opening.rb +93 -0
  157. data/lib/principles/models/write_up_pointer.rb +50 -0
  158. data/lib/principles/models/write_up_repoint.rb +39 -0
  159. data/lib/principles/prose_list.rb +23 -0
  160. data/lib/principles/supported_ruby.rb +93 -0
  161. data/lib/principles/version.rb +11 -0
  162. data/lib/principles/views/acknowledged_since_last_audit.rb +34 -0
  163. data/lib/principles/views/acknowledgement_result.rb +44 -0
  164. data/lib/principles/views/activation_in_hand.rb +47 -0
  165. data/lib/principles/views/assessment_request.rb +131 -0
  166. data/lib/principles/views/audit_changes.rb +79 -0
  167. data/lib/principles/views/audit_commit.rb +55 -0
  168. data/lib/principles/views/audit_result.rb +147 -0
  169. data/lib/principles/views/candidate_files_result.rb +95 -0
  170. data/lib/principles/views/check_result_template.rb +75 -0
  171. data/lib/principles/views/diff_since_last_audit.rb +29 -0
  172. data/lib/principles/views/findings_result.rb +45 -0
  173. data/lib/principles/views/help.rb +119 -0
  174. data/lib/principles/views/invocation.rb +66 -0
  175. data/lib/principles/views/landed_since_base.rb +27 -0
  176. data/lib/principles/views/lines_shared_with_member.rb +111 -0
  177. data/lib/principles/views/loads_to_check_result.rb +45 -0
  178. data/lib/principles/views/map_result.rb +169 -0
  179. data/lib/principles/views/member_change_since_baseline.rb +58 -0
  180. data/lib/principles/views/member_repairs.rb +82 -0
  181. data/lib/principles/views/members_a_commit_changed.rb +66 -0
  182. data/lib/principles/views/members_with_no_baseline.rb +56 -0
  183. data/lib/principles/views/meta_principle_passage.rb +23 -0
  184. data/lib/principles/views/meta_principle_result.rb +170 -0
  185. data/lib/principles/views/open_findings_elsewhere_result.rb +32 -0
  186. data/lib/principles/views/open_findings_line.rb +125 -0
  187. data/lib/principles/views/other_principles_unjudged_result.rb +32 -0
  188. data/lib/principles/views/outside_the_map_result.rb +31 -0
  189. data/lib/principles/views/package_since_last_audit_result.rb +34 -0
  190. data/lib/principles/views/principle_read_result.rb +86 -0
  191. data/lib/principles/views/principle_with_changed_members.rb +26 -0
  192. data/lib/principles/views/principles_with_no_completed_audit.rb +30 -0
  193. data/lib/principles/views/printed_principle.rb +45 -0
  194. data/lib/principles/views/prior_finding_decisions_result.rb +35 -0
  195. data/lib/principles/views/prior_findings_result.rb +80 -0
  196. data/lib/principles/views/problem_lines.rb +84 -0
  197. data/lib/principles/views/problem_result.rb +107 -0
  198. data/lib/principles/views/prompts/acknowledge-a-change/say-whether-the-change-keeps-the-meaning.md +12 -0
  199. data/lib/principles/views/prompts/answer-any-judgement-request/what-a-self-audit-cannot-show.md +6 -0
  200. data/lib/principles/views/prompts/assess-every-member/answer-guide.json +63 -0
  201. data/lib/principles/views/prompts/assess-every-member/assess-each-member-and-the-set.md +12 -0
  202. data/lib/principles/views/prompts/assess-every-member/check-roles-other-principles-give-these-lines.md +7 -0
  203. data/lib/principles/views/prompts/assess-every-member/decide-each-member-loading.md +17 -0
  204. data/lib/principles/views/prompts/assess-every-member/decide-each-prior-finding.md +38 -0
  205. data/lib/principles/views/prompts/assess-every-member/give-problem-evidence-at-still-holds.md +2 -0
  206. data/lib/principles/views/prompts/assess-every-member/give-the-evidence-a-prior-finding-lacks.md +7 -0
  207. data/lib/principles/views/prompts/assess-every-member/have-a-changed-check-reviewed-before-running-it.md +4 -0
  208. data/lib/principles/views/prompts/assess-every-member/have-a-new-check-reviewed-before-running-it.md +3 -0
  209. data/lib/principles/views/prompts/assess-every-member/have-hidden-characters-in-a-check-explained.md +4 -0
  210. data/lib/principles/views/prompts/assess-every-member/judge-acknowledged-edits-together.md +6 -0
  211. data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-against-the-other-principles.md +7 -0
  212. data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-under-the-release-since-the-last-audit.md +10 -0
  213. data/lib/principles/views/prompts/assess-every-member/judge-the-set-with-carried-assessments.md +10 -0
  214. data/lib/principles/views/prompts/assess-every-member/keep-the-last-review-basis-unless-you-give-a-new-one.md +1 -0
  215. data/lib/principles/views/prompts/assess-every-member/name-another-principles-open-finding-rather-than-raise-it.md +9 -0
  216. data/lib/principles/views/prompts/assess-every-member/read-whether-each-prior-finding-s-evidence-changed.md +12 -0
  217. data/lib/principles/views/prompts/assess-every-member/run-a-named-check-at-every-full-audit.md +2 -0
  218. data/lib/principles/views/prompts/assess-every-member/run-the-check-then-report-it.md +2 -0
  219. data/lib/principles/views/prompts/assess-every-member/run-the-reviewed-check-and-say-what-its-review-rested-on.md +6 -0
  220. data/lib/principles/views/prompts/assess-every-member/uncommitted-files-outside-the-map.md +4 -0
  221. data/lib/principles/views/prompts/assess-every-member/why-each-member-is-judged-again.md +24 -0
  222. data/lib/principles/views/prompts/dispose-of-each-finding/answer-guide.json +34 -0
  223. data/lib/principles/views/prompts/dispose-of-each-finding/fix-or-record-each-finding.md +24 -0
  224. data/lib/principles/views/prompts/find-a-principle-to-map/ask-the-owner-before-mapping.md +1 -0
  225. data/lib/principles/views/prompts/find-a-principle-to-map/choose-one-principle.md +5 -0
  226. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored-folders.md +2 -0
  227. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored.md +3 -0
  228. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-possibly-test-data.md +2 -0
  229. data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-untracked.md +2 -0
  230. data/lib/principles/views/prompts/find-a-principle-to-map/what-each-command-writes.md +4 -0
  231. data/lib/principles/views/prompts/find-a-principle-to-map/where-the-inventory-looks.md +10 -0
  232. data/lib/principles/views/prompts/learn-what-a-command-does/add-member.md +45 -0
  233. data/lib/principles/views/prompts/learn-what-a-command-does/change-activation.md +21 -0
  234. data/lib/principles/views/prompts/learn-what-a-command-does/change-description.md +2 -0
  235. data/lib/principles/views/prompts/learn-what-a-command-does/change-member-check.md +17 -0
  236. data/lib/principles/views/prompts/learn-what-a-command-does/change-member-role.md +9 -0
  237. data/lib/principles/views/prompts/learn-what-a-command-does/create-principle.md +6 -0
  238. data/lib/principles/views/prompts/learn-what-a-command-does/every-command-and-how-they-chain.md +28 -0
  239. data/lib/principles/views/prompts/learn-what-a-command-does/list-candidate-files.md +17 -0
  240. data/lib/principles/views/prompts/learn-what-a-command-does/list-member-problems.md +73 -0
  241. data/lib/principles/views/prompts/learn-what-a-command-does/list-open-findings.md +71 -0
  242. data/lib/principles/views/prompts/learn-what-a-command-does/list-principles.md +7 -0
  243. data/lib/principles/views/prompts/learn-what-a-command-does/move-member.md +11 -0
  244. data/lib/principles/views/prompts/learn-what-a-command-does/remove-member-check.md +10 -0
  245. data/lib/principles/views/prompts/learn-what-a-command-does/remove-member.md +6 -0
  246. data/lib/principles/views/prompts/learn-what-a-command-does/show-answer-check.md +16 -0
  247. data/lib/principles/views/prompts/learn-what-a-command-does/show-audit.md +20 -0
  248. data/lib/principles/views/prompts/learn-what-a-command-does/show-meta-principle-passage.md +52 -0
  249. data/lib/principles/views/prompts/learn-what-a-command-does/show-principle.md +86 -0
  250. data/lib/principles/views/prompts/learn-what-a-command-does/start-audit.md +68 -0
  251. data/lib/principles/views/prompts/learn-what-a-command-does/submit-answer.md +56 -0
  252. data/lib/principles/views/prompts/repoint-write-ups/point-each-moved-write-up-at-its-new-place.md +10 -0
  253. data/lib/principles/views/prompts/report-after-a-commit/it-refused-after-the-commits-touched-the-maps.md +2 -0
  254. data/lib/principles/views/prompts/report-after-a-commit/others-are-still-listed.md +1 -0
  255. data/lib/principles/views/prompts/report-after-a-commit/the-commits-changed-these-members.md +2 -0
  256. data/lib/principles/views/prompts/report-after-a-commit/this-command-shows-each-one.md +1 -0
  257. data/lib/principles/views/prompts/report-the-completed-audit/commit-only-what-the-audit-changed.md +2 -0
  258. data/lib/principles/views/prompts/report-the-completed-audit/replace-the-default-commit-message.md +4 -0
  259. data/lib/principles/views/prompts/report-the-completed-audit/what-completion-means.md +2 -0
  260. data/lib/principles/views/prompts/set-principles-up/entry-file-passage.md +3 -0
  261. data/lib/principles/views/prompts/set-principles-up/name-the-step-each-member-lacks.md +15 -0
  262. data/lib/principles/views/prompts/set-principles-up/place-the-passage-with-the-user.md +31 -0
  263. data/lib/principles/views/prompts/set-principles-up/set-up-first.md +2 -0
  264. data/lib/principles/views/prompts/set-principles-up/what-the-passage-presumes.md +4 -0
  265. data/lib/principles/views/prompts/start-a-session/act-on-the-open-findings.md +5 -0
  266. data/lib/principles/views/prompts/start-a-session/it-returns-after-each-clear-or-compaction-too.md +1 -0
  267. data/lib/principles/views/prompts/start-a-session/it-returns-at-each-session-start.md +1 -0
  268. data/lib/principles/views/prompts/start-a-session/raise-them-at-the-next-pause.md +1 -0
  269. data/lib/principles/views/prompts/start-a-session/raise-them-before-the-first-task.md +1 -0
  270. data/lib/principles/views/prompts.rb +71 -0
  271. data/lib/principles/views/repoint_result.rb +80 -0
  272. data/lib/principles/views/request_part.rb +26 -0
  273. data/lib/principles/views/request_parts.rb +26 -0
  274. data/lib/principles/views/shell_word.rb +29 -0
  275. data/lib/principles/views/visible_text.rb +26 -0
  276. data/lib/principles/views/workflow_diagram.rb +77 -0
  277. data/lib/principles.rb +5 -0
  278. metadata +326 -0
@@ -0,0 +1,93 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
6
+ module Models
7
+ # The opening of a write-up's region in a file's text: what list-open-findings prints as a finding's
8
+ # write_up_opening, and show-answer-check previews (#300, #411). This tool does not judge what it says.
9
+ class WriteUpOpening
10
+ # The longest opening printed whole; a longer one is cut and marked truncated.
11
+ LIMIT = 200
12
+ # A line that starts a new block, a list item or a heading, rather than continuing the paragraph before it.
13
+ NEW_BLOCK = /\A(([-*+]|\d+[.)])\s|#)/
14
+ # What a heading line starts with, a block of its own line.
15
+ HEADING = "#"
16
+ # The end of a sentence: its mark and any closing quotes or brackets, before a space or the end.
17
+ SENTENCE_END = /[.!?]["')\]]*(?=\s|\z)/
18
+
19
+ # One line of the file, as the opening reads it.
20
+ class Line
21
+ #: (String) -> void
22
+ def initialize(text)
23
+ @text = text
24
+ freeze
25
+ end
26
+
27
+ # The line without its indentation or line break.
28
+ #: () -> String
29
+ def text = @text.strip
30
+
31
+ #: () -> bool
32
+ def blank? = text.empty?
33
+
34
+ #: () -> bool
35
+ def heading? = text.start_with?(HEADING)
36
+
37
+ # Whether the line continues the paragraph before it: it is not blank and starts no new block. It keeps its
38
+ # line break, which NEW_BLOCK's \s may match, so a bare "-" line still starts a list item.
39
+ #: () -> bool
40
+ def continues? = !blank? && !@text.lstrip.match?(NEW_BLOCK)
41
+ end
42
+
43
+ # lines are the file's lines; the region runs from index first up to, not including, index last.
44
+ #: (lines: Array[String], first: Integer, last: Integer) -> void
45
+ def initialize(lines:, first:, last:)
46
+ @lines = lines.map { |each| Line.new(each) } #: Array[Line]
47
+ @first = first
48
+ @last = last
49
+ freeze
50
+ end
51
+
52
+ # The opening from the region's first non-blank line, with that line's number. nil when every line in the
53
+ # region is blank.
54
+ #: () -> Hash[String, untyped]?
55
+ def to_h
56
+ index = (@first...@last).find { |each| !line(each).blank? } or return nil
57
+ text = opening_text(index)
58
+ { "text" => text[0, LIMIT], "line" => index + 1 }.merge(text.length > LIMIT ? { "truncated" => true } : {})
59
+ end
60
+
61
+ private
62
+
63
+ # A heading line whole, since a heading is not a sentence and "F4." may number it (#739). Otherwise the
64
+ # first sentence: hard-wrapped lines joined with single spaces up to the sentence's end, or else to its
65
+ # paragraph's end, a blank line, the next list item or heading, or the file's end. It runs past the region's
66
+ # end, since a starting_at alone locates one line (#697).
67
+ #: (Integer) -> String
68
+ def opening_text(index)
69
+ return line(index).text if line(index).heading?
70
+
71
+ sentence(paragraph_from(index).join(" "))
72
+ end
73
+
74
+ # The trimmed lines of the paragraph that starts at index, to its end in the file.
75
+ #: (Integer) -> Array[String]
76
+ def paragraph_from(index)
77
+ rest = @lines.drop(index + 1).take_while(&:continues?)
78
+ [line(index), *rest].map(&:text)
79
+ end
80
+
81
+ # text up to the end of its first sentence, or all of it when none ends.
82
+ #: (String) -> String
83
+ def sentence(text)
84
+ found = SENTENCE_END.match(text) or return text
85
+ text[0, found.end(0)].to_s
86
+ end
87
+
88
+ # The line at index, or a blank one past the file's end.
89
+ #: (Integer) -> Line
90
+ def line(index) = @lines[index] || Line.new("")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,50 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "locator"
5
+
6
+ module Principles
7
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
8
+ module Models
9
+ # Where a finding is written up, as its record's recorded_at keeps it: a path, with a heading or a starting_at
10
+ # inside it (#768). It holds that place as the Locator the hash it was read from gives, which keeps its keys, nulls
11
+ # and order, since the record's bytes and each digest over them depend on it; two pointers are the same when
12
+ # those hashes hold the same keys and values, in any order.
13
+ class WriteUpPointer
14
+ #: (Hash[String, untyped]) -> void
15
+ def initialize(fields)
16
+ @locator = Locator.new(fields) #: Locator
17
+ freeze
18
+ end
19
+
20
+ # The file the write-up is in.
21
+ #: () -> String
22
+ def path = @locator.path
23
+
24
+ # Whether it names a starting_at inside its file.
25
+ #: () -> bool
26
+ def starting_at? = !@locator.starting_at.nil?
27
+
28
+ # The same heading or starting_at in the file at path, as a repoint checks git's match for a missing file (#722).
29
+ #: (String) -> WriteUpPointer
30
+ def in_file(path) = WriteUpPointer.new(to_h.merge("path" => path))
31
+
32
+ # The opening of its write-up in project's file as it stands now, as WriteUpOpening finds it, or nil when there
33
+ # is none; refused when the pointer no longer resolves (#300).
34
+ #: (Project) -> Hash[String, untyped]?
35
+ def opening_in(project) = project.member_file(path).write_up_opening(@locator)
36
+
37
+ # The pointer as its record's file holds it.
38
+ #: () -> Hash[String, untyped]
39
+ def to_h = @locator.to_h
40
+
41
+ #: (untyped) -> bool
42
+ def ==(other) = other.is_a?(WriteUpPointer) && other.to_h.eql?(to_h)
43
+
44
+ alias eql? ==
45
+
46
+ #: () -> Integer
47
+ def hash = to_h.hash
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,39 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "answer_fields"
5
+ require_relative "recorded_finding"
6
+ require_relative "write_up_pointer"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # One finding's write-up pointer moved by a repoint (D59): the finding it names, the recorded_at the agent
12
+ # found the write-up at, and why. It moves a finding there, keeping where it pointed before, when and why.
13
+ class WriteUpRepoint
14
+ # The repoint one entry of an answer's repoints gives, to recorded_at, its pointer already located, refused by
15
+ # fields without a reason, since the record keeps why each pointer moved (#760), naming its place in the answer
16
+ # (#787).
17
+ #: (Hash[String, untyped], WriteUpPointer, AnswerFields, AnswerPlace) -> WriteUpRepoint
18
+ def self.answered(repoint, recorded_at, fields, place)
19
+ new(finding_id: repoint.fetch("finding_id"), recorded_at: recorded_at,
20
+ reason: fields.text_at(repoint["reason"], "repoint.reason", place.child("reason")))
21
+ end
22
+
23
+ #: String
24
+ attr_reader :finding_id
25
+
26
+ #: (finding_id: String, recorded_at: WriteUpPointer, reason: String) -> void
27
+ def initialize(finding_id:, recorded_at:, reason:)
28
+ @finding_id = finding_id
29
+ @recorded_at = recorded_at
30
+ @reason = reason
31
+ freeze
32
+ end
33
+
34
+ # finding moved to this repoint's recorded_at, for its reason, at at.
35
+ #: (RecordedFinding, at: String) -> RecordedFinding
36
+ def applied_to(finding, at:) = finding.moved_to(@recorded_at, at: at, reason: @reason)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # Items as one would say them in a sentence: "a", "a and b", or "a, b and c", or with "or" for alternatives. It
6
+ # is the one owner of that list, which several results and refusals print (#445, #731, #737), so every layer may
7
+ # load it, as it may error.rb.
8
+ class ProseList
9
+ #: (Array[String], ?conjunction: String) -> void
10
+ def initialize(items, conjunction: "and")
11
+ @items = items
12
+ @conjunction = conjunction
13
+ freeze
14
+ end
15
+
16
+ #: () -> String
17
+ def to_s
18
+ return @items.join if @items.length < 2
19
+
20
+ "#{@items.take(@items.length - 1).join(", ")} #{@conjunction} #{@items.last}"
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,93 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The Ruby this tool runs on. exe/principles loads this before anything else, so an agent on a Ruby too old to
6
+ # parse lib/ still gets this tool's JSON refusal, not a SyntaxError. So this file stays in syntax any Ruby 2
7
+ # parses, and requires only rbconfig: a GEM_HOME left by another Ruby can hand an old Ruby a json it cannot
8
+ # parse. When the Ruby running is unsupported, it looks for a supported one that rbenv installed, since
9
+ # rbenv otherwise picks Ruby by the directory this tool runs in, and runs this tool on that instead (D31).
10
+ # Its bounds equal the gemspec's required_ruby_version; a spec checks it.
11
+ class SupportedRuby
12
+ MINIMUM = [3, 4, 2].freeze #: Array[Integer]
13
+ BELOW = [3, 5].freeze #: Array[Integer]
14
+ # Set on the process this tool hands itself to, so a Ruby that is not what its folder name says cannot
15
+ # hand it on again.
16
+ HANDED_OFF = "PRINCIPLES_HANDED_TO_RUBY"
17
+
18
+ #: (String, Hash[String, String]) -> void
19
+ def initialize(running, environment)
20
+ @running = running
21
+ @environment = environment
22
+ end
23
+
24
+ # Returns when this Ruby is supported; otherwise runs executable on a supported rbenv Ruby, or prints the
25
+ # refusal and exits 1.
26
+ #: (String, Array[String]) -> void
27
+ def run_here_or_on_another(executable, arguments)
28
+ step = next_step(executable, arguments)
29
+ return if step.key?("run_here")
30
+
31
+ if step.key?("hand_to")
32
+ process = Kernel #: as untyped
33
+ process.exec(*step.fetch("hand_to"))
34
+ end
35
+ warn step.fetch("refuse")
36
+ exit 1
37
+ end
38
+
39
+ # What this tool does on this Ruby, decided without acting: run_here; hand_to, the arguments for Kernel.exec
40
+ # that run executable on a supported rbenv Ruby; or refuse, the refusal to print before exiting 1.
41
+ #: (String, Array[String]) -> Hash[String, untyped]
42
+ def next_step(executable, arguments)
43
+ return { "run_here" => true } if supported?(@running)
44
+
45
+ other = rbenv_ruby
46
+ if other && !@environment[HANDED_OFF]
47
+ return { "hand_to" => [{ HANDED_OFF => other }, other, executable, *arguments] }
48
+ end
49
+
50
+ { "refuse" => refusal }
51
+ end
52
+
53
+ private
54
+
55
+ #: (String) -> bool
56
+ def supported?(version)
57
+ numbers = version.split(".").map(&:to_i)
58
+ (numbers <=> MINIMUM) >= 0 && (numbers <=> BELOW).negative?
59
+ end
60
+
61
+ # The newest supported Ruby among rbenv's installed versions, if any.
62
+ #: () -> String?
63
+ def rbenv_ruby
64
+ root = @environment["RBENV_ROOT"] || File.join(@environment["HOME"].to_s, ".rbenv")
65
+ found = Dir.glob(File.join(root, "versions", "*", "bin", "ruby")).select do |ruby|
66
+ supported?(version_of(ruby)) && File.executable?(ruby)
67
+ end
68
+ found.max_by { |ruby| version_of(ruby).split(".").map(&:to_i) }
69
+ end
70
+
71
+ # The version rbenv installed ruby as: the name of its folder under versions/.
72
+ #: (String) -> String
73
+ def version_of(ruby)
74
+ # File.dirname's level argument needs Ruby 3.1, and this runs on older Rubies.
75
+ File.basename(File.dirname(File.dirname(ruby))) # rubocop:disable Style/NestedFileDirname
76
+ end
77
+
78
+ # This tool's refusal as JSON, written by hand because json may not load here.
79
+ #: () -> String
80
+ def refusal
81
+ require "rbconfig"
82
+ needed = ">= #{MINIMUM.join(".")} and < #{BELOW.join(".")}"
83
+ fields = [%w[status refused], %w[code unsupported_ruby],
84
+ ["message", "This tool needs Ruby #{needed}; this is Ruby #{@running} at #{RbConfig.ruby}, and rbenv " \
85
+ "has no Ruby it supports"],
86
+ ["repair", "In Claude Code with the principles plugin, ask the user to run /principles:setup-ruby, " \
87
+ "which installs one with rbenv and ruby-build. Otherwise install a Ruby #{needed} with " \
88
+ "rbenv; it finds any rbenv Ruby it supports, whatever directory it runs in."]]
89
+ quoted = fields.map { |pair| pair.map { |text| "\"#{text.gsub(/[\\"]/) { |c| "\\#{c}" }}\"" }.join(":") }
90
+ "{#{quoted.join(",")}}"
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,11 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The next production release, as major.minor.patch. A production build is versioned with this alone.
6
+ RELEASE = "0.0.1"
7
+ # The version a built gem prints. `rake build[production]` stamps RELEASE here, in the copy of lib/ it
8
+ # packages, and `rake build[development]` stamps RELEASE.g<short sha>. A checkout leaves it nil, and
9
+ # Data::RunningVersion reads the commit this tool runs from instead.
10
+ BUILT_VERSION = "0.0.1" #: String?
11
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "prompts"
5
+
6
+ module Principles
7
+ # What this tool prints, from plain values a controller hands it. views/CLAUDE.md owns the boundary.
8
+ module Views
9
+ # The edits to one member acknowledged since the last full audit, as the next full audit's request shows them
10
+ # beside the member, so edits judged one at a time are judged together against the last audited text (D33).
11
+ class AcknowledgedSinceLastAudit
12
+ # last_audited_commit is the last full audit's base commit; acknowledgements are the record's own, oldest
13
+ # first; member_lines its own lines then and now, as a one-member audit shows them. The command that reads every
14
+ # edit since is the member's read_the_change, which every member judged again carries (#771).
15
+ #: (last_audited_commit: String, member_lines: Hash[String, untyped],
16
+ #| acknowledgements: Array[Hash[String, untyped]]) -> void
17
+ def initialize(last_audited_commit:, member_lines:, acknowledgements:)
18
+ @last_audited_commit = last_audited_commit
19
+ @member_lines = member_lines
20
+ @acknowledgements = acknowledgements
21
+ freeze
22
+ end
23
+
24
+ # The fields as the request prints them.
25
+ #: () -> Hash[String, untyped]
26
+ def to_h
27
+ { "last_audited_commit" => @last_audited_commit,
28
+ "acknowledgements" => @acknowledgements.map { |each| each.slice("commit", "reason", "acknowledged_at") },
29
+ "member_lines" => @member_lines,
30
+ "why" => Prompts.text("assess-every-member/judge-acknowledged-edits-together") }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "invocation"
5
+ require_relative "member_change_since_baseline"
6
+ require_relative "prompts"
7
+
8
+ module Principles
9
+ # What this tool prints, from plain values a controller hands it. views/CLAUDE.md owns the boundary.
10
+ module Views
11
+ # A one-member audit's request and its result (D33).
12
+ class AcknowledgementResult
13
+ #: (Invocation) -> void
14
+ def initialize(invocation)
15
+ @invocation = invocation
16
+ freeze
17
+ end
18
+
19
+ # change is the member's change since its baseline; open_findings, the record's findings on the member. What
20
+ # the agent judges is under context, the question last, as in a full audit's request (D60).
21
+ #: (request_id: String, principle_id: String, change: MemberChangeSinceBaseline,
22
+ #| open_findings: Hash[String, Array[String]]) -> Hash[String, untyped]
23
+ def request(request_id:, principle_id:, change:, open_findings:)
24
+ { "status" => "agent_judgement_required", "request_id" => request_id, "phase" => "acknowledgement",
25
+ "context" => { "principle_id" => principle_id, **change.to_h, **open_findings,
26
+ "question" => Prompts.text("acknowledge-a-change/say-whether-the-change-keeps-the-meaning") },
27
+ "response_template" => { "request_id" => request_id,
28
+ "basis" => { "base_commit" => change.base_commit, "member_id" => change.member_id },
29
+ "meaning_kept" => true, "reason" => "", "old_line" => "", "new_line" => "" },
30
+ "answer_with" => @invocation.submit_answer(request_id) }
31
+ end
32
+
33
+ # The acknowledgement was accepted and file holds it.
34
+ #: (principle_id: String, member_id: String, file: String) -> Hash[String, untyped]
35
+ def acknowledged(principle_id:, member_id:, file:)
36
+ { "status" => "acknowledged", "principle_id" => principle_id, "member_id" => member_id,
37
+ "files_to_commit" => [file],
38
+ "next_action" => "Commit #{file}. #{member_id} reads as judged until it is edited again.\n" \
39
+ "After committing, this no longer lists it: " \
40
+ "#{@invocation.in_project("list-member-problems", principle_id, usage: "--short")}" }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # What this tool prints, from plain values a controller hands it. views/CLAUDE.md owns the boundary.
6
+ module Views
7
+ # How this tool says when a principle's work is in hand, from the activation its map stores (D38): the phrase
8
+ # change-activation's question carries, and that later results will share.
9
+ class ActivationInHand
10
+ # How the question words each value of Models::Activation#full_audit_lists.
11
+ LISTED = {
12
+ "session_start_members" => "The next full audit lists each member that loads at every session start " \
13
+ "anyway, for you to weigh against it.",
14
+ "none" => "Its work is in hand in every session, so a full audit lists no member to weigh against it.",
15
+ "not_built" => "A full audit's listing of members that load outside it is not built yet."
16
+ }.freeze #: Hash[String, String]
17
+
18
+ #: (Hash[String, untyped]) -> void
19
+ def initialize(activation)
20
+ @activation = activation
21
+ freeze
22
+ end
23
+
24
+ # What change-activation's question says it recorded for principle_id, and, from lists, what the next full audit
25
+ # lists against it.
26
+ #: (String, String) -> String
27
+ def recorded(principle_id, lists)
28
+ "Recorded that #{principle_id}'s work is in hand #{phrase}. #{LISTED.fetch(lists)}"
29
+ end
30
+
31
+ # What change-activation's question says when the map already held this activation, so nothing changed.
32
+ #: (String) -> String
33
+ def unchanged(principle_id)
34
+ "#{principle_id}'s work was already in hand #{phrase}; nothing was written."
35
+ end
36
+
37
+ #: () -> String
38
+ def phrase
39
+ case @activation.fetch("when")
40
+ when "files" then "when files matching #{@activation.fetch("files").join(", ")} are read or changed"
41
+ when "invoked" then "when its work is invoked, as a skill or a runbook is"
42
+ else "for all work, in every session"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,131 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "check_result_template"
5
+ require_relative "prompts"
6
+ require_relative "request_parts"
7
+
8
+ module Principles
9
+ # What this tool prints, from plain values a controller hands it. views/CLAUDE.md owns the boundary.
10
+ module Views
11
+ # One assessment request start-audit prints: the principle and its members as the agent assesses them, and
12
+ # the answer's template. Each member is an entry of id, role, first_line, last_line, the evidence that locates
13
+ # it and, for a member with a check, the check whose result the template asks for (D30). Its hash travels in
14
+ # the request id, not here.
15
+ class AssessmentRequest
16
+ # The question every member's assessment answers, which a completed record digests with the description (D49).
17
+ #: () -> String
18
+ def self.question = Prompts.text("assess-every-member/assess-each-member-and-the-set")
19
+
20
+ # basis is what the answer hands back unchanged: the base commit and the last completed record's revision.
21
+ # parts are what the agent reads beside the members before the question, such as the files outside the map
22
+ # that differ from the base commit (#409), each with any slots it adds to the template (D60).
23
+ #: (principle_id: String, description: untyped, members: Array[Hash[String, untyped]],
24
+ #| basis: Hash[String, untyped], ?parts: Array[RequestPart]) -> void
25
+ def initialize(principle_id:, description:, members:, basis:, parts: [])
26
+ @principle_id = principle_id
27
+ @description = description
28
+ @members = members
29
+ @basis = basis
30
+ @parts = RequestParts.new(parts) #: RequestParts
31
+ freeze
32
+ end
33
+
34
+ # What the agent assesses, its other parts, the question and a guide to the answer's shapes.
35
+ #: () -> Hash[String, untyped]
36
+ def context
37
+ listed = { "principle_id" => @principle_id, "description" => @description,
38
+ "members" => @members.map do |member|
39
+ member.slice("id", "role", "first_line", "last_line", "check", "check_since_last_audit",
40
+ "check_hidden_characters", "acknowledged_since_last_audit", "carried_forward",
41
+ "judge_because", "moved_from", "read_the_change", "mapped_elsewhere")
42
+ end }
43
+ @parts.shown_after(listed).merge(asked)
44
+ end
45
+
46
+ # The answer to fill in: one assessment per member, and the set's.
47
+ #: () -> Hash[String, untyped]
48
+ def template
49
+ assessed = {
50
+ "basis" => @basis,
51
+ "member_assessments" => @members.map { |member| member_assessment_template(member) },
52
+ "collective_assessment" => "How the members reinforce the principle, and any contradiction or " \
53
+ "missing connection.",
54
+ "recent_decision" => { "unavailable_reason" => "Or use the assessed shape in answer_guide.recent_decision." },
55
+ "findings" => []
56
+ }
57
+ @parts.slotted_after(assessed)
58
+ end
59
+
60
+ private
61
+
62
+ # What the request asks of what the agent read: the question, why members are judged afresh, how to treat the
63
+ # members it carries forward and the roles other principles give their lines, and the guide to the answer's
64
+ # shapes.
65
+ #: () -> Hash[String, untyped]
66
+ def asked
67
+ { "question" => AssessmentRequest.question }.merge(judge_because_instruction, carried_forward_instruction,
68
+ mapped_elsewhere_instruction)
69
+ .merge("answer_guide" => answer_guide)
70
+ end
71
+
72
+ # Why each member with judge_because is judged afresh, explaining only the values the members carry, so the
73
+ # agent meets each value's meaning where it meets the value (D61); nothing when no member gives one.
74
+ #: () -> Hash[String, untyped]
75
+ def judge_because_instruction
76
+ reasons = @members.filter_map { |member| member["judge_because"] }.uniq
77
+ return {} if reasons.empty?
78
+
79
+ intro, *meanings = Prompts.text("assess-every-member/why-each-member-is-judged-again").split("\n\n")
80
+ shown = meanings.select { |meaning| reasons.any? { |reason| meaning.start_with?("`#{reason}`:") } }
81
+ { "judge_because_instruction" => [intro, *shown].join("\n\n") }
82
+ end
83
+
84
+ # How to judge the roles other principles give a member's lines, printed only when a member shares lines with
85
+ # another principle's (#719, D61), and, when an entry names findings open on its member, where to read how to
86
+ # treat them, which prints apart, before the question (#808).
87
+ #: () -> Hash[String, untyped]
88
+ def mapped_elsewhere_instruction
89
+ entries = @members.flat_map { |member| member["mapped_elsewhere"] || [] }
90
+ return {} if entries.empty?
91
+
92
+ roles, open_findings = Prompts.text("assess-every-member/check-roles-other-principles-give-these-lines")
93
+ .split("\n\n")
94
+ shown = entries.any? { |entry| entry["open_finding_ids"] } ? [roles, open_findings] : [roles]
95
+ { "mapped_elsewhere_instruction" => shown.join("\n\n") }
96
+ end
97
+
98
+ # The answer guide, showing the check result's shapes only when a member has a check, so an audit
99
+ # with no checks costs no more context than before (D30).
100
+ #: () -> Hash[String, untyped]
101
+ def answer_guide
102
+ guide = Prompts.data("assess-every-member/answer-guide")
103
+ @members.any? { |member| member["check"] } ? guide : guide.except("check_result")
104
+ end
105
+
106
+ # How to answer when the request carries some members' last assessments forward; nothing when it carries none.
107
+ #: () -> Hash[String, untyped]
108
+ def carried_forward_instruction
109
+ return {} unless @members.any? { |member| member["carried_forward"] }
110
+
111
+ { "carried_forward_instruction" => Prompts.text("assess-every-member/judge-the-set-with-carried-assessments") }
112
+ end
113
+
114
+ # A member's template entry: a fresh assessment to fill in, or, for a member whose last assessment is carried
115
+ # forward, the marker that keeps it (D49); either with its check's result when it has a check.
116
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
117
+ def member_assessment_template(member)
118
+ assessed = if member["carried_forward"]
119
+ { "member_id" => member.fetch("id"), "carried_forward" => true }
120
+ else
121
+ { "member_id" => member.fetch("id"), "assessment" => "Your assessment, including its context cost",
122
+ "delivery" => "not_observed", "evidence" => member.fetch("evidence") }
123
+ end
124
+ check = member["check"] or return assessed
125
+ template = CheckResultTemplate.new(check, member["check_since_last_audit"],
126
+ member.fetch("check_hidden_characters", []))
127
+ assessed.merge("check_result" => template.to_h)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,79 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "audit_commit"
5
+ require_relative "landed_since_base"
6
+ require_relative "principle_with_changed_members"
7
+ require_relative "shell_word"
8
+
9
+ module Principles
10
+ # What this tool prints, from plain values a controller hands it. views/CLAUDE.md owns the boundary.
11
+ module Views
12
+ # What a completed audit changed in the project, as its result prints it: the files to commit, in git what
13
+ # landed since the base commit, as LandedSinceBase prints it, each other principle whose members its fixes changed
14
+ # (#410), each file the record cites that no longer exists (#861), and each member added to the map during the
15
+ # audit, which no assessment read, so the next full audit assesses it (T69).
16
+ class AuditChanges
17
+ #: (files_to_commit: Array[String], ?landed: LandedSinceBase?,
18
+ #| ?other_changed: Array[PrincipleWithChangedMembers], ?cited_files_missing: Array[String],
19
+ #| ?added: Array[String]) -> void
20
+ def initialize(files_to_commit:, landed: nil, other_changed: [], cited_files_missing: [], added: [])
21
+ @files_to_commit = files_to_commit
22
+ @added = added
23
+ @landed = landed
24
+ @other_changed = other_changed
25
+ @cited_files_missing = cited_files_missing
26
+ freeze
27
+ end
28
+
29
+ # The files to commit, and each other principle whose members the audit's fixes changed, as the next step
30
+ # names them.
31
+ #: Array[String]
32
+ attr_reader :files_to_commit
33
+ #: Array[PrincipleWithChangedMembers]
34
+ attr_reader :other_changed
35
+
36
+ # The fields as printed; the members added and the cited files missing, what landed since the base only in
37
+ # git, where it is given, and the other principles, each only when there are some.
38
+ #: () -> Hash[String, untyped]
39
+ def to_h
40
+ printed = { "files_to_commit" => @files_to_commit } #: Hash[String, untyped]
41
+ printed = printed.merge("members_added_awaiting_assessment" => @added) unless @added.empty?
42
+ printed = printed.merge("cited_files_missing" => @cited_files_missing) unless @cited_files_missing.empty?
43
+ printed = printed.merge(@landed.to_h) if @landed
44
+ others = @other_changed.map(&:to_h)
45
+ others.empty? ? printed : printed.merge("other_principles_with_changed_members" => others)
46
+ end
47
+
48
+ # Refuses, staging nothing, while a merge is unfinished, whose commit would otherwise take in these files
49
+ # (758f12b, #811), saying why on stderr, the one place the stop is explained (#828). It fails with false, not
50
+ # exit, so a shell running it outside the printed subshell survives; the braces keep a failed cd from reading
51
+ # as a merge. The message is in single quotes, so it holds no apostrophe.
52
+ NO_MERGE_UNFINISHED = '{ test ! -e "$(git rev-parse --git-path MERGE_HEAD)" || ' \
53
+ "{ echo 'principles: a merge is unfinished; finish or abort it, then run this again' " \
54
+ ">&2; false; }; }" #: String
55
+
56
+ # In git, the command that commits the files to commit and nothing else staged, with subject, run from
57
+ # project, where those paths are relative. Each path is quoted for the shell and read by git as written, since
58
+ # project files anyone may have written name them (#227, #811). nil outside git, where it could not run.
59
+ #: (String, String) -> String?
60
+ def commit_with(subject, project)
61
+ return unless @landed
62
+
63
+ files = @files_to_commit.map { |file| ShellWord.new(file) }.join(" ")
64
+ "(cd #{ShellWord.new(project)} && #{NO_MERGE_UNFINISHED} && git --literal-pathspecs add -- #{files} && " \
65
+ "git --literal-pathspecs commit -m #{ShellWord.new(subject)} -- #{files})"
66
+ end
67
+
68
+ # How the result says to commit these files with subject, run from project: in git with commit_with's
69
+ # command, outside git by hand.
70
+ #: (String, String) -> (AuditCommit | AuditCommit::ByHand)
71
+ def commit(subject, project)
72
+ command = commit_with(subject, project)
73
+ return AuditCommit::ByHand.new(@files_to_commit) unless command
74
+
75
+ AuditCommit.new(files_to_commit: @files_to_commit, subject:, command:)
76
+ end
77
+ end
78
+ end
79
+ end