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,95 @@
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
+ # A member's own lines at its baseline and now, as a one-member audit's request shows them, so the agent reads
8
+ # the change its quotes are checked against and not the rest of an edit to the file (#379). A long member, such
9
+ # as a whole file, shows only its changed region: the lines between its first and last difference, with a little
10
+ # context (#449). Past a limit even that is counted instead, so a request never carries a file twice (D38).
11
+ class MemberLines
12
+ # The most lines either side shows: a section or a passage fits, and so does the changed region of most edits
13
+ # to a whole file. A first guess, for an alpha test to confirm (#379).
14
+ SHOWN_AT_MOST = 80
15
+
16
+ # The unchanged lines shown on each side of a changed region, as git shows by default (#449).
17
+ CONTEXT_LINES = 3
18
+
19
+ # Each line is kept without its line ending, so a line the agent copies from the request is a whole line it
20
+ # can quote (#451).
21
+ #: (at_baseline: Array[String], now: Array[String]) -> void
22
+ def initialize(at_baseline:, now:)
23
+ @at_baseline = at_baseline.map(&:chomp) #: Array[String]
24
+ @now = now.map(&:chomp) #: Array[String]
25
+ freeze
26
+ end
27
+
28
+ # The member's lines at the baseline and now, each without its line ending.
29
+ #: Array[String]
30
+ attr_reader :at_baseline
31
+
32
+ #: Array[String]
33
+ attr_reader :now
34
+
35
+ # Whether its lines now read otherwise than at the baseline, as a prior finding's evidence item says (#858).
36
+ #: () -> bool
37
+ def changed? = @at_baseline != @now
38
+
39
+ # Both sides' lines while each fits; else their changed region, with where it starts within the member and
40
+ # each side's length; else how many lines each side has.
41
+ #: () -> Hash[String, untyped]
42
+ def shown
43
+ sides = { "at_baseline" => @at_baseline, "now" => @now }
44
+ return sides if fits?(sides.values)
45
+
46
+ counts = sides.transform_values(&:length)
47
+ region = changed_region
48
+ return { "omitted" => "too_long", "lines" => counts } unless fits?(region.values.map { |each| each["lines"] })
49
+
50
+ { "changed_region" => { "context_lines" => CONTEXT_LINES, **region }, "lines" => counts }
51
+ end
52
+
53
+ private
54
+
55
+ #: (Array[Array[String]]) -> bool
56
+ def fits?(sides)
57
+ sides.all? { |lines| lines.length <= SHOWN_AT_MOST }
58
+ end
59
+
60
+ # Each side's lines from CONTEXT_LINES before its first difference to CONTEXT_LINES after its last, with the
61
+ # 1-based line, within the member, the region starts at.
62
+ #: () -> Hash[String, Hash[String, untyped]]
63
+ def changed_region
64
+ same_start = common_length(@at_baseline, @now)
65
+ same_end = common_length(@at_baseline.drop(same_start).reverse, @now.drop(same_start).reverse)
66
+ from = [same_start - CONTEXT_LINES, 0].max
67
+ { "at_baseline" => @at_baseline, "now" => @now }.transform_values do |lines|
68
+ to = [lines.length - same_end + CONTEXT_LINES, lines.length].min
69
+ { "from_line" => from + 1, "lines" => lines[from...to] || [] }
70
+ end
71
+ end
72
+
73
+ # How many lines two sides share from their start.
74
+ #: (Array[String], Array[String]) -> Integer
75
+ def common_length(one, other)
76
+ one.zip(other).take_while { |line, other_line| line == other_line && !other_line.nil? }.length
77
+ end
78
+ end
79
+
80
+ # A member that cannot be placed in its file at the last full audit's commit, as after a move: the commit lacks
81
+ # the file, or the member's reference finds nothing there. An empty side would say it was empty (#457).
82
+ class MemberLinesNotFoundAtLastAudit
83
+ #: () -> void
84
+ def initialize
85
+ freeze
86
+ end
87
+
88
+ # That fact, in place of the lines.
89
+ #: () -> Hash[String, untyped]
90
+ def shown
91
+ { "omitted" => "not_found_at_last_audit" }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,197 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "../data/project_files"
6
+ require_relative "frontmatter_fields"
7
+ require_relative "member_file"
8
+ require_relative "text_size"
9
+ require_relative "agent_loads"
10
+ require_relative "hook_commands"
11
+ require_relative "skill_loads"
12
+
13
+ module Principles
14
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
15
+ module Models
16
+ # When a host loads a member's file into the agent's context, derived from the file's path as the host
17
+ # documents its loading, with the size of what loads (D38). Only Claude Code is derived. A derivation says
18
+ # what the documentation promises, not what loaded in any session. An audit record keeps these loads, and
19
+ # audit-record.schema.json names each field so show-audit prints it; member_loads_spec holds a sample of each
20
+ # branch to hold the two together, so add one there for a new branch.
21
+ class MemberLoads
22
+ # The instruction files Claude Code loads at session start in the folder it starts in, and once a file is
23
+ # read, written or edited in a folder below it, as its memory documentation says. This tool assumes a session
24
+ # started at the project root.
25
+ INSTRUCTIONS = %w[CLAUDE.md .claude/CLAUDE.md CLAUDE.local.md].freeze
26
+
27
+ # The folder Claude Code reads rules from, recursively: a rule loads at start, or with a paths field only
28
+ # on matching files. This tool shows that field as written and never reads its globs.
29
+ RULES = ".claude/rules/"
30
+
31
+ # The fields that give the size of the whole file that loads.
32
+ FILE_SIZE_FIELDS = %w[file_bytes estimated_tokens].freeze #: [String, String]
33
+
34
+ # The fields that give the member's own share of that file, which moving the member out of it would save.
35
+ MEMBER_SIZE_FIELDS = %w[member_bytes member_tokens].freeze #: [String, String]
36
+
37
+ # The fields that give the size of a skill's or agent's description, which loads apart from its body.
38
+ DESCRIPTION_SIZE_FIELDS = %w[description_bytes description_tokens].freeze #: [String, String]
39
+
40
+ # A rule in a .claude/rules folder below the project root, whose loading this tool does not derive yet.
41
+ NESTED_RULE = {
42
+ "when" => "not_determined", "not_derived" => "a rules folder below the project root"
43
+ }.freeze #: Hash[String, String]
44
+
45
+ # The member's loads, from its whole file as files hold it and its own lines in it; a file that cannot be
46
+ # read gives no size, a member that cannot be found there no size of its own, and freshness reports why.
47
+ #: (Data::ProjectFiles, Locator, hooks: HookCommands) -> MemberLoads
48
+ def self.read(files, locator, hooks:)
49
+ text = files.read_text(locator.path)
50
+ member = begin
51
+ MemberFile.for(path: locator.path, text: text).lines_of(locator).join
52
+ rescue Error
53
+ nil
54
+ end
55
+ new(locator, text, hooks: hooks, member_text: member)
56
+ rescue Error
57
+ new(locator, nil, hooks: hooks)
58
+ end
59
+
60
+ # A whole file's loads, with no member's share in it, as LoadedFiles lists each file (#483).
61
+ #: (Data::ProjectFiles, String, hooks: HookCommands) -> MemberLoads
62
+ def self.of_file(files, path, hooks:)
63
+ new(Locator.new("path" => path), files.read_text(path), hooks: hooks)
64
+ rescue Error
65
+ new(Locator.new("path" => path), nil, hooks: hooks)
66
+ end
67
+
68
+ # locator is where the member is, as Member#locator gives it; text is its whole file, or nil when it
69
+ # cannot be read, so its size is left out; member_text is the member's own lines, or nil when it was not
70
+ # found, so its own size is left out.
71
+ #: (Locator, String?, hooks: HookCommands, ?member_text: String?) -> void
72
+ def initialize(locator, text, hooks:, member_text: nil)
73
+ @path = locator.path #: String
74
+ @query = locator.query #: String?
75
+ @text = text
76
+ @hooks = hooks
77
+ @member_text = member_text
78
+ freeze
79
+ end
80
+
81
+ #: () -> Hash[String, untyped]
82
+ def to_h
83
+ named = @hooks.naming(@path)
84
+ { "claude_code" => named.empty? ? claude_code : claude_code.merge("named_in_hooks" => named) }
85
+ end
86
+
87
+ # The moments Claude Code loads the member's file at, as to_h names them.
88
+ #: () -> Array[String]
89
+ def moments
90
+ [claude_code.fetch("when")]
91
+ end
92
+
93
+ # Whether Claude Code loads the file by itself at all, by the facility its path makes it.
94
+ #: () -> bool
95
+ def carried?
96
+ moments != ["no_known_carrier"]
97
+ end
98
+
99
+ # Whether the whole file loads at session start, so it counts towards what a session starts with.
100
+ #: () -> bool
101
+ def at_session_start?
102
+ moments == ["session_start"]
103
+ end
104
+
105
+ # When Claude Code loads the file, by its facility, with the size of what loads.
106
+ #: () -> Hash[String, untyped]
107
+ def claude_code
108
+ found = carrier
109
+ return { "when" => "no_known_carrier" } unless found
110
+
111
+ # A skill's description member costs its description at start, not the file, so it has only its own size.
112
+ return found.merge(member_size) if found["skill_part"]
113
+
114
+ found.merge(size, member_size)
115
+ end
116
+
117
+ # The member's own lines, its share of the file, with tokens estimated as size does; empty when the member
118
+ # was not found.
119
+ #: () -> Hash[String, Integer]
120
+ def member_size = TextSize.new(@member_text).under(MEMBER_SIZE_FIELDS)
121
+
122
+ # The whole file loads, so this is the file's size, with tokens estimated as characters / 4, rounded up; empty
123
+ # when the file cannot be read.
124
+ #: () -> Hash[String, Integer]
125
+ def size = TextSize.new(@text).under(FILE_SIZE_FIELDS)
126
+
127
+ # The size of the file's skill or agent description when it loads at session start, apart from the body, as the
128
+ # skill listing or the subagent list reads it; empty for any other file or moment.
129
+ #: () -> Hash[String, Integer]
130
+ def description_at_start
131
+ return {} unless claude_code.dig("description", "when") == "session_start"
132
+
133
+ TextSize.new(description_text).under(DESCRIPTION_SIZE_FIELDS)
134
+ end
135
+
136
+ private
137
+
138
+ # The description lines of the skill or agent the file is, as its facility reads them; nil for any other file.
139
+ #: () -> String?
140
+ def description_text
141
+ facility&.description_text
142
+ end
143
+
144
+ # The agent or skill the file is, by its path, which owns when its body and description load; nil for any other
145
+ # file.
146
+ #: () -> (AgentLoads | SkillLoads)?
147
+ def facility
148
+ return AgentLoads.new(@path, @text) if AgentLoads::AGENT_FILE.match?(@path)
149
+
150
+ SkillLoads.new(@path, @text, query: @query) if SkillLoads::SKILL_FILE.match?(@path)
151
+ end
152
+
153
+ # When Claude Code loads the file, by the facility its path makes it, or nil for a path it does not load
154
+ # by itself.
155
+ #: () -> Hash[String, untyped]?
156
+ def carrier
157
+ if @path.end_with?(".md")
158
+ facility_loads = facility&.to_h
159
+ return facility_loads if facility_loads
160
+ return rule if @path.start_with?(RULES)
161
+ return NESTED_RULE if nested_rule?
162
+ end
163
+
164
+ instruction_file
165
+ end
166
+
167
+ # Whether the file is in a .claude/rules folder below the project root, whose loading this tool does not derive.
168
+ #: () -> bool
169
+ def nested_rule?
170
+ @path.include?("/#{RULES}")
171
+ end
172
+
173
+ # A rule's loading moment, from whether its frontmatter has a paths field; not determined when its file
174
+ # cannot be read or its frontmatter is one this tool refuses to read as lines.
175
+ #: () -> Hash[String, untyped]
176
+ def rule
177
+ lines = @text&.lines or return { "when" => "not_determined" }
178
+ paths = FrontmatterFields.new(path: @path, lines: lines).field_lines("$.paths")
179
+ return { "when" => "session_start" } unless paths
180
+
181
+ { "when" => "on_files", "paths_field" => paths.map(&:chomp) }
182
+ rescue Error => e
183
+ { "when" => "not_determined", "frontmatter_refused" => e.payload.fetch("code") }
184
+ end
185
+
186
+ # An instruction file's loading moment: at start in the root folder, on files in one below it.
187
+ #: () -> Hash[String, untyped]?
188
+ def instruction_file
189
+ folder = INSTRUCTIONS.filter_map { |name| @path.delete_suffix(name) if @path.end_with?(name) }.min_by(&:length)
190
+ return unless folder
191
+ return { "when" => "session_start" } if folder.empty?
192
+
193
+ { "when" => "on_files", "files" => [folder] } if folder.end_with?("/")
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,219 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "../error"
6
+ require_relative "../data/schema_check"
7
+ require_relative "member"
8
+ require_relative "map_members"
9
+ require_relative "activation"
10
+ require_relative "json_digest"
11
+ require_relative "meta_principle"
12
+
13
+ module Principles
14
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
15
+ module Models
16
+ # One principle's member map: the principle it names and the members that reinforce it, each a project
17
+ # file or a place in one, with the role it plays. Every change returns a new map and leaves this one as
18
+ # it was; MemberMaps saves it. Fields the map holds beyond these are kept as they are.
19
+ class MemberMap
20
+ #: String
21
+ attr_reader :id
22
+
23
+ #: (String, Hash[String, untyped]) -> void
24
+ def initialize(id, fields)
25
+ @id = id
26
+ # The map owns its data: every field, an unknown one too, is copied and frozen down to each string, so no
27
+ # change made in place to what it was built from or what it hands out changes its file or version (#648,
28
+ # #654). The copy goes through the JSON its version digests, which a map's file holds anyway.
29
+ owned = JSON.parse(JSON.generate(fields), freeze: true) #: Hash[String, untyped]
30
+ @members = MapMembers.new(id, owned.fetch("members").map { |each| Member.new(each) }) #: MapMembers
31
+ @fields = owned.merge("members" => @members.map(&:to_h).freeze).freeze #: Hash[String, untyped]
32
+ freeze
33
+ end
34
+
35
+ #: () -> untyped
36
+ def description
37
+ @fields["description"]
38
+ end
39
+
40
+ # Its members as Member values, which say each one's id, key, place and check.
41
+ #: MapMembers
42
+ attr_reader :members
43
+
44
+ # Each member in one of paths, by its id and path, as show-meta-principle-passage reports the members already in
45
+ # an entry file, which the agent judges against the passage (D68).
46
+ #: (Array[String]) -> Array[Hash[String, String]]
47
+ def members_in(paths)
48
+ @members.in_files(paths).map { |member| { "id" => member.id, "path" => member.path } }
49
+ end
50
+
51
+ # Whether this principle's audit keeps the release of principles it ran under, and its next full audit states
52
+ # what changed since: only the meta-principle's, which judges whether the keeping that release ships still
53
+ # works (D68).
54
+ #: () -> bool
55
+ def keeps_package? = meta_principle?
56
+
57
+ # Whether this principle's full audit states which other principles have members no audit has judged as they
58
+ # are now: only the meta-principle's, whose members are meant to bring those changes to an audit (D71).
59
+ #: () -> bool
60
+ def keeps_others_held? = meta_principle?
61
+
62
+ # Whether this is the meta-principle's map with no members, as a setup that wrote its map before the first
63
+ # member, or removing its last member, leaves it. Only this map's having none is out of the way: setup no longer
64
+ # writes it, and its first add-member creates it with that member (T74), while any other principle has none from
65
+ # create-principle until its first add-member (#931).
66
+ #: () -> bool
67
+ def unstarted? = meta_principle? && @members.empty?
68
+
69
+ # The map's version: the digest of what its JSON says, so any change to it gives a new version, and a hand
70
+ # edit or a formatter that only reorders its keys does not (#656).
71
+ #: () -> String
72
+ def version = JsonDigest.new(@fields).to_s
73
+
74
+ # Whether other would write the same file as this map, so saving it would change nothing.
75
+ #: (MemberMap) -> bool
76
+ def same_as?(other) = version == other.version
77
+
78
+ # Each member as this tool prints it: its id beside the fields shown gives it.
79
+ #: () -> Array[Hash[String, untyped]]
80
+ def members_shown
81
+ @members.zip(shown.fetch("members")).map { |member, fields| { "id" => member.id }.merge(fields) }
82
+ end
83
+
84
+ # The map as its file holds it.
85
+ #: () -> Hash[String, untyped]
86
+ def to_h
87
+ @fields
88
+ end
89
+
90
+ # The member with member_id, refused unless the map has it.
91
+ #: (String) -> Hash[String, untyped]
92
+ def member(member_id)
93
+ @members.found(member_id).to_h
94
+ end
95
+
96
+ #: (String) -> MemberMap
97
+ def describing(description)
98
+ MemberMap.new(id, @fields.merge("description" => description))
99
+ end
100
+
101
+ # When the agent said this principle's work is in hand, as the map stores it, or nil when it has not (D38).
102
+ #: () -> Hash[String, untyped]?
103
+ def activation = @fields["activation"]
104
+
105
+ #: (Activation) -> MemberMap
106
+ def activating(activation) = MemberMap.new(id, @fields.merge("activation" => activation.to_h))
107
+
108
+ # This map with member added, refused when the map already has a member with its id.
109
+ #: (Hash[String, untyped]) -> MemberMap
110
+ def adding(member)
111
+ added = Member.new(member)
112
+ new_id = added.id
113
+ if @members.taken?(new_id)
114
+ raise taken(new_id, repair("To change its role", "change-member-role", new_id, "--role ROLE"),
115
+ repair("To point it elsewhere", "move-member", new_id, added.move_placeholders))
116
+ end
117
+
118
+ with_members(@members.to_a + [@members.keyed(added)])
119
+ end
120
+
121
+ # This map with member_id pointed at the place located names, in the same file. The new place replaces
122
+ # the old one whole, and every other field is kept: its role, and any a newer version wrote (D29). It is
123
+ # refused when another member is already there.
124
+ #: (String, Hash[String, untyped]) -> MemberMap
125
+ def moving(member_id, located)
126
+ old = @members.found(member_id)
127
+ moved = old.moved_to(located)
128
+ new_id = moved.id
129
+ if new_id != member_id && @members.taken?(new_id)
130
+ raise taken(new_id, repair("To remove one of them", "remove-member", member_id))
131
+ end
132
+
133
+ replacing(old, moved)
134
+ end
135
+
136
+ #: (String, String) -> MemberMap
137
+ def changing_role(member_id, role) = changing(member_id, "role" => role)
138
+
139
+ # Names the command that enforces a member, which an audit asks the agent to run (D30). This tool
140
+ # stores it and never runs it.
141
+ #: (String, String) -> MemberMap
142
+ def changing_check(member_id, check) = changing(member_id, "check" => check)
143
+
144
+ #: (String) -> MemberMap
145
+ def removing_check(member_id)
146
+ old = @members.found(member_id)
147
+ unless old.to_h.key?("check")
148
+ raise Error.new("no_check", "#{member_id} has no check in #{id}",
149
+ "To see each member's check: #{Error::EXECUTABLE} show-principle #{id}")
150
+ end
151
+
152
+ replacing(old, old.without_check)
153
+ end
154
+
155
+ #: (String) -> MemberMap
156
+ def removing(member_id)
157
+ old = @members.found(member_id)
158
+ with_members(@members.reject { |each| each.equal?(old) })
159
+ end
160
+
161
+ # Member owns the format each member field needs; this name stays while member_map_spec checks it there.
162
+ FIELD_FORMATS = Member::FIELD_FORMATS #: Hash[String, Integer]
163
+
164
+ # The oldest format that holds every member of this map: 1 unless a member uses a later field.
165
+ #: () -> Integer
166
+ def format
167
+ @members.format
168
+ end
169
+
170
+ private
171
+
172
+ # Whether this is the map of the principle that keeps every other one held (D67).
173
+ #: () -> bool
174
+ def meta_principle? = @id == MetaPrinciple::ID
175
+
176
+ # The map with only the fields this tool knows, for printing: a field someone added to the file is kept
177
+ # in it but never shown as if this tool had said it.
178
+ #: () -> Hash[String, untyped]
179
+ def shown
180
+ Data::SchemaCheck.new("member-map").known(@fields)
181
+ end
182
+
183
+ # The refusal for a member id another member of this map has, ending with its repairs.
184
+ #: (String, *Error::RepairCommand) -> Error
185
+ def taken(new_id, *repairs)
186
+ Error.new("member_exists", "#{new_id} is already a member of #{id}", "").ending_with(repairs)
187
+ end
188
+
189
+ # A repair command acting on a member of this map, with the placeholders the agent fills; the controller prints
190
+ # it, quoting the member (D56, #729).
191
+ #: (String, String, String, ?String?) -> Error::RepairCommand
192
+ def repair(purpose, command, member, usage = nil)
193
+ Error::RepairCommand.new(purpose: purpose, command: command, values: [id, member], usage: usage)
194
+ end
195
+
196
+ # The map with member_id's entry holding fields in place of its own.
197
+ #: (String, Hash[String, untyped]) -> MemberMap
198
+ def changing(member_id, fields)
199
+ old = @members.found(member_id)
200
+ replacing(old, old.with(fields))
201
+ end
202
+
203
+ #: (Member, Member) -> MemberMap
204
+ def replacing(old, member)
205
+ with_members(@members.map { |each| each.equal?(old) ? member : each })
206
+ end
207
+
208
+ # Members are kept in id order, so a map's file changes only where a member does.
209
+ # Its format is worked out again, and written only above 1, so a map that uses no later field keeps
210
+ # the bytes an older version wrote, and its version.
211
+ #: (Array[Member]) -> MemberMap
212
+ def with_members(list)
213
+ changed = MemberMap.new(id, @fields.merge("members" => list.sort_by(&:id).map(&:to_h)))
214
+ fields = changed.to_h.except("format")
215
+ MemberMap.new(id, changed.format > 1 ? fields.merge("format" => changed.format) : fields)
216
+ end
217
+ end
218
+ end
219
+ end
@@ -0,0 +1,143 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "member"
6
+ require_relative "member_history"
7
+ require_relative "member_maps"
8
+ require_relative "placement"
9
+ require_relative "project_history"
10
+
11
+ module Principles
12
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
13
+ module Models
14
+ # One map's members as the commits that changed it held them, walking back from HEAD on its first-parent
15
+ # line. Each member's run is the unbroken stretch of those commits whose map holds a member with its key,
16
+ # so its oldest commit is the one that mapped the member where it is (#243). The walk stops at a map it
17
+ # cannot read, so a run may start later than the true mapping; that can only hide an older change.
18
+ class MemberMapHistory
19
+ #: (id: String, history: ProjectHistory, member_maps: MemberMaps) -> void
20
+ def initialize(id:, history:, member_maps:)
21
+ @id = id
22
+ @history = history
23
+ @member_maps = member_maps
24
+ freeze
25
+ end
26
+
27
+ # Each key's run, newest first, as [commit, the member's fields at that commit]; a key HEAD's map does
28
+ # not hold has an empty run. Each commit's map is read once for every key.
29
+ #: (Array[String]) -> Hash[String, Array[[String, Hash[String, untyped]]]]
30
+ def runs(keys)
31
+ runs = keys.to_h { |key| [key, []] } #: Hash[String, Array[[String, Hash[String, untyped]]]]
32
+ open = keys.dup
33
+ @history.commits_touching(map_paths).each do |commit|
34
+ sha = commit.fetch("sha")
35
+ members = members_at(sha) or break
36
+ # A key the map at sha does not hold ends its run.
37
+ open.select! { |key| members.key?(key) && runs.fetch(key).push([sha, members.fetch(key)]) }
38
+ break if open.empty?
39
+ end
40
+ runs
41
+ end
42
+
43
+ # Each key's text at the oldest commit of its run that can locate the member, as {"commit", "sha256"};
44
+ # a key with no such commit is left out.
45
+ #: (Array[String]) -> Hash[String, Hash[String, String]]
46
+ def mapped_baselines(keys)
47
+ runs(keys).each_with_object({}) do |(key, run), found|
48
+ run.reverse_each do |sha, fields|
49
+ hash = @history.hashes_by_key_at(sha, [fields])[key] or next
50
+ found[key] = { "commit" => sha, "sha256" => hash }
51
+ break
52
+ end
53
+ end
54
+ end
55
+
56
+ # The commits that changed member's own text since baseline, walking the commits that touched this map or
57
+ # any file it has placed the member in, so a move to a renamed file keeps its earlier edits (#284).
58
+ #: (Member, baseline: String, now: String) -> Hash[String, untyped]
59
+ def member_history(member, baseline:, now:)
60
+ key = member.key
61
+ now_at = @member_maps.find(@id).member(member.id)
62
+ hashes = {} #: Hash[String, String?]
63
+ hash_at = ->(revision) { hashes.fetch(revision) { hashes[revision] = hash_at(key, now_at, revision) } }
64
+ commits = @history.commits_touching([*paths_of(member), *map_paths])
65
+ MemberHistory.new(baseline: baseline, now: now, commits: commits, hash_at: hash_at).to_h
66
+ end
67
+
68
+ # The map as commit holds it, such as the one an audit began from, refused as MemberMaps refuses its text;
69
+ # a commit before the folder took its name now holds it under the earlier name (#536), and a commit that lacks
70
+ # the map under both gives empty text, which is refused as unparseable.
71
+ #: (String) -> MemberMap
72
+ def map_at(commit)
73
+ text = map_paths.lazy.filter_map { |path| @history.text_at(commit, path) }.first
74
+ @member_maps.parsed(@id, text.to_s, commit: commit)
75
+ end
76
+
77
+ # The member's fields as the map at commit placed it, found by key; nil where that map did not hold it or cannot
78
+ # be read, so a one-member audit reads a moved member where it was at its baseline (#547).
79
+ #: (String, String) -> Hash[String, untyped]?
80
+ def placed_at(commit, key)
81
+ members_at(commit)&.fetch(key, nil)
82
+ end
83
+
84
+ # Where the member with key may be read at since and at each later commit that touched its map or a file the
85
+ # map has placed it in, oldest first: as each commit's map placed it, then as now_at, its entry now, places it.
86
+ # Lazy, so a search for the baseline reads no further than the commit that holds it (#550).
87
+ #: (String, String, Hash[String, untyped]) -> Enumerator::Lazy[Placement]
88
+ def placements_since(since, key, now_at)
89
+ paths = [*paths_of(Member.new(now_at)), *map_paths]
90
+ later = @history.commits_touching(paths, since: since).map { |commit| commit.fetch("sha") }.reverse
91
+ [since, *later].lazy.flat_map { |commit| placements_at(commit, key, now_at) }
92
+ end
93
+
94
+ # The paths a change to the member with key from commit to now_at, its entry now, spans: as commit's map placed
95
+ # it, or as now_at does where that map did not hold it, then its path now (#557).
96
+ #: (String, String, Hash[String, untyped]) -> Array[String]
97
+ def paths_from(commit, key, now_at)
98
+ placements_at(commit, key, now_at).fetch(0).paths_to(Member.new(now_at).path)
99
+ end
100
+
101
+ private
102
+
103
+ # The map's file now and under the folder's earlier name, so a walk of its history reaches back past the move.
104
+ #: () -> Array[String]
105
+ def map_paths = [@member_maps.file(@id), @member_maps.earlier_file(@id)]
106
+
107
+ # The member's placements at commit, as that commit's map placed it and as now_at does, once each.
108
+ #: (String, String, Hash[String, untyped]) -> Array[Placement]
109
+ def placements_at(commit, key, now_at)
110
+ fields_at = [members_at(commit)&.fetch(key, nil), now_at].compact
111
+ fields_at.uniq { |fields| Member.new(fields).reference }.map do |fields|
112
+ Placement.new(commit: commit, reference: Member.new(fields).reference,
113
+ sha256: @history.hashes_by_key_at(commit, [fields]).values.first)
114
+ end
115
+ end
116
+
117
+ # Every path this map has given member, newest first, from its run back from HEAD.
118
+ #: (Member) -> Array[String]
119
+ def paths_of(member)
120
+ earlier = runs([member.key]).fetch(member.key).map { |_sha, fields| fields.fetch("path") }
121
+ [member.path, *earlier].uniq
122
+ end
123
+
124
+ # The hash of the member with key at revision, located as this map at revision places it. Where that map
125
+ # holds the member but its place cannot be found there, as at the commit that renamed its file before the
126
+ # map followed, it is located as now_at, its entry in the map now, places it. nil when the map at revision
127
+ # does not hold it.
128
+ #: (String, Hash[String, untyped], String) -> String?
129
+ def hash_at(key, now_at, revision)
130
+ fields = members_at(revision)&.fetch(key, nil) or return nil
131
+ @history.hashes_by_key_at(revision, [fields])[key] || @history.hashes_by_key_at(revision, [now_at])[key]
132
+ end
133
+
134
+ # Each member the map at commit holds, by key, or nil when that commit lacks the map or MemberMaps refuses it.
135
+ #: (String) -> Hash[String, Hash[String, untyped]]?
136
+ def members_at(commit)
137
+ map_at(commit).members.to_h { |member| [member.key, member.to_h] }
138
+ rescue Error
139
+ nil
140
+ end
141
+ end
142
+ end
143
+ end