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,57 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "evidence_item"
6
+ require_relative "member_lines"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # The lines one evidence item of a prior finding names at the commit the audit that kept the finding was based on,
12
+ # and now, either nil where they cannot be found: what the next full audit's request says of the item (#858). A
13
+ # removed pointer needs finding again and a reworded one judging again, so whether it is found now is said apart
14
+ # from whether it changed (#877).
15
+ class EvidenceLinesSince
16
+ # The lines item names in project at since and now, either nil where it cannot be found or read, so one moved or
17
+ # removed pointer never refuses the request.
18
+ #: (EvidenceItem, since: String, project: Project) -> EvidenceLinesSince
19
+ def self.of(item, since:, project:)
20
+ new(at_base: unless_refused { item.lines_at(project.history, since) },
21
+ now: unless_refused { item.lines_now(project) })
22
+ end
23
+
24
+ # What the block reads, or nil when it is refused.
25
+ #: () { () -> Array[String]? } -> Array[String]?
26
+ def self.unless_refused(&)
27
+ yield
28
+ rescue Error
29
+ nil
30
+ end
31
+ private_class_method :unless_refused
32
+
33
+ #: (at_base: Array[String]?, now: Array[String]?) -> void
34
+ def initialize(at_base:, now:)
35
+ @at_base = at_base
36
+ @now = now
37
+ freeze
38
+ end
39
+
40
+ # Whether its text reads otherwise now than at the base commit, true too when it cannot be found then or now, and
41
+ # whether it is found now.
42
+ #: () -> Hash[String, bool]
43
+ def facts = { "changed_since_last_audit" => changed?, "found_now" => !@now.nil? }
44
+
45
+ private
46
+
47
+ #: () -> bool
48
+ def changed?
49
+ at_base = @at_base
50
+ now = @now
51
+ return true unless at_base && now
52
+
53
+ MemberLines.new(at_baseline: at_base, now: now).changed?
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,99 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "locator"
6
+ require_relative "answer_fields"
7
+ require_relative "answer_place"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # The new places a still_holds decision gives a kept finding's evidence items that are no longer found, each
13
+ # problem_evidence entry naming by replaces the position of the item it replaces (D70). An item still found stays
14
+ # as the audit raised it, so a decision never rewrites what an audit found (D64); git keeps the old pointer.
15
+ class EvidenceReplacements
16
+ # The field an entry names the kept item it replaces by, which the record does not keep.
17
+ REPLACES = "replaces"
18
+
19
+ # kept is the finding's evidence as its record keeps it, in order; entries are the decision's problem_evidence.
20
+ #: (Array[Hash[String, untyped]], Array[Hash[String, untyped]]) -> void
21
+ def initialize(kept, entries)
22
+ @kept = kept
23
+ @entries = entries
24
+ freeze
25
+ end
26
+
27
+ # Refuses, through refuse with a reason and a repair, an entry that names no kept item by replaces, names one
28
+ # twice, or names one locate still finds, and has locate find each new pointer, at place.
29
+ #: (^(Locator) -> void, String, AnswerPlace, refuse: ^(String, String?) -> void) -> void
30
+ def check(locate, id, place, refuse:)
31
+ positions(id, refuse)
32
+ @entries.each do |entry|
33
+ index = entry.fetch(REPLACES)
34
+ next unless found?(locate, @kept.fetch(index))
35
+
36
+ refuse.call("evidence item #{index} is still found, so a decision may not replace it",
37
+ "Drop that entry from #{id}'s problem_evidence, or raise it again as a new finding to " \
38
+ "restate its evidence.")
39
+ end
40
+ AnswerFields.new(locate).evidence(pointers, place)
41
+ end
42
+
43
+ # The evidence the record keeps: each kept item, with each one an entry names swapped for its new pointer.
44
+ #: () -> Array[Hash[String, untyped]]
45
+ def evidence
46
+ by_index = @entries.to_h { |entry| [entry.fetch(REPLACES), entry.except(REPLACES)] }
47
+ @kept.each_with_index.map { |item, index| by_index.fetch(index, item) }
48
+ end
49
+
50
+ # Each swap, as the completed result names it for finding id: the pointer replaced and its new one.
51
+ #: (String) -> Array[Hash[String, untyped]]
52
+ def swaps(id)
53
+ @entries.map do |entry|
54
+ { "finding_id" => id, "replaced" => @kept.fetch(entry.fetch(REPLACES)), "with" => entry.except(REPLACES) }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # The new pointers, as their record keeps them.
61
+ #: () -> Array[Hash[String, untyped]]
62
+ def pointers = @entries.map { |entry| entry.except(REPLACES) }
63
+
64
+ # Refuses an entry without replaces, one naming no kept item, and two naming the same one.
65
+ #: (String, ^(String, String?) -> void) -> void
66
+ def positions(id, refuse)
67
+ named = @entries.map { |entry| entry[REPLACES] }
68
+ unnamed(id, refuse) if named.any?(nil)
69
+ stray = named.grep_v(0...@kept.size).first
70
+ if stray
71
+ refuse.call("replaces #{stray} names no item of its evidence",
72
+ "Name by replaces a position in #{id}'s evidence, from 0 to #{@kept.size - 1}.")
73
+ end
74
+ twice = named.find { |index| named.count(index) > 1 } or return
75
+ refuse.call("replaces names evidence item #{twice} twice",
76
+ "Give each item of #{id}'s evidence at most one entry in its problem_evidence.")
77
+ end
78
+
79
+ # Refuses entries that do not say which kept item each replaces, since the record keeps the evidence it was
80
+ # raised with.
81
+ #: (String, ^(String, String?) -> void) -> void
82
+ def unnamed(id, refuse)
83
+ refuse.call("its record keeps the evidence it was raised with, so each problem_evidence entry must name by " \
84
+ "replaces the item no longer found that it replaces",
85
+ "Give each entry replaces, the position in #{id}'s evidence of an item no longer found, " \
86
+ "counting from 0, or drop problem_evidence.")
87
+ end
88
+
89
+ # Whether locate finds item now.
90
+ #: (^(Locator) -> void, Hash[String, untyped]) -> bool
91
+ def found?(locate, item)
92
+ locate.call(Locator.new(item))
93
+ true
94
+ rescue Error
95
+ false
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,108 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "missing_files"
5
+ require_relative "open_finding"
6
+ require_relative "project"
7
+
8
+ module Principles
9
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
10
+ module Models
11
+ # Some open findings, each with its write-up opening where its recorded_at points, as the project's file stands
12
+ # now (#300).
13
+ class FindingWriteUps
14
+ # What to do about a recorded_at that no longer resolves, worded for a write-up rather than a member.
15
+ REPAIR = "Fix the write-up, or point recorded_at at it again with start-audit --write-ups, without a full audit."
16
+ # What the repair adds when git detects a rename for the write-up's missing file.
17
+ RENAMED = " git_detected_rename is git's match for its file, to #{MissingFiles::RENAME_MATCH}".freeze #: String
18
+
19
+ # The findings records keep open for the next audit, in project.
20
+ #: (Project, Array[AuditRecord]) -> FindingWriteUps
21
+ def self.of(project, records) = new(project, records.flat_map(&:left_open))
22
+
23
+ #: (Project, Array[OpenFinding]) -> void
24
+ def initialize(project, findings)
25
+ @project = project
26
+ @findings = findings
27
+ freeze
28
+ end
29
+
30
+ # Each finding with its write_up_opening, as list-open-findings prints them.
31
+ #: () -> Array[Hash[String, untyped]]
32
+ def listed
33
+ @findings.map { |finding| finding.listed.merge(opening_fields(finding.recorded_at)) }
34
+ end
35
+
36
+ # The findings that are written up somewhere, each with the write-up opening list-open-findings will print for
37
+ # it, so a pointer that lands on a generic line is seen before the answer is submitted (#411).
38
+ #: () -> Array[Hash[String, untyped]]
39
+ def written_up
40
+ fields = %w[finding_id write_up_opening write_up_opening_reason write_up_opening_error]
41
+ listed.select { |finding| finding["recorded_at"] }.map { |finding| finding.slice(*fields) }
42
+ end
43
+
44
+ # The findings whose write-up can no longer be found where recorded_at points, each by its principle and
45
+ # finding_id, which list-open-findings --short names so a session-start hook shows the broken pointer (#694).
46
+ # It asks git for no rename, which only the full list prints.
47
+ #: () -> Array[Hash[String, untyped]]
48
+ def not_found
49
+ @findings.select { |finding| unresolved?(finding.recorded_at) }
50
+ .map { |finding| { "principle" => finding.principle_id, "finding_id" => finding.id } }
51
+ end
52
+
53
+ private
54
+
55
+ # pointer's write_up_opening, or null with the reason: not_recorded, empty_region, or unavailable with the
56
+ # refusal, so one pointer that no longer resolves never refuses the whole list.
57
+ #: (WriteUpPointer?) -> Hash[String, untyped]
58
+ def opening_fields(pointer) = pointer ? opening_found(pointer) : none("not_recorded")
59
+
60
+ # The write_up_opening pointer leads to, or null with why: empty_region, or unavailable with the refusal.
61
+ #: (WriteUpPointer) -> Hash[String, untyped]
62
+ def opening_found(pointer)
63
+ opening = pointer.opening_in(@project)
64
+ opening ? { "write_up_opening" => opening } : none("empty_region")
65
+ rescue Error => e
66
+ none("unavailable").merge("write_up_opening_error" => unavailable(e.payload, pointer))
67
+ end
68
+
69
+ # Whether a recorded pointer no longer resolves in the project's file now.
70
+ #: (WriteUpPointer?) -> bool
71
+ def unresolved?(pointer)
72
+ return false unless pointer
73
+
74
+ pointer.opening_in(@project)
75
+ false
76
+ rescue Error
77
+ true
78
+ end
79
+
80
+ # The refusal that left a write-up unavailable, with any rename git detects for its file when that file is
81
+ # missing. The agent checks the match before pointing recorded_at there: this tool does not decide the match
82
+ # is the write-up, as for a member's missing file (#276, #694).
83
+ #: (Hash[String, untyped], WriteUpPointer) -> Hash[String, untyped]
84
+ def unavailable(payload, pointer)
85
+ refused = payload.slice("code", "message")
86
+ rename = @project.history.rename_of(pointer.path) if refused["code"] == "unreadable_file"
87
+ return refused.merge("repair" => REPAIR) unless rename
88
+
89
+ refused.merge("git_detected_rename" => rename.merge(starting_at_found(pointer.in_file(rename.fetch("to")))),
90
+ "repair" => REPAIR + RENAMED)
91
+ end
92
+
93
+ # Whether moved, the pointer with its starting_at in the file git matched, still resolves there: its
94
+ # starting_at occurs exactly once there, within its heading's section when it names one, as a member's passage
95
+ # must. A deterministic check of git's match, which the agent then reads as one fact; nothing for a pointer
96
+ # with no starting_at (#722).
97
+ #: (WriteUpPointer) -> Hash[String, bool]
98
+ def starting_at_found(moved)
99
+ moved.starting_at? ? { "starting_at_found_in_rename" => !unresolved?(moved) } : {}
100
+ end
101
+
102
+ #: (String) -> Hash[String, untyped]
103
+ def none(reason)
104
+ { "write_up_opening" => nil, "write_up_opening_reason" => reason }
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,34 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "open_finding"
5
+
6
+ module Principles
7
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
8
+ module Models
9
+ # Every finding the records leave open, deferred or accepted, as one fact the session-start line states: how
10
+ # many wait, and which has waited longest (D47).
11
+ class FindingsLeftOpen
12
+ # The findings each record leaves open for the next full audit.
13
+ #: (Array[AuditRecord]) -> FindingsLeftOpen
14
+ def self.of(records) = new(records.flat_map(&:left_open))
15
+
16
+ #: (Array[OpenFinding]) -> void
17
+ def initialize(findings)
18
+ @findings = findings
19
+ freeze
20
+ end
21
+
22
+ # How many are open, how many of them record no waits_on (#736), and the one first raised earliest as it
23
+ # has waited until now, or nil when none is open. How long it waited runs from first_raised_at, never from when
24
+ # its waits_on was asked, which a later audit may have given it (D52).
25
+ #: (Time) -> Hash[String, untyped]?
26
+ def waiting_at(now)
27
+ oldest = @findings.min_by(&:first_raised_time) or return nil
28
+
29
+ { "count" => @findings.length, "without_waits_on" => @findings.count(&:without_waits_on?),
30
+ **oldest.waited_at(now) }
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,128 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require_relative "../error"
6
+ require_relative "audit_records"
7
+ require_relative "open_finding"
8
+
9
+ module Principles
10
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
11
+ module Models
12
+ # A still_holds decision's claim that its finding was first raised earlier than the record says, citing in
13
+ # raised_in the commit whose audit record left it open then (D53). Whether that record's finding is the same
14
+ # finding is the agent's judgement. This tool checks only the facts the claim cites, at that one commit: the
15
+ # record there leaves a finding with the same id deferred or accepted, its audit was made at the claimed time,
16
+ # and that time is earlier than the one stored. It walks no history.
17
+ class FirstRaisedClaim
18
+ FIELDS = %w[first_raised_at raised_in].freeze
19
+
20
+ # decision is the agent's decision on finding, the prior finding as its record left it open.
21
+ #: (Hash[String, untyped], OpenFinding) -> void
22
+ def initialize(decision, finding)
23
+ @decision = decision
24
+ @id = finding.id #: String
25
+ @stored = finding.first_raised_at #: String?
26
+ freeze
27
+ end
28
+
29
+ # Refuses the claim unless it is whole, on a still_holds decision, and the record of principle_id that records
30
+ # reads at its commit bears it out; a decision that claims nothing passes, and needs no records.
31
+ #: (AuditRecords?, String) -> void
32
+ def check(records, principle_id)
33
+ return if @decision.slice(*FIELDS).empty?
34
+
35
+ whole
36
+ records or raise ArgumentError, "a claim on #{@id} is checked only against the audit records"
37
+ borne_out(*read(records, principle_id))
38
+ raise not_earlier unless earlier?
39
+ end
40
+
41
+ private
42
+
43
+ # Refuses unless version, the record read from path, leaves this finding open from an audit at the claimed time.
44
+ #: (String, AuditRecord) -> void
45
+ def borne_out(path, version)
46
+ open = version.open_findings.find { |finding| finding.fetch("finding_id") == @id } or raise lacking(path)
47
+ audited_at = open.fetch("audited_at")
48
+ raise time_differs(path, audited_at) unless audited_at == claimed
49
+ end
50
+
51
+ #: () -> String
52
+ def claimed = @decision.fetch("first_raised_at")
53
+
54
+ #: () -> String
55
+ def commit = @decision.fetch("raised_in")
56
+
57
+ #: () -> void
58
+ def whole
59
+ unless @decision["decision"] == "still_holds"
60
+ invalid("only still_holds may give first_raised_at and raised_in",
61
+ "Drop first_raised_at and raised_in from #{@id}'s decision, or decide it still_holds.")
62
+ end
63
+ return if FIELDS.all? { |field| @decision.key?(field) }
64
+
65
+ invalid("first_raised_at and raised_in go together",
66
+ "Give both: first_raised_at, the audited_at of the record that first left #{@id} open, and " \
67
+ "raised_in, the full sha of the commit holding that record; or give neither.")
68
+ end
69
+
70
+ # The record's path and the record as the claimed commit held it, refused when it holds none or cannot be read.
71
+ #: (AuditRecords, String) -> [String, AuditRecord]
72
+ def read(records, principle_id)
73
+ records.at(principle_id, commit) or raise not_found(records, principle_id)
74
+ rescue Error => e
75
+ raise unless %w[invalid_audit_record unparseable_audit].include?(e.payload.fetch("code"))
76
+
77
+ raise Error.new("raised_in_unreadable", "Prior finding #{@id}: the record at raised_in #{commit} cannot be " \
78
+ "read: #{e.payload.fetch("message")}",
79
+ "Cite another commit whose record holds #{@id}, or drop first_raised_at and raised_in.",
80
+ fields: e.payload["fields"])
81
+ end
82
+
83
+ #: () -> bool
84
+ def earlier?
85
+ Time.iso8601(claimed) < Time.iso8601(@stored.to_s)
86
+ rescue ArgumentError
87
+ false
88
+ end
89
+
90
+ #: (AuditRecords, String) -> Error
91
+ def not_found(records, principle_id)
92
+ paths = "#{records.file(principle_id)} or #{records.earlier_file(principle_id)}"
93
+ Error.new("raised_in_not_found", "Prior finding #{@id}: commit #{commit} holds no audit record at #{paths}",
94
+ "Set raised_in to the full sha of a commit holding #{paths} that left #{@id} open, as " \
95
+ "git log --follow on the record finds it; outside git, drop first_raised_at and raised_in.")
96
+ end
97
+
98
+ #: (String) -> Error
99
+ def lacking(path)
100
+ Error.new("raised_in_lacks_finding",
101
+ "Prior finding #{@id}: #{path} at #{commit} leaves no finding #{@id} deferred or accepted",
102
+ "Set raised_in to a commit whose record leaves #{@id} deferred or accepted, or drop " \
103
+ "first_raised_at and raised_in.")
104
+ end
105
+
106
+ #: (String, String) -> Error
107
+ def time_differs(path, audited_at)
108
+ Error.new("raised_in_time_differs",
109
+ "Prior finding #{@id}: #{path} at #{commit} was audited at #{audited_at}, not #{claimed}",
110
+ "Set first_raised_at to #{audited_at}, that record's audited_at, or cite the commit of the record " \
111
+ "audited at #{claimed}.")
112
+ end
113
+
114
+ #: () -> Error
115
+ def not_earlier
116
+ Error.new("first_raised_not_earlier",
117
+ "Prior finding #{@id}: #{claimed} is not earlier than its first_raised_at, #{@stored}",
118
+ "Drop first_raised_at and raised_in from #{@id}'s decision; the record keeps #{@stored}, and only " \
119
+ "an earlier record replaces it.")
120
+ end
121
+
122
+ #: (String, String) -> bot
123
+ def invalid(why, repair)
124
+ raise Error.new("invalid_decision", "Prior finding #{@id}: #{why}", repair)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,128 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "json_path_query"
6
+
7
+ module Principles
8
+ # The domain logic, which loads and saves itself through data/. models/CLAUDE.md owns the boundary.
9
+ module Models
10
+ # The YAML frontmatter at the head of a Markdown file, read as lines and never parsed as YAML, since a
11
+ # YAML parser over untrusted bytes is attack surface. A field is a top-level `key:` line and the indented,
12
+ # list or blank lines after it; its member is those lines exactly. This tool never interprets a value, so it
13
+ # refuses only what would make a field's lines differ from what the harness reads: an anchor, an alias or
14
+ # a tag, or a line it cannot place in a field.
15
+ class FrontmatterFields
16
+ # A top-level field's opening line, and its name.
17
+ FIELD = /\A([A-Za-z0-9_-]+):(?:\s|\z)/ #: Regexp
18
+
19
+ # A node that starts with an anchor, an alias or a tag, after a key or a list dash.
20
+ INDIRECT = /\A\s*(?:-\s+|[^\s:#][^:#]*:\s+)[&*!]/ #: Regexp
21
+
22
+ # A value that is a flow collection, after a key or a list dash, such as [a, b] or {a: b}.
23
+ FLOW = /\A\s*(?:-\s+|[^\s:#][^:#]*:\s+)([\[{].*)/ #: Regexp
24
+
25
+ # A quoted scalar, whose text is never a node of its own.
26
+ QUOTED = /"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'/ #: Regexp
27
+
28
+ # A node inside a flow collection that starts with an anchor, an alias or a tag.
29
+ FLOW_INDIRECT = /[\[{,:]\s*[&*!]/ #: Regexp
30
+
31
+ #: (path: String, lines: Array[String]) -> void
32
+ def initialize(path:, lines:)
33
+ @path = path
34
+ @lines = lines
35
+ freeze
36
+ end
37
+
38
+ # The line range of the field query names: from its key line through its last non-blank line.
39
+ #: (String) -> [Integer, Integer]
40
+ def field(query)
41
+ name = JsonPathQuery.new(query).top_level_name or refuse_query(query)
42
+ found = fields.select { |field_name, _| field_name == name }
43
+ if found.empty?
44
+ raise Error.new("field_not_found", "#{@path} has no frontmatter field #{name.inspect}",
45
+ "Name a top-level field its frontmatter has, such as $.description.", @path)
46
+ end
47
+ refuse_duplicate(name) if found.length > 1
48
+
49
+ found.fetch(0).last
50
+ end
51
+
52
+ # The lines of the field query names, as written, or nil when the frontmatter has no such field; every
53
+ # other refusal still refuses.
54
+ #: (String) -> Array[String]?
55
+ def field_lines(query)
56
+ first, last = field(query)
57
+ (first...last).map { |index| @lines.fetch(index) }
58
+ rescue Error => e
59
+ raise unless e.payload.fetch("code") == "field_not_found"
60
+ end
61
+
62
+ private
63
+
64
+ # Each top-level field's name and line range, in order; refused on a line this tool would misread.
65
+ #: () -> Array[[String, [Integer, Integer]]]
66
+ def fields
67
+ closing = closing_line or return []
68
+ starts = (1...closing).select { |i| opens_field?(i) }
69
+ starts.each_with_index.map do |start, n|
70
+ [@lines.fetch(start)[FIELD, 1].to_s, [start, last_filled(start, (starts[n + 1] || closing) - 1) + 1]]
71
+ end
72
+ end
73
+
74
+ # The index of the --- that closes the frontmatter, or nil when the text opens with none.
75
+ #: () -> Integer?
76
+ def closing_line
77
+ return nil unless @lines.first&.chomp == "---"
78
+
79
+ (1...@lines.length).find { |i| @lines.fetch(i).chomp == "---" }
80
+ end
81
+
82
+ # The last line from start to last that is not blank, so trailing blank lines stay out of a field.
83
+ #: (Integer, Integer) -> Integer
84
+ def last_filled(start, last)
85
+ last -= 1 while last > start && @lines.fetch(last).strip.empty?
86
+ last
87
+ end
88
+
89
+ # Whether line i opens a field, after refusing what this tool cannot read as lines.
90
+ #: (Integer) -> bool
91
+ def opens_field?(index)
92
+ line = @lines.fetch(index)
93
+ refuse_line(index, "an anchor, alias or tag") if line.match?(INDIRECT) || indirect_in_flow?(line)
94
+ return true if line.match?(FIELD)
95
+ return false if line.strip.empty? || line.start_with?(" ", "\t", "-", "#")
96
+
97
+ refuse_line(index, "a line that is not a top-level field")
98
+ end
99
+
100
+ # Whether line's value is a flow collection holding an anchor, alias or tag outside its quoted text.
101
+ #: (String) -> bool
102
+ def indirect_in_flow?(line)
103
+ flow = line[FLOW, 1] or return false
104
+ flow.gsub(QUOTED, "\"\"").match?(FLOW_INDIRECT)
105
+ end
106
+
107
+ #: (Integer, String) -> bot
108
+ def refuse_line(index, what)
109
+ raise Error.new("unsupported_frontmatter", "#{@path} line #{index + 1} holds #{what}",
110
+ "Frontmatter is read as plain fields. Write the value out in full, or map the " \
111
+ "file with --starting-at TEXT instead.", @path).including("line" => index + 1)
112
+ end
113
+
114
+ #: (String) -> bot
115
+ def refuse_query(query)
116
+ raise Error.new("unsupported_query", "#{query.inspect} is not a supported frontmatter query",
117
+ "Name one top-level field, such as $.description, or $['allowed-tools'] for a name " \
118
+ "with a hyphen.", @path)
119
+ end
120
+
121
+ #: (String) -> bot
122
+ def refuse_duplicate(name)
123
+ raise Error.new("duplicate_frontmatter_key", "#{@path} names #{name.inspect} more than once",
124
+ "Keep one #{name} field; the harness may read either.", @path)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,100 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../error"
5
+ require_relative "../data/project_files"
6
+
7
+ module Principles
8
+ # The domain logic, and the mediation of persistence. models/CLAUDE.md owns the boundary.
9
+ module Models
10
+ # The command hooks a project's settings files declare, and which of them name a file. A command names a file
11
+ # when its command or one of its args gives the file's path from the project root, bare or after the project
12
+ # placeholder, with nothing else joined to either end. That is a fact about the string: this tool does not run
13
+ # the command, and says nothing of whether its output reaches the agent's context. It reads only command
14
+ # handlers, not prompt or agent ones, as the hooks documentation shapes them: event, matcher group, handler.
15
+ class HookCommands
16
+ # The project placeholder before a path, bare, braced or quoted, which stands for the project root.
17
+ PROJECT_DIR = %r{"?\$\{?CLAUDE_PROJECT_DIR\}?"?/} #: Regexp
18
+
19
+ # The project settings files whose hooks Claude Code runs in this checkout: the committed one and the local.
20
+ SETTINGS = %w[.claude/settings.json .claude/settings.local.json].freeze #: Array[String]
21
+
22
+ # The command hooks in each project settings file present, or the code each unreadable one was refused with.
23
+ #: (Data::ProjectFiles) -> HookCommands
24
+ def self.read(files)
25
+ new(SETTINGS.select { |path| files.file?(path) }.to_h do |path|
26
+ [path, files.parse_json(files.read_text(path), path)]
27
+ rescue Error => e
28
+ [path, e.payload.fetch("code")]
29
+ end)
30
+ end
31
+
32
+ # settings maps each settings file's path to its parsed JSON, or to the code this tool refused it with.
33
+ #: (Hash[String, untyped]) -> void
34
+ def initialize(settings)
35
+ @settings = settings
36
+ freeze
37
+ end
38
+
39
+ # Each command handler that names the file at path, with the settings file and event it is in, and its
40
+ # matcher, command and args as written.
41
+ #: (String) -> Array[Hash[String, untyped]]
42
+ def naming(path)
43
+ named = %r{(?:\A|[\s"'=])(?:\./)?#{Regexp.escape(path)}(?=\z|[\s"';|&)])}
44
+ handlers.select do |entry|
45
+ [entry["command"], *entry["args"]].any? { |text| text.to_s.gsub(PROJECT_DIR, "").match?(named) }
46
+ end
47
+ end
48
+
49
+ # Each settings file this tool could not read, or whose hooks are not the documented shape, with why.
50
+ #: () -> Array[Hash[String, String]]
51
+ def not_read
52
+ @settings.filter_map do |path, parsed|
53
+ code = parsed.is_a?(String) ? parsed : ("unexpected_hooks_shape" unless events(parsed))
54
+ { "path" => path, "code" => code } if code
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # Every command handler in every readable settings file, in file and declaration order.
61
+ #: () -> Array[Hash[String, untyped]]
62
+ def handlers
63
+ @settings.flat_map do |path, parsed|
64
+ (events(parsed) || []).flat_map do |event, groups|
65
+ groups.flat_map { |group| commands(group).map { |hook| entry(path, event, group, hook) } }
66
+ end
67
+ end
68
+ end
69
+
70
+ #: (String, String, Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
71
+ def entry(path, event, group, hook)
72
+ { "settings" => path, "event" => event, "matcher" => group["matcher"], "command" => hook["command"],
73
+ "args" => hook["args"] }.compact
74
+ end
75
+
76
+ # The settings' hooks by event, or nil when they are not the documented shape; none when there are none.
77
+ #: (untyped) -> Hash[String, Array[Hash[String, untyped]]]?
78
+ def events(parsed)
79
+ return nil unless parsed.is_a?(Hash)
80
+
81
+ hooks = parsed.fetch("hooks", {})
82
+ return nil unless hooks.is_a?(Hash) && hooks.values.all? { |groups| groups?(groups) }
83
+
84
+ hooks
85
+ end
86
+
87
+ #: (untyped) -> bool
88
+ def groups?(groups)
89
+ return false unless groups.is_a?(Array)
90
+
91
+ groups.all? { |group| group.is_a?(Hash) && group.fetch("hooks", []).is_a?(Array) }
92
+ end
93
+
94
+ #: (Hash[String, untyped]) -> Array[Hash[String, untyped]]
95
+ def commands(group)
96
+ group.fetch("hooks", []).select { |hook| hook.is_a?(Hash) && hook["type"] == "command" }
97
+ end
98
+ end
99
+ end
100
+ end