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,171 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "locator"
6
+
7
+ module Principles
8
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
9
+ module Models
10
+ # One member of a principle as its map records it: a file, a Markdown section of it, or a passage
11
+ # starting at a quoted first line. Its id names all three, so an agent can read where the member is
12
+ # from the id alone.
13
+ class Member
14
+ # The Unicode names of the format characters and separators most often met in text, by code point.
15
+ # Ruby has no table of names, so a character missing here is given by its code point alone.
16
+ HIDDEN_CHARACTER_NAMES = {
17
+ 0x00AD => "SOFT HYPHEN", 0x061C => "ARABIC LETTER MARK", 0x180E => "MONGOLIAN VOWEL SEPARATOR",
18
+ 0x200B => "ZERO WIDTH SPACE", 0x200C => "ZERO WIDTH NON-JOINER", 0x200D => "ZERO WIDTH JOINER",
19
+ 0x200E => "LEFT-TO-RIGHT MARK", 0x200F => "RIGHT-TO-LEFT MARK", 0x2028 => "LINE SEPARATOR",
20
+ 0x2029 => "PARAGRAPH SEPARATOR", 0x202A => "LEFT-TO-RIGHT EMBEDDING", 0x202B => "RIGHT-TO-LEFT EMBEDDING",
21
+ 0x202C => "POP DIRECTIONAL FORMATTING", 0x202D => "LEFT-TO-RIGHT OVERRIDE",
22
+ 0x202E => "RIGHT-TO-LEFT OVERRIDE", 0x2060 => "WORD JOINER", 0x2066 => "LEFT-TO-RIGHT ISOLATE",
23
+ 0x2067 => "RIGHT-TO-LEFT ISOLATE", 0x2068 => "FIRST STRONG ISOLATE", 0x2069 => "POP DIRECTIONAL ISOLATE",
24
+ 0xFEFF => "ZERO WIDTH NO-BREAK SPACE"
25
+ }.freeze #: Hash[Integer, String]
26
+
27
+ # The map format each member field needs: a member using a field is unreadable, without loss, by a
28
+ # version older than its format. Every field the member-map schema names is listed; a spec checks it.
29
+ # A new member field that changes what an audit asks or records gets the next format, and
30
+ # Data::MapFiles::NEWEST_FORMAT moves with it.
31
+ FIELD_FORMATS = { "path" => 1, "section" => 1, "starting_at" => 1, "ending_at" => 1, "role" => 1,
32
+ "key" => 1, "check" => 2, "query" => 2 }.freeze #: Hash[String, Integer]
33
+
34
+ # The fields that say where a member's text is; a move replaces them whole.
35
+ LOCATORS = %w[path section starting_at ending_at query].freeze #: [String, String, String, String, String]
36
+
37
+ # fields is copied and frozen, so no caller changes a member through the hash it was built from or handed;
38
+ # the copy is shallow, so its strings are the caller's. MemberMap builds its members from strings it has
39
+ # copied and frozen, so a member in a map shares nothing (#654).
40
+ #: (Hash[String, untyped]) -> void
41
+ def initialize(fields)
42
+ @fields = fields.dup.freeze #: Hash[String, untyped]
43
+ freeze
44
+ end
45
+
46
+ # The member's fields, as its map's file holds them.
47
+ #: () -> Hash[String, untyped]
48
+ def to_h = @fields
49
+
50
+ # path, path#section or path$query, or either of the first two followed by @ and the passage's first-line
51
+ # anchor.
52
+ #: () -> String
53
+ def id
54
+ id = @fields["section"] ? "#{@fields.fetch("path")}##{@fields["section"]}" : @fields.fetch("path")
55
+ id = "#{id}#{@fields["query"]}" if @fields["query"]
56
+ @fields["starting_at"] ? "#{id}@#{@fields["starting_at"]}" : id
57
+ end
58
+
59
+ # What the member is known by across moves: its stored key, which move-member keeps from its id before
60
+ # its first move, or else its id. Audit records file each member's hash under it.
61
+ #: () -> String
62
+ def key
63
+ @fields["key"] || id
64
+ end
65
+
66
+ # The move-member options that place a member of this kind, with placeholders to fill: a section's heading,
67
+ # a passage's anchors or a query, or else the whole file's path. Run as printed, a placeholder fails, where a
68
+ # bare --path would silently widen a section or passage to its whole file.
69
+ #: () -> String
70
+ def move_placeholders
71
+ placed = { "section" => "--section HEADING", "starting_at" => "--starting-at TEXT",
72
+ "ending_at" => "--ending-at TEXT", "query" => "--query QUERY" }
73
+ .filter_map { |field, option| option if @fields[field] }
74
+ placed.empty? ? "--path PATH" : placed.join(" ")
75
+ end
76
+
77
+ # The oldest map format that holds this member: 1 unless it uses a later field.
78
+ #: () -> Integer
79
+ def map_format
80
+ @fields.keys.map { |field| FIELD_FORMATS.fetch(field, 1) }.max || 1
81
+ end
82
+
83
+ # The file the member is in.
84
+ #: () -> String
85
+ def path
86
+ @fields.fetch("path")
87
+ end
88
+
89
+ # Whether it is its whole file, which no locator but its path narrows, so it holds all the text of every
90
+ # line it spans (#756).
91
+ #: () -> bool
92
+ def whole_file?
93
+ (LOCATORS - ["path"]).none? { |locator| @fields.key?(locator) }
94
+ end
95
+
96
+ # What the member does for its principle, as the map states it.
97
+ #: () -> String?
98
+ def role
99
+ @fields["role"]
100
+ end
101
+
102
+ # The command that enforces the member, which an audit asks the agent to run (D30); nil when it has none.
103
+ #: () -> String?
104
+ def check
105
+ @fields["check"]
106
+ end
107
+
108
+ # Each invisible formatting or direction-changing character, or line separator, the member's check
109
+ # holds, in order, as its code point, its name when HIDDEN_CHARACTER_NAMES has it, and its position
110
+ # counted in characters from 1; none when it has no check. This tool prints such characters escaped, and
111
+ # naming them helps a reviewer who scans the command quickly (#258, #260).
112
+ #: () -> Array[String]
113
+ def check_hidden_characters
114
+ check.to_s.each_char.with_index(1).filter_map do |character, position|
115
+ next unless character.match?(/[\p{Cf}\p{Zl}\p{Zp}]/)
116
+
117
+ code = character.ord
118
+ [format("U+%04X", code), HIDDEN_CHARACTER_NAMES[code], "at character #{position}"].compact.join(" ")
119
+ end
120
+ end
121
+
122
+ # Where the member's text is, as MemberFile finds it.
123
+ #: () -> Hash[String, untyped]
124
+ def reference
125
+ { "path" => @fields.fetch("path"), "heading" => @fields["section"],
126
+ "starting_at" => @fields["starting_at"], "ending_at" => @fields["ending_at"],
127
+ "query" => @fields["query"] }.compact
128
+ end
129
+
130
+ # Where the member's text is, as the Locator MemberFile reads.
131
+ #: () -> Locator
132
+ def locator = Locator.new(reference)
133
+
134
+ # This member, still known by key once it has moved. The key is stored only while it differs from the
135
+ # member's id, so a member moved back to where it began has the bytes it had.
136
+ #: (String) -> Member
137
+ def keeping_key(key)
138
+ without = Member.new(@fields.except("key"))
139
+ without.id == key ? without : without.keyed_as(key)
140
+ end
141
+
142
+ # This member pointed at the place located names: the new place replaces the old one whole, every other
143
+ # field is kept, and the member is still known by its key.
144
+ #: (Hash[String, untyped]) -> Member
145
+ def moved_to(located)
146
+ Member.new(located.merge(@fields.except(*LOCATORS))).keeping_key(key)
147
+ end
148
+
149
+ # This member known by key, as a member added where another moved away from keeps its own audit.
150
+ #: (String) -> Member
151
+ def keyed_as(key) = with("key" => key)
152
+
153
+ # This member with fields in place of its own.
154
+ #: (Hash[String, untyped]) -> Member
155
+ def with(fields) = Member.new(@fields.merge(fields))
156
+
157
+ # This member with no check.
158
+ #: () -> Member
159
+ def without_check = Member.new(@fields.except("check"))
160
+
161
+ # Refuses a place an agent gave that quotes where a passage ends but not where it starts.
162
+ #: () -> void
163
+ def check_location
164
+ return unless @fields["ending_at"] && !@fields["starting_at"]
165
+
166
+ raise Error.new("ending_without_start", "--ending-at needs --starting-at",
167
+ "Quote the passage's opening with --starting-at too.")
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,144 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "locator"
6
+ require_relative "answer_fields"
7
+ require_relative "answer_place"
8
+ require_relative "carried_assessments"
9
+ require_relative "map_members"
10
+ require_relative "member_sighting"
11
+ require_relative "misspelt_member_id"
12
+ require_relative "record_judged_against"
13
+ require_relative "reported_check"
14
+
15
+ module Principles
16
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
17
+ module Models
18
+ # The member_assessments of an assessment answer: one for each mapped member, each with an assessment, a delivery,
19
+ # an observation when delivery is observed, evidence that resolves, and the result of the member's check when it
20
+ # has one (D30).
21
+ class MemberAssessments
22
+ # What a record entry keeps for a member carried forward, until completion puts back its last assessment.
23
+ CARRIED = { "carried_forward" => true }.freeze #: Hash[String, untyped]
24
+
25
+ # assessments is the answer's member_assessments; members are the mapped members, with each checked one's
26
+ # check; fields checks each field and locates each piece of evidence; judged_against is the principle's record
27
+ # as it stands, which says which members' last assessments may be carried forward, marked {"member_id",
28
+ # "carried_forward": true} (D49), and which command it ran for each member's check (D63); answered_at is when
29
+ # the answer came, which no sighting it reports is after (#699).
30
+ #: (untyped, MapMembers, fields: AnswerFields, answered_at: String, ?judged_against: RecordJudgedAgainst?)
31
+ #| -> void
32
+ def initialize(assessments, members, fields:, answered_at:, judged_against: nil)
33
+ @assessments = assessments
34
+ @members = members
35
+ @answered_at = answered_at
36
+ @fields = fields
37
+ @judged_against = judged_against
38
+ @carried = judged_against&.carried #: CarriedAssessments?
39
+ freeze
40
+ end
41
+
42
+ # Refuses the assessments unless each mapped member is assessed exactly once, and each assessment is complete.
43
+ #: () -> void
44
+ def check
45
+ assessments = @fields.array(@assessments, "member_assessments")
46
+ refuse_incomplete_coverage(assessments.map { |assessment| assessment["member_id"] })
47
+ assessments.each_with_index { |assessment, index| member_assessment(assessment, place_of(assessment, index)) }
48
+ end
49
+
50
+ # The result reported for each checked member's check, by member id, once each has been checked. Call it after
51
+ # check, which refuses an unmapped id first: finding the command the record ran needs each member's key.
52
+ #: () -> Hash[String, Hash[String, untyped]]
53
+ def check_results
54
+ @assessments.each_with_index.to_h do |assessment, index|
55
+ [assessment["member_id"], reported(assessment, place_of(assessment, index))]
56
+ end.compact
57
+ end
58
+
59
+ # Each member's assessment by id, as its record entry keeps it: what the agent said, and the hash of each piece
60
+ # of evidence it cited, which the block locates, so the next audit can say whether that evidence changed (D49).
61
+ #: () { (Locator) -> Hash[String, untyped] } -> Hash[String, Hash[String, untyped]]
62
+ def kept(&locate)
63
+ @assessments.to_h do |assessment|
64
+ next [assessment.fetch("member_id"), { "assessment" => CARRIED }] if carried_forward?(assessment)
65
+
66
+ hashes = assessment.fetch("evidence").map { |reference| locate.call(Locator.new(reference)).fetch("sha256") }
67
+ [assessment.fetch("member_id"),
68
+ { "assessment" => assessment.slice("assessment", "delivery", "observation", "observed_at", "evidence")
69
+ .reject { |key, value| key == "observed_at" && value.nil? }
70
+ .merge("evidence_sha256" => hashes) }]
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ # Refuses answered, the ids the assessments name, unless they are the mapped ids, each once, hinting at any
77
+ # mapped id an unmapped one is spelled like.
78
+ #: (Array[untyped]) -> void
79
+ def refuse_incomplete_coverage(answered)
80
+ ids = @members.ids
81
+ return if answered.sort == ids.sort
82
+
83
+ raise Error.new("incomplete_coverage", "Assess each member exactly once: #{ids.join(", ")}",
84
+ [*(answered - ids).grep(String).filter_map { |id| MisspeltMemberId.new(id).hint(ids) },
85
+ "Complete the member_assessments array, copying each member id exactly."].join(" "))
86
+ end
87
+
88
+ # The place of the assessment at index in the answer, whose fields each refusal about it points into.
89
+ #: (untyped, Integer) -> AnswerPlace
90
+ def place_of(assessment, index)
91
+ AnswerPlace.new("/member_assessments/#{index}", "Member #{assessment["member_id"]}")
92
+ end
93
+
94
+ #: (untyped, AnswerPlace) -> void
95
+ def member_assessment(assessment, place)
96
+ return carried_assessment(assessment, place) if carried_forward?(assessment)
97
+
98
+ @fields.text(assessment["assessment"], "member_assessment.assessment")
99
+ sighting = MemberSighting.for(assessment, carried: @carried, answered_at: @answered_at, at: place)
100
+ @fields.evidence(assessment["evidence"], place.child("evidence"))
101
+ sighting.check
102
+ reported(assessment, place)
103
+ end
104
+
105
+ # Whether the assessment is a marker carrying forward the member's last one.
106
+ #: (untyped) -> bool
107
+ def carried_forward?(assessment) = assessment.key?("carried_forward")
108
+
109
+ # A marker is accepted only for a member the request listed as carried, alone but for its check's result.
110
+ #: (Hash[String, untyped], AnswerPlace) -> void
111
+ def carried_assessment(assessment, place)
112
+ reason = marker_refusal(assessment) or return reported(assessment, place)
113
+
114
+ raise Error.new("invalid_answer", "#{assessment["member_id"]} cannot be carried forward: #{reason}",
115
+ "Assess it as the request's template does, or give only {\"member_id\", " \
116
+ "\"carried_forward\": true} for a member the request lists with carried_forward.",
117
+ fields: [{ "pointer" => place.child("carried_forward").pointer, "problem" => reason }])
118
+ end
119
+
120
+ # Why a marker cannot carry the member's assessment forward, or nil when it can: the request asks to judge the
121
+ # member, giving why, or the marker is not true or comes with an assessment.
122
+ #: (Hash[String, untyped]) -> String?
123
+ def marker_refusal(assessment)
124
+ return "carried_forward must be true" unless assessment["carried_forward"] == true
125
+
126
+ assessed = assessment.keys.intersect?(%w[assessment delivery evidence])
127
+ return "give the marker or an assessment, not both" if assessed
128
+
129
+ reason = @carried ? @carried.judge_because(assessment["member_id"]) : "not_assessed_before"
130
+ "the request asks to judge it: judge_because #{reason}" if reason
131
+ end
132
+
133
+ # The result reported for the member assessed at place, checked against its check.
134
+ #: (untyped, AnswerPlace) -> Hash[String, untyped]?
135
+ def reported(assessment, place)
136
+ id = assessment["member_id"]
137
+ key = @members.found(id).key
138
+ ReportedCheck.new(place, @members.check(id), ran: @judged_against&.check_run_for(key),
139
+ kept: @judged_against&.check_review_for(key) || {})
140
+ .result(assessment["check_result"])
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,74 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "line_span"
5
+ require_relative "member"
6
+
7
+ module Principles
8
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
9
+ module Models
10
+ # A member of another principle's map whose text shares a line with one located member: its principle, id,
11
+ # role there and lines, and how its lines stand to the located member's: the same lines, all of them, within
12
+ # them, or none of these, for a partial overlap (#383, #442, #610, #718). These are lines, not text, so what an
13
+ # edit reaches is left to the agent.
14
+ class MemberElsewhere
15
+ # member is the other map's member, whose id and role are printed; span is its lines now.
16
+ #: (principle_id: String, member: Member, span: LineSpan, located: LineSpan) -> void
17
+ def initialize(principle_id:, member:, span:, located:)
18
+ @principle_id = principle_id
19
+ @member = member
20
+ @span = span
21
+ @relation = relation_to(located) #: String?
22
+ @text_check_needed = text_check_needed_against?(located) #: bool
23
+ freeze
24
+ end
25
+
26
+ # Whether the agent should check the text where the lines meet before deciding which edit reaches which
27
+ # member: one holds the other's lines, and the one holding them is not a whole file (#756).
28
+ #: () -> bool
29
+ def text_check_needed? = @text_check_needed
30
+
31
+ # The principle whose map holds it.
32
+ #: String
33
+ attr_reader :principle_id
34
+
35
+ # The findings among left_open, its principle's open findings, that name it (#772).
36
+ #: (Array[OpenFinding]) -> Array[OpenFinding]
37
+ def open_among(left_open) = left_open.select { |finding| finding.on?(@member.key) }
38
+
39
+ # Its entry, naming by id the findings among left_open that name it, which the request lists once, so the same
40
+ # problem is not raised again in another principle's audit (#772, D62); as to_h when none do.
41
+ #: (Array[OpenFinding]) -> Hash[String, untyped]
42
+ def naming(left_open)
43
+ on_it = open_among(left_open)
44
+ on_it.empty? ? to_h : to_h.merge("open_finding_ids" => on_it.map(&:id))
45
+ end
46
+
47
+ # As add-member, show-principle and a full audit's request print it under mapped_elsewhere, its relation marked
48
+ # true when it has one.
49
+ #: () -> Hash[String, untyped]
50
+ def to_h
51
+ { "principle_id" => @principle_id, "member_id" => @member.id, "role" => @member.role,
52
+ "first_line" => @span.first_line, "last_line" => @span.last_line, **(@relation ? { @relation => true } : {}) }
53
+ end
54
+
55
+ private
56
+
57
+ #: (LineSpan) -> String?
58
+ def relation_to(located)
59
+ return "same_lines" if @span.same_lines_as?(located)
60
+ return "spans_these_lines" if @span.holds?(located)
61
+
62
+ "within_these_lines" if located.holds?(@span)
63
+ end
64
+
65
+ #: (LineSpan) -> bool
66
+ def text_check_needed_against?(located)
67
+ return !@span.holds_all_text_of?(located) if @relation == "spans_these_lines"
68
+ return !located.holds_all_text_of?(@span) if @relation == "within_these_lines"
69
+
70
+ false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,156 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require_relative "../error"
6
+ require_relative "locator"
7
+ require_relative "write_up_opening"
8
+
9
+ module Principles
10
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # The text of one project file, as the type that knows how to find a member in it. It holds text, not a
13
+ # file, so the same rules apply to a file on disk and to the file at a commit. Each type finds the region
14
+ # a member names; passage anchors then narrow it the same way in every type.
15
+ class MemberFile
16
+ # The refusals, raised here and by the file types, that say a member's reference finds nothing in a file's
17
+ # text, rather than that the reference itself is wrong. Add a new one here when it raises one (#457).
18
+ PLACEMENT_REFUSALS = %w[section_not_found ambiguous_heading passage_not_found ambiguous_passage
19
+ passage_end_not_found section_needs_markdown query_needs_frontmatter field_not_found
20
+ unsupported_frontmatter duplicate_frontmatter_key].freeze
21
+ # The placement refusals of a quote the text does not hold, which a pointer that is not a member may also
22
+ # repair by pointing at other text (#742).
23
+ QUOTE_NOT_FOUND = %w[passage_not_found passage_end_not_found].freeze #: Array[String]
24
+ # The placement refusals that mean a member's place in its file is gone: its heading, passage anchor or
25
+ # frontmatter field no longer matches, so it is lost there, not changed and not unreadable.
26
+ PLACE_LOST = %w[section_not_found passage_not_found passage_end_not_found ambiguous_heading
27
+ ambiguous_passage field_not_found].freeze #: Array[String]
28
+
29
+ # The file name endings read as Markdown, where a # line is a heading and not a comment.
30
+ MARKDOWN = /\.(md|markdown|mdx)\z/i #: Regexp
31
+
32
+ # The file type for path, chosen by its name.
33
+ #: (path: String, text: String) -> MemberFile
34
+ def self.for(path:, text:)
35
+ (path.match?(MARKDOWN) ? MarkdownFile : PlainTextFile).new(path: path, text: text)
36
+ end
37
+
38
+ #: String
39
+ attr_reader :path
40
+ #: String
41
+ attr_reader :text
42
+
43
+ #: (path: String, text: String) -> void
44
+ def initialize(path:, text:)
45
+ @path = path
46
+ @text = text
47
+ freeze
48
+ end
49
+
50
+ # Where the member locator names is in this text, and the hash of what it says there.
51
+ #: (Locator) -> Hash[String, untyped]
52
+ def locate(locator)
53
+ refuse_mixed_locators(locator)
54
+ lines = @text.lines
55
+ first, last = region(lines, locator)
56
+ selected = lines[first...last].to_a.join
57
+ start_text = locator.starting_at
58
+ first, last, selected = passage(lines, first, selected, start_text, locator.ending_at) if start_text
59
+ snapshot(locator, selected, first + 1, last)
60
+ end
61
+
62
+ # The lines of the region locator names, as the agent reads them (D33).
63
+ #: (Locator) -> Array[String]
64
+ def lines_of(locator)
65
+ located = locate(locator)
66
+ @text.lines[(located.fetch("first_line") - 1)...located.fetch("last_line")].to_a
67
+ end
68
+
69
+ # The opening of the region locator names, after the heading line itself when it names a heading, as
70
+ # WriteUpOpening finds it; nil when there is none. This tool does not judge what it says (#300).
71
+ #: (Locator) -> Hash[String, untyped]?
72
+ def write_up_opening(locator)
73
+ located = locate(locator)
74
+ heading_line = locator.heading && !locator.starting_at ? 1 : 0
75
+ WriteUpOpening.new(lines: @text.lines, first: located.fetch("first_line") - 1 + heading_line,
76
+ last: located.fetch("last_line")).to_h
77
+ end
78
+
79
+ private
80
+
81
+ # What locate found: the member's place, the hash of its text and its span. query appears only for a
82
+ # member that has one, so every other member's snapshot, and each request id digesting it, is unchanged.
83
+ #: (Locator, String, Integer, Integer) -> Hash[String, untyped]
84
+ def snapshot(locator, selected, first_line, last_line)
85
+ { "path" => @path, "heading" => locator.heading, "starting_at" => locator.starting_at,
86
+ "sha256" => Digest::SHA256.hexdigest(selected), "characters" => selected.length,
87
+ "first_line" => first_line, "last_line" => last_line }.merge(locator.to_h.slice("query"))
88
+ end
89
+
90
+ # A query names a field whole, so it takes no section or passage beside it.
91
+ #: (Locator) -> void
92
+ def refuse_mixed_locators(locator)
93
+ return unless locator.query && (locator.heading || locator.starting_at)
94
+
95
+ raise Error.new("query_with_other_locators",
96
+ "--query names a whole field, so it takes no --section or --starting-at",
97
+ "Give --query alone, or --section and --starting-at without it.", @path)
98
+ end
99
+
100
+ # The line range a member names before any passage narrows it; each file type says.
101
+ #: (Array[String], Locator) -> [Integer, Integer]
102
+ def region(lines, locator)
103
+ raise NotImplementedError, "#{self.class} names no region"
104
+ end
105
+
106
+ # Narrows a member to a passage of its section, or of the whole text. starting_at must occur exactly
107
+ # once. With ending_at, the passage runs from the start of that match to the end of the first ending_at
108
+ # after it; without, to the end of the line it starts on. Anchors, never line numbers, so the passage
109
+ # stays found while text around and inside it changes. A lost anchor is refused, never widened to the
110
+ # whole file. Returns the passage's first and last lines, counted from the start of the text, and the
111
+ # passage.
112
+ #: (Array[String], Integer, String, String, String?) -> [Integer, Integer, String]
113
+ def passage(lines, first, selected, start_text, end_text)
114
+ start = only_opening(selected, start_text)
115
+ found = selected[start...passage_end(selected, start, start_text, end_text)].to_s
116
+ line = @text[0, lines[0...first].to_a.join.length + start].to_s.count("\n")
117
+ [line, line + line_count(found), found]
118
+ end
119
+
120
+ # How many lines a passage spans, counting a last line with no line break.
121
+ #: (String) -> Integer
122
+ def line_count(found)
123
+ found.count("\n") + (found.end_with?("\n") ? 0 : 1)
124
+ end
125
+
126
+ #: (String, String) -> Integer
127
+ def only_opening(text, start_text)
128
+ matches = text.scan(start_text).length
129
+ return text.index(start_text).to_i if matches == 1
130
+ if matches.zero?
131
+ raise Error.new("passage_not_found", "The passage starting #{start_text.inspect} is not in the file",
132
+ "Quote the passage's opening text exactly.", @path)
133
+ end
134
+
135
+ raise Error.new("ambiguous_passage", "#{start_text.inspect} occurs #{matches} times",
136
+ "Quote more of the passage's start so it occurs once.", @path)
137
+ end
138
+
139
+ #: (String, Integer, String, String?) -> Integer
140
+ def passage_end(text, start, start_text, end_text)
141
+ return (text.index("\n", start) || (text.length - 1)) + 1 unless end_text
142
+
143
+ found = text.index(end_text, start + start_text.length)
144
+ unless found
145
+ raise Error.new("passage_end_not_found", "No #{end_text.inspect} after #{start_text.inspect}",
146
+ "Quote the passage's closing text exactly.", @path)
147
+ end
148
+
149
+ found + end_text.length
150
+ end
151
+ end
152
+ end
153
+ end
154
+
155
+ require_relative "markdown_file"
156
+ require_relative "plain_text_file"
@@ -0,0 +1,32 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "json_digest"
5
+ require_relative "loading_facts"
6
+ require_relative "map_members"
7
+ require_relative "member_loads"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # The facts beside each member's own text that its assessment was judged on: its map entry, which holds its path,
13
+ # locator, role and check, and when it loads, without the sizes an edit elsewhere in its file moves. A full audit
14
+ # keeps a digest of each with the assessment, and carries the assessment forward only while both match (D49).
15
+ class MemberGrounds
16
+ # members are the map's members; loads is each member's MemberLoads by id.
17
+ #: (MapMembers, Hash[String, MemberLoads]) -> void
18
+ def initialize(members, loads)
19
+ @members = members.by_id #: Hash[String, Member]
20
+ @loads = loads
21
+ freeze
22
+ end
23
+
24
+ # The digests of the member id's map entry and of its loading facts, as its kept assessment holds them.
25
+ #: (String) -> Hash[String, String]
26
+ def of(id)
27
+ { "entry_sha256" => JsonDigest.new(@members.fetch(id).to_h).to_s,
28
+ "loads_sha256" => JsonDigest.new(LoadingFacts.new(@loads.fetch(id).to_h).to_h).to_s }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,68 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Principles
5
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
6
+ module Models
7
+ # The commits that changed one member's own text since this tool last knew it, newest first. The walk goes
8
+ # back from HEAD and stops at the first commit whose text for the member hashes to the baseline, so a fix
9
+ # an audit already took in is never listed. It also stops where the member cannot be found, or at the limit.
10
+ # It reports facts; whether a change is accounted for is the agent's judgement.
11
+ class MemberHistory
12
+ # The most commits listed for one member; the walk says when it stopped here.
13
+ LIMIT = 20
14
+
15
+ # commits are the commits that touched the member's file or its map, newest first; hash_at gives the
16
+ # member's hash at a revision, or nil where it cannot be found there.
17
+ #: (baseline: String, now: String, commits: Array[Hash[String, String]], hash_at: ^(String) -> String?) -> void
18
+ def initialize(baseline:, now:, commits:, hash_at:)
19
+ @baseline = baseline
20
+ @now = now
21
+ @commits = commits
22
+ @hash_at = hash_at
23
+ freeze
24
+ end
25
+
26
+ # The commits that changed the member's text, where and why the walk stopped, and whether the text on disk
27
+ # differs from HEAD.
28
+ #: () -> Hash[String, untyped]
29
+ def to_h
30
+ listed = [] #: Array[Hash[String, String]]
31
+ stopped = walk(listed)
32
+ { "commits" => listed, "stopped_at" => stopped, "uncommitted" => @hash_at.call("HEAD") != @now }
33
+ end
34
+
35
+ private
36
+
37
+ #: (Array[Hash[String, String]]) -> Hash[String, String]
38
+ def walk(listed)
39
+ @commits.each do |commit|
40
+ after = @hash_at.call(commit.fetch("sha"))
41
+ return stop("baseline_matched", commit) if after == @baseline
42
+ return { "reason" => "limit" } if listed.length == LIMIT
43
+
44
+ stopped = step(listed, commit, after)
45
+ return stopped if stopped
46
+ end
47
+ { "reason" => "history_start" }
48
+ end
49
+
50
+ # Lists commit when it changed the member's text, and says why the walk stops at it, if it does: a root
51
+ # commit has no parent, and a parent without the member ends what can be tied to it.
52
+ #: (Array[Hash[String, String]], Hash[String, String], String?) -> Hash[String, String]?
53
+ def step(listed, commit, after)
54
+ root = commit.fetch("root") == "true"
55
+ before = root ? nil : @hash_at.call("#{commit.fetch("sha")}^")
56
+ listed << commit.except("sha", "root") if before != after
57
+ return { "reason" => "history_start" } if root
58
+
59
+ stop("member_not_found", commit) unless before
60
+ end
61
+
62
+ #: (String, Hash[String, String]) -> Hash[String, String]
63
+ def stop(reason, commit)
64
+ { "reason" => reason, "commit" => commit.fetch("commit") }
65
+ end
66
+ end
67
+ end
68
+ end