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,203 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "../data/repository"
6
+ require_relative "../data/project_files"
7
+ require_relative "../data/principles_subfolder"
8
+ require_relative "map_members"
9
+ require_relative "member"
10
+ require_relative "member_file"
11
+ require_relative "commits_brought_in"
12
+
13
+ module Principles
14
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
15
+ module Models
16
+ # What an audit needs from the project's git history, read through Data::Repository: the commit an audit
17
+ # is based on, each member as that commit holds it, and what changed since. Outside git there is no
18
+ # history, and an audit has no base commit. A path that is a symbolic link, or lies below one, is read at
19
+ # each commit in the file its links lead to, and whether it is committed, and which commits touched it, cover
20
+ # every link on the way, since git holds a link as its target's name (#695). uncommitted_outside alone asks by
21
+ # the paths given: start-audit asks it only once base_commit found every file they lead to committed.
22
+ class ProjectHistory
23
+ # This tool's folder as git names a folder, now and under its earlier name.
24
+ OWN_FOLDERS = [Data::PrinciplesSubfolder::FOLDER, Data::PrinciplesSubfolder::EARLIER_FOLDER]
25
+ .map { |folder| "#{folder}/" }.freeze #: Array[String]
26
+
27
+ #: (Data::Repository, Data::ProjectFiles) -> void
28
+ def initialize(repository, files)
29
+ @repository = repository
30
+ @files = files
31
+ end
32
+
33
+ # Which of paths, and of the links and files they lead through, git does not track, which an audit refuses as
34
+ # members until committed (#386, #695). nil outside git, where nothing is checked.
35
+ #: (Array[String]) -> Array[String]?
36
+ def untracked(paths) = (@repository.untracked(through_links(paths)) if @repository.inside?)
37
+
38
+ # Whether git tracks path and every file its links lead to, so a fresh clone finds its text; an ignored file is
39
+ # not tracked either (#896). True outside git, where nothing is checked.
40
+ #: (String) -> bool
41
+ def tracks?(path) = !@repository.inside? || @repository.not_tracked(through_links([path])).empty?
42
+
43
+ # Whether the project is in git, so it has a history at all.
44
+ #: () -> bool
45
+ def inside_git? = @repository.inside?
46
+
47
+ # The project's files, other than paths, that differ from HEAD or are untracked, ignored ones aside; nil
48
+ # outside git or when there are none. A check and an answer's evidence read them as they are now (#409).
49
+ #: (Array[String]) -> Array[String]?
50
+ def uncommitted_outside(paths)
51
+ return unless @repository.inside?
52
+
53
+ outside = @repository.changed_files - paths
54
+ outside unless outside.empty?
55
+ end
56
+
57
+ # Refuses a write-up's path while it, or a file its links lead to, differs from HEAD: the record is committed
58
+ # pointing at it, so a fresh clone would find nothing there (#760). Nothing outside git.
59
+ #: (String) -> void
60
+ def refuse_uncommitted_write_up(path)
61
+ return unless @repository.inside?
62
+
63
+ dirty = uncommitted([path])
64
+ return if dirty.empty?
65
+
66
+ linked = dirty - [path]
67
+ raise Error.new("uncommitted_files",
68
+ "The write-up at #{path} differs from the last commit" +
69
+ (linked.empty? ? "" : ", through #{linked.join(", ")}"),
70
+ "The record will point at the write-up as committed.\n" \
71
+ "Commit the write-up, then submit the same answer again.")
72
+ end
73
+
74
+ # The commit an audit of id is based on: HEAD, refused while any of files, or a file their links lead to,
75
+ # differs from it. nil outside git.
76
+ #: (String, Array[String]) -> String?
77
+ def base_commit(id, files)
78
+ return unless @repository.inside?
79
+
80
+ dirty = uncommitted(files)
81
+ unless dirty.empty?
82
+ raise Error.new("uncommitted_files", "The map or members of #{id} differ from the last commit: " \
83
+ "#{dirty.join(", ")}",
84
+ "An audit assesses the map and members as committed, so its record can name the commit " \
85
+ "it began from.\nCommit them, then start it again: #{Error::EXECUTABLE} start-audit #{id}")
86
+ end
87
+
88
+ @repository.head
89
+ end
90
+
91
+ # path's text at commit, or nil when commit does not hold it.
92
+ #: (String, String) -> String?
93
+ def text_at(commit, path)
94
+ @repository.text_at(commit, @files.leads_to(path))
95
+ end
96
+
97
+ # The lines of the member at locator in its file at commit, or nil when commit lacks the file or the
98
+ # locator finds nothing in its text there, as after a move; any other refusal still refuses (#457).
99
+ #: (String, Locator) -> Array[String]?
100
+ def lines_at(commit, locator)
101
+ path = locator.path
102
+ text = text_at(commit, path) or return nil
103
+ MemberFile.for(path: path, text: text).lines_of(locator)
104
+ rescue Error => e
105
+ raise unless MemberFile::PLACEMENT_REFUSALS.include?(e.payload.fetch("code"))
106
+
107
+ nil
108
+ end
109
+
110
+ # Each member's id and the hash of its text at commit, or nil when commit lacks a member's file or the
111
+ # member's place in it.
112
+ #: (String, MapMembers) -> Hash[String, String]?
113
+ def hashes_at(commit, members)
114
+ members.to_h do |member|
115
+ path = member.path
116
+ text = text_at(commit, path)
117
+ return nil unless text
118
+
119
+ [member.id, MemberFile.for(path: path, text: text).locate(member.locator).fetch("sha256")]
120
+ end
121
+ rescue Error
122
+ nil
123
+ end
124
+
125
+ # The rename git detects for path, a file absent from the working tree, as {"to", "similarity",
126
+ # "deleted_in"}: the last commit that touched path, or nil while the move is not yet committed. It is
127
+ # git's similarity match, which can pair a deleted file with an unrelated similar one, so the agent checks
128
+ # it (#276). nil outside git, for a file that exists, or when git detects none.
129
+ #: (String) -> Hash[String, untyped]?
130
+ def rename_of(path)
131
+ return unless @repository.inside? && @repository.absent?(path)
132
+
133
+ deleted_in = @repository.text_at("HEAD", path) ? nil : @repository.last_commit(path)
134
+ base = deleted_in ? "#{deleted_in}^" : "HEAD"
135
+ found = @repository.renames_since(base).find { |each| each["from"] == path } or return nil
136
+ found.slice("to", "similarity").merge("deleted_in" => deleted_in)
137
+ end
138
+
139
+ # Each member's key and the hash of its text at commit, leaving out a member that commit cannot find.
140
+ #: (String, Array[Hash[String, untyped]]) -> Hash[String, String]
141
+ def hashes_by_key_at(commit, members)
142
+ members.map { |fields| Member.new(fields) }.each_with_object({}) do |member, found|
143
+ path = member.path
144
+ text = text_at(commit, path) or next
145
+ found[member.key] = MemberFile.for(path: path, text: text).locate(member.locator).fetch("sha256")
146
+ rescue Error
147
+ next
148
+ end
149
+ end
150
+
151
+ # Which of paths, and of the links and files they lead through, differ from HEAD, or are not yet committed.
152
+ #: (Array[String]) -> Array[String]
153
+ def uncommitted(paths)
154
+ @repository.uncommitted(through_links(paths))
155
+ end
156
+
157
+ # Which of paths name a file in the project now, so a file deleted since an audit cited it is not committed as a
158
+ # deletion (#842).
159
+ #: (Array[String]) -> Array[String]
160
+ def existing(paths) = paths.select { |path| @files.file?(path) }
161
+
162
+ # The commit at HEAD now; nil outside git.
163
+ #: () -> String?
164
+ def head = (@repository.head if @repository.inside?)
165
+
166
+ # The commits after commit that changed path, a link on the way to it or the file they lead to, newest
167
+ # first.
168
+ #: (String, String) -> Array[Hash[String, String]]
169
+ def commits_since(commit, path)
170
+ @repository.commits_since(commit, through_links([path]))
171
+ end
172
+
173
+ # Every commit on HEAD's first-parent line that changed any of paths, newest first; with since, only those
174
+ # after it.
175
+ #: (Array[String], ?since: String?) -> Array[Hash[String, String]]
176
+ def commits_touching(paths, since: nil)
177
+ @repository.commits_touching(through_links(paths), since: since)
178
+ end
179
+
180
+ # The commits after commit, up to HEAD, that one move of HEAD brought in, whatever they changed (D73).
181
+ #: (String) -> CommitsBroughtIn
182
+ def commits_brought_in(commit)
183
+ CommitsBroughtIn.new(@repository.commits_since(commit, []).map { |each| each.fetch("commit") })
184
+ end
185
+
186
+ # Whether any commit after commit changed this tool's own folder, under its name now or its earlier one, or one
187
+ # of paths, a link on the way to one or the file they lead to: the only changes that could make a command
188
+ # refuse that did not refuse before (review R8).
189
+ #: (String, Array[String]) -> bool
190
+ def touched_since?(commit, paths)
191
+ !@repository.commits_since(commit, [*OWN_FOLDERS, *through_links(paths)]).empty?
192
+ end
193
+
194
+ private
195
+
196
+ # paths, each with the links on the way to it and the file they lead to, once each.
197
+ #: (Array[String]) -> Array[String]
198
+ def through_links(paths)
199
+ paths.flat_map { |path| @files.text_paths(path) }.uniq
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,42 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "audit_record"
5
+ require_relative "carried_assessments"
6
+ require_relative "loads_to_check"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # The principle's audit record as it stands when an assessment answer is judged against it, each part read from
12
+ # that one record: the members whose loads the answer must decide (#355), the assessments it may carry
13
+ # forward (D49), and the command it ran for each member's check (D63).
14
+ class RecordJudgedAgainst
15
+ # record is the principle's stored record, open or completed, nil when it has none.
16
+ #: (loads: LoadsToCheck, carried: CarriedAssessments, record: AuditRecord?) -> void
17
+ def initialize(loads:, carried:, record:)
18
+ @loads = loads
19
+ @carried = carried
20
+ @record = record
21
+ freeze
22
+ end
23
+
24
+ # The members whose loading the answer decides, and the decisions the record accepted.
25
+ #: LoadsToCheck
26
+ attr_reader :loads
27
+
28
+ # Which members' last assessments the answer may carry forward.
29
+ #: CarriedAssessments
30
+ attr_reader :carried
31
+
32
+ # The command the stored record was given an exit status for, for the member known by key, as
33
+ # check_since_last_audit reads it: nil when it ran none for that member.
34
+ #: (String) -> String?
35
+ def check_run_for(key) = @record&.check_run_for(key)
36
+
37
+ # The line saying what the review of that run rested on, and since when, as check_since_last_audit shows it.
38
+ #: (String) -> Hash[String, untyped]
39
+ def check_review_for(key) = @record&.check_review_for(key) || {}
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,93 @@
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
+ # One member's entry in a completed audit record, filed under the id the member had then: its hash, any
10
+ # acknowledgements since (D33), and the assessment that audit kept for it, with what it was judged on (D49).
11
+ class RecordedEntry
12
+ #: (String, Hash[String, untyped]) -> void
13
+ def initialize(filed_id, entry)
14
+ @filed_id = filed_id
15
+ @entry = entry
16
+ freeze
17
+ end
18
+
19
+ # The assessment the audit kept, with when it was made and what it was judged on; nil when it kept none.
20
+ #: () -> Hash[String, untyped]?
21
+ def assessment = @entry["assessment"]
22
+
23
+ # The path of each file the kept assessment cites as evidence; none when it kept none (#724).
24
+ #: () -> Array[String]
25
+ def cited_paths = (assessment&.fetch("evidence", nil) || []).map { it.fetch("path") }
26
+
27
+ # The kept assessment as the next full audit shows it carried forward, an observed delivery's sighting dated
28
+ # beside it (#680), with whether any evidence it cited changed since, which the block says from each evidence
29
+ # item's hash then (D49).
30
+ #: () { (Locator, String) -> bool } -> Hash[String, untyped]
31
+ def carried_forward(&changed)
32
+ kept = assessment || {}
33
+ evidence = kept.fetch("evidence").zip(kept.fetch("evidence_sha256"))
34
+ kept.slice("assessment", "delivery").merge(sighting(kept), kept.slice("observation", "evidence", "assessed_at"))
35
+ .merge("evidence_changed" => evidence.any? { |each, sha| changed.call(Locator.new(each), sha) })
36
+ end
37
+
38
+ # When the sighting the kept assessment observed was, if observation repeats its observation word for word:
39
+ # its observed_at, or for a record from before sightings were dated, its assessed_at (#680). Nil otherwise.
40
+ #: (String) -> String?
41
+ def repeated_sighting(observation)
42
+ kept = assessment or return nil
43
+ sighted_at(kept) if kept["observation"] == observation
44
+ end
45
+
46
+ # The id the member had when the audit recorded it, when that differs from id, its id now.
47
+ #: (String) -> String?
48
+ def moved_from(id) = (@filed_id unless @filed_id == id)
49
+
50
+ # Why the member, now id with its text hashing to sha256 and judged on grounds, must be judged again, or nil
51
+ # when its kept assessment still describes it: the same text the audit judged, under the same id, map entry
52
+ # and loads (D49). An acknowledged edit is judged again, unless it was undone.
53
+ #: (id: String, sha256: String?, grounds: Hash[String, String]) -> String?
54
+ def judge_because(id:, sha256:, grounds:)
55
+ return "changed_during_last_audit" if @entry["changed_during_last_audit"]
56
+
57
+ kept = assessment or return "not_assessed_before"
58
+ return text_changed if audited_sha256 != sha256
59
+ return "moved" if moved_from(id)
60
+ return "role_or_check_changed" if kept["entry_sha256"] != grounds["entry_sha256"]
61
+
62
+ "loads_changed" if kept["loads_sha256"] != grounds["loads_sha256"]
63
+ end
64
+
65
+ private
66
+
67
+ # When kept's observed delivery was seen, as shown beside it: its observed_at, or for a record from before
68
+ # sightings were dated, its assessed_at, saying so. Empty for a delivery not observed (#680).
69
+ #: (Hash[String, untyped]) -> Hash[String, untyped]
70
+ def sighting(kept)
71
+ return {} unless kept["delivery"] == "observed"
72
+
73
+ { "observed_at" => sighted_at(kept), "observed_at_from_assessed_at" => (true unless kept["observed_at"]) }
74
+ .compact
75
+ end
76
+
77
+ # When the sighting kept was: its observed_at, or its assessed_at in a record from before sightings were dated.
78
+ #: (Hash[String, untyped]) -> String?
79
+ def sighted_at(kept) = kept["observed_at"] || kept["assessed_at"]
80
+
81
+ # The hash of the text the audit judged: the first acknowledgement's starting point when the member has been
82
+ # acknowledged since, since an acknowledgement moves the recorded hash, or else the recorded hash.
83
+ #: () -> String
84
+ def audited_sha256 = acknowledgements.first&.fetch("from_sha256") || @entry.fetch("sha256")
85
+
86
+ #: () -> String
87
+ def text_changed = acknowledgements.empty? ? "text_changed" : "acknowledged_since_last_audit"
88
+
89
+ #: () -> Array[Hash[String, untyped]]
90
+ def acknowledgements = @entry.fetch("acknowledgements", [])
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,97 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "write_up_pointer"
5
+
6
+ module Principles
7
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
8
+ module Models
9
+ # One finding as an audit record holds it: its id, what the audit did about it and where it is written up.
10
+ # AuditRecord reads its findings only through this value (#768).
11
+ class RecordedFinding
12
+ # What a finding an assessment raised carries to its record, through the correction's basis: the one list of
13
+ # those fields, so the basis and the record cannot carry different ones.
14
+ RAISED_FIELDS = %w[id member_ids problem evidence].freeze #: Array[String]
15
+
16
+ #: (Hash[String, untyped]) -> void
17
+ def initialize(fields)
18
+ @fields = fields.freeze #: Hash[String, untyped]
19
+ freeze
20
+ end
21
+
22
+ #: () -> String
23
+ def id = @fields.fetch("id")
24
+
25
+ # The ids of the members it is on, as its audit recorded them.
26
+ #: () -> Array[String]
27
+ def member_ids = @fields.fetch("member_ids")
28
+
29
+ # What it says is wrong, or nil when the record kept no text for it.
30
+ #: () -> String?
31
+ def problem = @fields["problem"]
32
+
33
+ # What its audit did about it: fixed, deferred or accepted.
34
+ #: () -> String?
35
+ def action = @fields["action"]
36
+
37
+ # Where its write-up is, or nil when it was never written up.
38
+ #: () -> WriteUpPointer?
39
+ def recorded_at
40
+ fields = @fields["recorded_at"]
41
+ WriteUpPointer.new(fields) if fields
42
+ end
43
+
44
+ # When it was first raised, when an earlier audit raised it; nil when its own audit did.
45
+ #: () -> String?
46
+ def first_raised_at = @fields["first_raised_at"]
47
+
48
+ # The locators of the evidence it was raised with, or nil when its record keeps none (D64).
49
+ #: () -> Array[Hash[String, untyped]]?
50
+ def evidence = @fields["evidence"]
51
+
52
+ # Whether its record keeps the evidence it was raised with; one written before this tool kept evidence, or kept
53
+ # by a still_holds decision that gave none, keeps none (D64, #856).
54
+ #: () -> bool
55
+ def evidence_kept? = !evidence.nil?
56
+
57
+ # The path of each file it points at: its evidence and its write-up (#724).
58
+ #: () -> Array[String]
59
+ def cited_paths = [*evidence&.map { it.fetch("path") }, recorded_at&.path].compact.uniq
60
+
61
+ # The commit whose record an audit cited for an earlier first_raised_at, when one did (D53).
62
+ #: () -> String?
63
+ def raised_in = @fields["raised_in"]
64
+
65
+ # The one line saying whom it waits on and to decide what, when its deferral says (D52).
66
+ #: () -> String?
67
+ def waits_on = @fields["waits_on"]
68
+
69
+ # Since when it has waited on that, when an earlier audit asked it; nil when its own audit did.
70
+ #: () -> String?
71
+ def waits_on_since = @fields["waits_on_since"]
72
+
73
+ # Whether its audit left it open, deferred or accepted, for the next full audit to decide (D35).
74
+ #: () -> bool
75
+ def open? = %w[deferred accepted].include?(action)
76
+
77
+ # Whether its audit fixed it.
78
+ #: () -> bool
79
+ def fixed? = action == "fixed"
80
+
81
+ # This finding pointed at recorded_at, the move from where it pointed appended to its repointed with when and
82
+ # why (D59), or itself when it already points there, so an identical retry writes nothing (D24).
83
+ #: (WriteUpPointer, at: String, reason: String) -> RecordedFinding
84
+ def moved_to(pointer, at:, reason:)
85
+ return self if recorded_at == pointer
86
+
87
+ moved = { "from" => @fields["recorded_at"], "to" => pointer.to_h, "at" => at, "reason" => reason }
88
+ RecordedFinding.new(@fields.merge("recorded_at" => pointer.to_h,
89
+ "repointed" => [*@fields["repointed"], moved]))
90
+ end
91
+
92
+ # The finding as its record's file holds it.
93
+ #: () -> Hash[String, untyped]
94
+ def to_h = @fields
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,58 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "open_finding"
5
+ require_relative "recorded_finding"
6
+ require_relative "recorded_members"
7
+ require_relative "write_up_repoint"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # The findings an audit record's own audit recorded, whatever it did about each, with the principle, audit time
13
+ # and members that audit recorded them under. AuditRecord#left_open decides whose findings are open; these are
14
+ # only its own. Callers reach them through AuditRecord.
15
+ class RecordedFindings
16
+ #: (Array[Hash[String, untyped]], principle_id: String, audited_at: String?, members: RecordedMembers) -> void
17
+ def initialize(entries, principle_id:, audited_at:, members:)
18
+ @findings = entries.map { |finding| RecordedFinding.new(finding) } #: Array[RecordedFinding]
19
+ @principle_id = principle_id
20
+ @audited_at = audited_at
21
+ @members = members
22
+ freeze
23
+ end
24
+
25
+ # The findings as the record's file holds them.
26
+ #: () -> Array[Hash[String, untyped]]
27
+ def to_a = @findings.map(&:to_h)
28
+
29
+ # Those its audit deferred or accepted, each with the members as it recorded them.
30
+ #: () -> Array[OpenFinding]
31
+ def left_open
32
+ @findings.select(&:open?).map do |finding|
33
+ OpenFinding.new(finding, principle_id: @principle_id, audited_at: @audited_at, members: @members)
34
+ end
35
+ end
36
+
37
+ # The ids of those its audit fixed.
38
+ #: () -> Array[String]
39
+ def fixed_ids = @findings.select(&:fixed?).map(&:id)
40
+
41
+ # These findings with each one a repoint names moved by it, dated at (D59); the last repoint naming one wins.
42
+ #: (Array[WriteUpRepoint], at: String) -> RecordedFindings
43
+ def repointed(repoints, at:)
44
+ by_finding = repoints.to_h { |repoint| [repoint.finding_id, repoint] }
45
+ moved = @findings.map do |finding|
46
+ repoint = by_finding[finding.id]
47
+ repoint ? repoint.applied_to(finding, at: at) : finding
48
+ end
49
+ RecordedFindings.new(moved.map(&:to_h), principle_id: @principle_id, audited_at: @audited_at,
50
+ members: @members)
51
+ end
52
+
53
+ # The path of each file they point at: their evidence and write-ups (#724).
54
+ #: () -> Array[String]
55
+ def cited_paths = @findings.flat_map(&:cited_paths).uniq
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,149 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "accepted_loading"
5
+ require_relative "member"
6
+ require_relative "recorded_entry"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # The members an audit record assessed: each filed under the id it had then, with its key beside it when the
12
+ # two differ, holding its hash then, the result reported for its check (D30), its accepted loading decision
13
+ # and the acknowledgements made for it since (D33). Callers reach them through AuditRecord#members.
14
+ class RecordedMembers
15
+ #: (Hash[String, untyped]) -> void
16
+ def initialize(entries)
17
+ @entries = entries
18
+ freeze
19
+ end
20
+
21
+ # The entries as the record's file holds them.
22
+ #: () -> Hash[String, untyped]
23
+ def to_h = @entries
24
+
25
+ # The entries without when each assessment was made, or when a sighting it gave no date for was dated, which a
26
+ # record made again from the same answer dates anew (#680).
27
+ #: () -> Hash[String, untyped]
28
+ def undated
29
+ @entries.transform_values do |entry|
30
+ kept = entry["assessment"]
31
+ kept ? entry.merge("assessment" => kept.except("assessed_at", "observed_at")) : entry
32
+ end
33
+ end
34
+
35
+ # The path of each file an assessment kept here cites as evidence, carried forward or made now (#724).
36
+ #: () -> Array[String]
37
+ def cited_paths
38
+ @entries.flat_map { |id, entry| RecordedEntry.new(id, entry).cited_paths }.uniq
39
+ end
40
+
41
+ # Whether the text of the member known by key, whose hash is now sha, is the text the record assessed.
42
+ #: (String, String) -> String
43
+ def freshness(key, sha)
44
+ recorded = hash_for(key)
45
+ return "unassessed" unless recorded
46
+
47
+ recorded == sha ? "current" : "changed"
48
+ end
49
+
50
+ # Each hash the record assessed, by the key of the member it belongs to.
51
+ #: () -> Hash[String, String]
52
+ def hashes_by_key
53
+ @entries.to_h { |id, entry| [entry["key"] || id, entry.fetch("sha256")] }
54
+ end
55
+
56
+ # The hash the record assessed for the member known by key, or nil when it assessed no such member.
57
+ #: (String) -> String?
58
+ def hash_for(key)
59
+ entry_for(key)&.fetch("sha256")
60
+ end
61
+
62
+ # The check the record's audit was given an exit status for, for the member known by key: nil when the
63
+ # member had no check then, or its check was reported as not run, so no audit has run it (D30, #227).
64
+ #: (String) -> String?
65
+ def check_run_for(key)
66
+ result = entry_for(key)&.fetch("check_result", nil) or return nil
67
+ result.key?("exit_status") ? result.fetch("command") : nil
68
+ end
69
+
70
+ # What the review of that run rested on, for the member known by key, as the record keeps it: its review_basis
71
+ # and any review_basis_since, or nothing when the record ran no check for it or kept no line (D63).
72
+ #: (String) -> Hash[String, untyped]
73
+ def check_review_as_kept(key)
74
+ return {} unless check_run_for(key)
75
+
76
+ entry_for(key)&.fetch("check_result")&.slice("review_basis", "review_basis_since") || {}
77
+ end
78
+
79
+ # Each member's accepted loading decision, by its id now, found by the key it keeps through moves (#502).
80
+ #: (MapMembers) -> Hash[String, AcceptedLoading]
81
+ def loading_accepted(members)
82
+ members.to_h do |member|
83
+ entry = entry_for(member.key)
84
+ [member.id, entry&.key?("loading_accepted") ? AcceptedLoading.new(entry) : nil]
85
+ end.compact
86
+ end
87
+
88
+ # The acknowledgements made for the member known by key since the record's audit, oldest first (D33).
89
+ #: (String) -> Array[Hash[String, untyped]]
90
+ def acknowledgements_for(key)
91
+ entry_for(key)&.fetch("acknowledgements", nil) || []
92
+ end
93
+
94
+ # The member acknowledged most recently and when, as [id, acknowledged_at], or nil when none was since the
95
+ # record's audit (#668). The times compare as text, which orders them only because every writer gives UTC in
96
+ # ISO 8601.
97
+ #: () -> [String, String]?
98
+ def latest_acknowledgement
99
+ @entries.flat_map do |id, entry|
100
+ (entry["acknowledgements"] || []).map { |change| [id, change.fetch("acknowledged_at")] }
101
+ end.max_by(&:last)
102
+ end
103
+
104
+ # These entries with the member known by key moved to change's to_sha256, and change kept beside it (D33).
105
+ #: (String, Hash[String, untyped]) -> RecordedMembers
106
+ def acknowledged(key, change)
107
+ RecordedMembers.new(@entries.to_h do |id, entry|
108
+ next [id, entry] unless (entry["key"] || id) == key
109
+
110
+ [id, entry.merge("sha256" => change.fetch("to_sha256"),
111
+ "acknowledgements" => [*entry["acknowledgements"], change])]
112
+ end)
113
+ end
114
+
115
+ # The key of the member filed under id: its stored key when it had moved, or else id.
116
+ #: (String) -> String
117
+ def key_of(id)
118
+ @entries.dig(id, "key") || id
119
+ end
120
+
121
+ # The entry of the member known by key, under the id it was filed under, or nil when the record has none.
122
+ #: (String) -> RecordedEntry?
123
+ def recorded(key)
124
+ id, entry = @entries.find { |filed, each| (each["key"] || filed) == key }
125
+ RecordedEntry.new(id, entry) if id && entry
126
+ end
127
+
128
+ # These entries with each assessment an answer carried forward, marked {"carried_forward": true}, replaced by
129
+ # the one last kept for the same member, so the record keeps it with its first date (D49).
130
+ #: (RecordedMembers?) -> RecordedMembers
131
+ def carrying(last)
132
+ RecordedMembers.new(@entries.to_h do |id, entry|
133
+ next [id, entry] unless entry.dig("assessment", "carried_forward")
134
+
135
+ [id, entry.merge("assessment" => last&.recorded(entry["key"] || id)&.assessment).compact]
136
+ end)
137
+ end
138
+
139
+ private
140
+
141
+ # The entry for the member known by key: filed under the id it had then, with its key beside it when the
142
+ # two differ.
143
+ #: (String) -> Hash[String, untyped]?
144
+ def entry_for(key)
145
+ @entries.find { |id, entry| (entry["key"] || id) == key }&.last
146
+ end
147
+ end
148
+ end
149
+ end