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.
- checksums.yaml +7 -0
- data/exe/principles +10 -0
- data/lib/principles/after_commit_hook_options.rb +44 -0
- data/lib/principles/cli.rb +149 -0
- data/lib/principles/command_line.rb +188 -0
- data/lib/principles/command_routes.rb +60 -0
- data/lib/principles/controllers/acknowledgement.rb +106 -0
- data/lib/principles/controllers/answer.rb +172 -0
- data/lib/principles/controllers/audit.rb +203 -0
- data/lib/principles/controllers/audit_completion.rb +113 -0
- data/lib/principles/controllers/command.rb +201 -0
- data/lib/principles/controllers/correction.rb +99 -0
- data/lib/principles/controllers/help.rb +107 -0
- data/lib/principles/controllers/inventory.rb +181 -0
- data/lib/principles/controllers/mapping.rb +56 -0
- data/lib/principles/controllers/member_entries.rb +149 -0
- data/lib/principles/controllers/members_a_commit_changed.rb +43 -0
- data/lib/principles/controllers/members_to_assess.rb +160 -0
- data/lib/principles/controllers/meta_principle_setup.rb +92 -0
- data/lib/principles/controllers/repoint.rb +68 -0
- data/lib/principles/controllers/running_tool.rb +41 -0
- data/lib/principles/data/audit_files.rb +96 -0
- data/lib/principles/data/bounded_file.rb +45 -0
- data/lib/principles/data/candidate_files_found.rb +105 -0
- data/lib/principles/data/command-steps.json +305 -0
- data/lib/principles/data/command_steps_file.rb +50 -0
- data/lib/principles/data/expected_type.rb +71 -0
- data/lib/principles/data/ignored_by_git.rb +54 -0
- data/lib/principles/data/json_text.rb +57 -0
- data/lib/principles/data/known_fields.rb +42 -0
- data/lib/principles/data/linked_path.rb +102 -0
- data/lib/principles/data/map_files.rb +104 -0
- data/lib/principles/data/principle_id.rb +32 -0
- data/lib/principles/data/principles_subfolder.rb +93 -0
- data/lib/principles/data/project_files.rb +109 -0
- data/lib/principles/data/repository.rb +165 -0
- data/lib/principles/data/running_version.rb +44 -0
- data/lib/principles/data/schema_check.rb +139 -0
- data/lib/principles/data/schemas/acknowledgement-answer.schema.json +25 -0
- data/lib/principles/data/schemas/answer-guide.schema.json +99 -0
- data/lib/principles/data/schemas/assessment-answer.schema.json +332 -0
- data/lib/principles/data/schemas/audit-record.schema.json +277 -0
- data/lib/principles/data/schemas/command-steps.schema.json +83 -0
- data/lib/principles/data/schemas/correction-answer-guide.schema.json +68 -0
- data/lib/principles/data/schemas/correction-answer.schema.json +306 -0
- data/lib/principles/data/schemas/member-map.schema.json +39 -0
- data/lib/principles/data/schemas/repointing-answer.schema.json +44 -0
- data/lib/principles/error.rb +102 -0
- data/lib/principles/invocation_options.rb +77 -0
- data/lib/principles/models/accepted_loading.rb +36 -0
- data/lib/principles/models/acknowledgement.rb +203 -0
- data/lib/principles/models/activation.rb +64 -0
- data/lib/principles/models/agent_loads.rb +64 -0
- data/lib/principles/models/answer_fields.rb +67 -0
- data/lib/principles/models/answer_place.rb +53 -0
- data/lib/principles/models/answer_text.rb +37 -0
- data/lib/principles/models/answered_time.rb +65 -0
- data/lib/principles/models/assessment_answer.rb +131 -0
- data/lib/principles/models/assessment_basis.rb +47 -0
- data/lib/principles/models/assessment_report.rb +62 -0
- data/lib/principles/models/audit.rb +200 -0
- data/lib/principles/models/audit_record.rb +249 -0
- data/lib/principles/models/audit_records.rb +103 -0
- data/lib/principles/models/audit_start.rb +96 -0
- data/lib/principles/models/audited_member_lines.rb +45 -0
- data/lib/principles/models/candidate_files.rb +66 -0
- data/lib/principles/models/candidate_inventory.rb +67 -0
- data/lib/principles/models/candidate_listing.rb +32 -0
- data/lib/principles/models/carried_assessments.rb +76 -0
- data/lib/principles/models/check_since_last_audit.rb +61 -0
- data/lib/principles/models/cited_files.rb +39 -0
- data/lib/principles/models/command_steps.rb +270 -0
- data/lib/principles/models/commits_brought_in.rb +27 -0
- data/lib/principles/models/commits_since_base.rb +32 -0
- data/lib/principles/models/correction_answer.rb +66 -0
- data/lib/principles/models/correction_basis.rb +70 -0
- data/lib/principles/models/dated_line.rb +29 -0
- data/lib/principles/models/decided_prior_findings.rb +106 -0
- data/lib/principles/models/declared_map_changes.rb +156 -0
- data/lib/principles/models/disposition.rb +233 -0
- data/lib/principles/models/entry_file.rb +65 -0
- data/lib/principles/models/entry_files.rb +46 -0
- data/lib/principles/models/evidence_item.rb +48 -0
- data/lib/principles/models/evidence_lines_since.rb +57 -0
- data/lib/principles/models/evidence_replacements.rb +99 -0
- data/lib/principles/models/finding_write_ups.rb +108 -0
- data/lib/principles/models/findings_left_open.rb +34 -0
- data/lib/principles/models/first_raised_claim.rb +128 -0
- data/lib/principles/models/frontmatter_fields.rb +128 -0
- data/lib/principles/models/hook_commands.rb +100 -0
- data/lib/principles/models/ignored_candidates.rb +46 -0
- data/lib/principles/models/json_digest.rb +35 -0
- data/lib/principles/models/json_path_query.rb +131 -0
- data/lib/principles/models/json_path_selectors.rb +64 -0
- data/lib/principles/models/json_path_string.rb +76 -0
- data/lib/principles/models/kept_prior_finding.rb +70 -0
- data/lib/principles/models/last_completed_revision.rb +70 -0
- data/lib/principles/models/line_span.rb +49 -0
- data/lib/principles/models/loaded_files.rb +60 -0
- data/lib/principles/models/loading_decision.rb +45 -0
- data/lib/principles/models/loading_facts.rb +45 -0
- data/lib/principles/models/loads_to_check.rb +93 -0
- data/lib/principles/models/locate_refusal.rb +23 -0
- data/lib/principles/models/located_text.rb +43 -0
- data/lib/principles/models/locator.rb +52 -0
- data/lib/principles/models/map_members.rb +171 -0
- data/lib/principles/models/markdown_file.rb +92 -0
- data/lib/principles/models/member.rb +171 -0
- data/lib/principles/models/member_assessments.rb +144 -0
- data/lib/principles/models/member_elsewhere.rb +74 -0
- data/lib/principles/models/member_file.rb +156 -0
- data/lib/principles/models/member_grounds.rb +32 -0
- data/lib/principles/models/member_history.rb +68 -0
- data/lib/principles/models/member_lines.rb +95 -0
- data/lib/principles/models/member_loads.rb +197 -0
- data/lib/principles/models/member_map.rb +219 -0
- data/lib/principles/models/member_map_history.rb +143 -0
- data/lib/principles/models/member_maps.rb +185 -0
- data/lib/principles/models/member_moves.rb +95 -0
- data/lib/principles/models/member_problems.rb +253 -0
- data/lib/principles/models/member_sighting.rb +176 -0
- data/lib/principles/models/member_snapshots.rb +36 -0
- data/lib/principles/models/member_state.rb +35 -0
- data/lib/principles/models/members_elsewhere.rb +46 -0
- data/lib/principles/models/members_sharing_lines.rb +116 -0
- data/lib/principles/models/meta_principle.rb +51 -0
- data/lib/principles/models/missing_files.rb +85 -0
- data/lib/principles/models/misspelt_member_id.rb +45 -0
- data/lib/principles/models/open_finding.rb +221 -0
- data/lib/principles/models/open_findings_elsewhere.rb +45 -0
- data/lib/principles/models/other_principles.rb +47 -0
- data/lib/principles/models/placement.rb +42 -0
- data/lib/principles/models/plain_text_file.rb +32 -0
- data/lib/principles/models/principles_folder_move.rb +59 -0
- data/lib/principles/models/principles_package.rb +53 -0
- data/lib/principles/models/prior_finding_decision.rb +162 -0
- data/lib/principles/models/prior_finding_outcomes.rb +60 -0
- data/lib/principles/models/prior_findings.rb +109 -0
- data/lib/principles/models/problem_report.rb +110 -0
- data/lib/principles/models/project.rb +214 -0
- data/lib/principles/models/project_history.rb +203 -0
- data/lib/principles/models/record_judged_against.rb +42 -0
- data/lib/principles/models/recorded_entry.rb +93 -0
- data/lib/principles/models/recorded_finding.rb +97 -0
- data/lib/principles/models/recorded_findings.rb +58 -0
- data/lib/principles/models/recorded_members.rb +149 -0
- data/lib/principles/models/repoint.rb +112 -0
- data/lib/principles/models/reported_check.rb +130 -0
- data/lib/principles/models/request_id.rb +70 -0
- data/lib/principles/models/runs_from.rb +36 -0
- data/lib/principles/models/skill_loads.rb +123 -0
- data/lib/principles/models/still_holds_restatement.rb +87 -0
- data/lib/principles/models/text_size.rb +26 -0
- data/lib/principles/models/uncommitted_outside_the_map.rb +30 -0
- data/lib/principles/models/waits_on.rb +58 -0
- data/lib/principles/models/write_up_opening.rb +93 -0
- data/lib/principles/models/write_up_pointer.rb +50 -0
- data/lib/principles/models/write_up_repoint.rb +39 -0
- data/lib/principles/prose_list.rb +23 -0
- data/lib/principles/supported_ruby.rb +93 -0
- data/lib/principles/version.rb +11 -0
- data/lib/principles/views/acknowledged_since_last_audit.rb +34 -0
- data/lib/principles/views/acknowledgement_result.rb +44 -0
- data/lib/principles/views/activation_in_hand.rb +47 -0
- data/lib/principles/views/assessment_request.rb +131 -0
- data/lib/principles/views/audit_changes.rb +79 -0
- data/lib/principles/views/audit_commit.rb +55 -0
- data/lib/principles/views/audit_result.rb +147 -0
- data/lib/principles/views/candidate_files_result.rb +95 -0
- data/lib/principles/views/check_result_template.rb +75 -0
- data/lib/principles/views/diff_since_last_audit.rb +29 -0
- data/lib/principles/views/findings_result.rb +45 -0
- data/lib/principles/views/help.rb +119 -0
- data/lib/principles/views/invocation.rb +66 -0
- data/lib/principles/views/landed_since_base.rb +27 -0
- data/lib/principles/views/lines_shared_with_member.rb +111 -0
- data/lib/principles/views/loads_to_check_result.rb +45 -0
- data/lib/principles/views/map_result.rb +169 -0
- data/lib/principles/views/member_change_since_baseline.rb +58 -0
- data/lib/principles/views/member_repairs.rb +82 -0
- data/lib/principles/views/members_a_commit_changed.rb +66 -0
- data/lib/principles/views/members_with_no_baseline.rb +56 -0
- data/lib/principles/views/meta_principle_passage.rb +23 -0
- data/lib/principles/views/meta_principle_result.rb +170 -0
- data/lib/principles/views/open_findings_elsewhere_result.rb +32 -0
- data/lib/principles/views/open_findings_line.rb +125 -0
- data/lib/principles/views/other_principles_unjudged_result.rb +32 -0
- data/lib/principles/views/outside_the_map_result.rb +31 -0
- data/lib/principles/views/package_since_last_audit_result.rb +34 -0
- data/lib/principles/views/principle_read_result.rb +86 -0
- data/lib/principles/views/principle_with_changed_members.rb +26 -0
- data/lib/principles/views/principles_with_no_completed_audit.rb +30 -0
- data/lib/principles/views/printed_principle.rb +45 -0
- data/lib/principles/views/prior_finding_decisions_result.rb +35 -0
- data/lib/principles/views/prior_findings_result.rb +80 -0
- data/lib/principles/views/problem_lines.rb +84 -0
- data/lib/principles/views/problem_result.rb +107 -0
- data/lib/principles/views/prompts/acknowledge-a-change/say-whether-the-change-keeps-the-meaning.md +12 -0
- data/lib/principles/views/prompts/answer-any-judgement-request/what-a-self-audit-cannot-show.md +6 -0
- data/lib/principles/views/prompts/assess-every-member/answer-guide.json +63 -0
- data/lib/principles/views/prompts/assess-every-member/assess-each-member-and-the-set.md +12 -0
- data/lib/principles/views/prompts/assess-every-member/check-roles-other-principles-give-these-lines.md +7 -0
- data/lib/principles/views/prompts/assess-every-member/decide-each-member-loading.md +17 -0
- data/lib/principles/views/prompts/assess-every-member/decide-each-prior-finding.md +38 -0
- data/lib/principles/views/prompts/assess-every-member/give-problem-evidence-at-still-holds.md +2 -0
- data/lib/principles/views/prompts/assess-every-member/give-the-evidence-a-prior-finding-lacks.md +7 -0
- data/lib/principles/views/prompts/assess-every-member/have-a-changed-check-reviewed-before-running-it.md +4 -0
- data/lib/principles/views/prompts/assess-every-member/have-a-new-check-reviewed-before-running-it.md +3 -0
- data/lib/principles/views/prompts/assess-every-member/have-hidden-characters-in-a-check-explained.md +4 -0
- data/lib/principles/views/prompts/assess-every-member/judge-acknowledged-edits-together.md +6 -0
- data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-against-the-other-principles.md +7 -0
- data/lib/principles/views/prompts/assess-every-member/judge-the-keeping-under-the-release-since-the-last-audit.md +10 -0
- data/lib/principles/views/prompts/assess-every-member/judge-the-set-with-carried-assessments.md +10 -0
- data/lib/principles/views/prompts/assess-every-member/keep-the-last-review-basis-unless-you-give-a-new-one.md +1 -0
- data/lib/principles/views/prompts/assess-every-member/name-another-principles-open-finding-rather-than-raise-it.md +9 -0
- data/lib/principles/views/prompts/assess-every-member/read-whether-each-prior-finding-s-evidence-changed.md +12 -0
- data/lib/principles/views/prompts/assess-every-member/run-a-named-check-at-every-full-audit.md +2 -0
- data/lib/principles/views/prompts/assess-every-member/run-the-check-then-report-it.md +2 -0
- data/lib/principles/views/prompts/assess-every-member/run-the-reviewed-check-and-say-what-its-review-rested-on.md +6 -0
- data/lib/principles/views/prompts/assess-every-member/uncommitted-files-outside-the-map.md +4 -0
- data/lib/principles/views/prompts/assess-every-member/why-each-member-is-judged-again.md +24 -0
- data/lib/principles/views/prompts/dispose-of-each-finding/answer-guide.json +34 -0
- data/lib/principles/views/prompts/dispose-of-each-finding/fix-or-record-each-finding.md +24 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/ask-the-owner-before-mapping.md +1 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/choose-one-principle.md +5 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored-folders.md +2 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-ignored.md +3 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-possibly-test-data.md +2 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/owner-ask-untracked.md +2 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/what-each-command-writes.md +4 -0
- data/lib/principles/views/prompts/find-a-principle-to-map/where-the-inventory-looks.md +10 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/add-member.md +45 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/change-activation.md +21 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/change-description.md +2 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/change-member-check.md +17 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/change-member-role.md +9 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/create-principle.md +6 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/every-command-and-how-they-chain.md +28 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/list-candidate-files.md +17 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/list-member-problems.md +73 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/list-open-findings.md +71 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/list-principles.md +7 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/move-member.md +11 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/remove-member-check.md +10 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/remove-member.md +6 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/show-answer-check.md +16 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/show-audit.md +20 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/show-meta-principle-passage.md +52 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/show-principle.md +86 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/start-audit.md +68 -0
- data/lib/principles/views/prompts/learn-what-a-command-does/submit-answer.md +56 -0
- data/lib/principles/views/prompts/repoint-write-ups/point-each-moved-write-up-at-its-new-place.md +10 -0
- data/lib/principles/views/prompts/report-after-a-commit/it-refused-after-the-commits-touched-the-maps.md +2 -0
- data/lib/principles/views/prompts/report-after-a-commit/others-are-still-listed.md +1 -0
- data/lib/principles/views/prompts/report-after-a-commit/the-commits-changed-these-members.md +2 -0
- data/lib/principles/views/prompts/report-after-a-commit/this-command-shows-each-one.md +1 -0
- data/lib/principles/views/prompts/report-the-completed-audit/commit-only-what-the-audit-changed.md +2 -0
- data/lib/principles/views/prompts/report-the-completed-audit/replace-the-default-commit-message.md +4 -0
- data/lib/principles/views/prompts/report-the-completed-audit/what-completion-means.md +2 -0
- data/lib/principles/views/prompts/set-principles-up/entry-file-passage.md +3 -0
- data/lib/principles/views/prompts/set-principles-up/name-the-step-each-member-lacks.md +15 -0
- data/lib/principles/views/prompts/set-principles-up/place-the-passage-with-the-user.md +31 -0
- data/lib/principles/views/prompts/set-principles-up/set-up-first.md +2 -0
- data/lib/principles/views/prompts/set-principles-up/what-the-passage-presumes.md +4 -0
- data/lib/principles/views/prompts/start-a-session/act-on-the-open-findings.md +5 -0
- data/lib/principles/views/prompts/start-a-session/it-returns-after-each-clear-or-compaction-too.md +1 -0
- data/lib/principles/views/prompts/start-a-session/it-returns-at-each-session-start.md +1 -0
- data/lib/principles/views/prompts/start-a-session/raise-them-at-the-next-pause.md +1 -0
- data/lib/principles/views/prompts/start-a-session/raise-them-before-the-first-task.md +1 -0
- data/lib/principles/views/prompts.rb +71 -0
- data/lib/principles/views/repoint_result.rb +80 -0
- data/lib/principles/views/request_part.rb +26 -0
- data/lib/principles/views/request_parts.rb +26 -0
- data/lib/principles/views/shell_word.rb +29 -0
- data/lib/principles/views/visible_text.rb +26 -0
- data/lib/principles/views/workflow_diagram.rb +77 -0
- data/lib/principles.rb +5 -0
- 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
|