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