carson 2.23.0 → 2.25.0

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.
data/lib/carson/config.rb CHANGED
@@ -7,16 +7,14 @@ module Carson
7
7
  # Config is built-in only for outsider mode; host repositories do not carry Carson config files.
8
8
  class Config
9
9
  attr_accessor :git_remote
10
- attr_reader :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
11
- :path_groups, :template_managed_files, :template_superseded_files, :template_canonical,
12
- :lint_policy_source,
10
+ attr_reader :main_branch, :protected_branches, :hooks_path, :managed_hooks,
11
+ :template_managed_files, :template_canonical,
13
12
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
14
- :review_sweep_states, :review_disposition_prefix, :review_risk_keywords,
13
+ :review_sweep_states, :review_disposition, :review_risk_keywords,
15
14
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
16
- :ruby_indentation,
17
15
  :audit_advisory_check_names,
18
16
  :workflow_style,
19
- :govern_repos, :govern_merge_authority, :govern_merge_method,
17
+ :govern_repos, :govern_auto_merge, :govern_merge_method,
20
18
  :govern_agent_provider, :govern_dispatch_state_path,
21
19
  :govern_check_wait
22
20
 
@@ -35,26 +33,13 @@ module Carson
35
33
  "protected_branches" => [ "main", "master" ]
36
34
  },
37
35
  "hooks" => {
38
- "base_path" => "~/.carson/hooks",
39
- "required_hooks" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
40
- },
41
- "scope" => {
42
- "path_groups" => {
43
- "tool" => [ "exe/**", "bin/**", "lib/**", "script/**", ".github/**", "templates/.github/**", "hooks/**", "install.sh", "README.md", "RELEASE.md", "VERSION", "carson.gemspec" ],
44
- "ui" => [ "app/views/**", "app/assets/**", "app/javascript/**", "docs/ui_*.md" ],
45
- "test" => [ "test/**", "spec/**", "features/**" ],
46
- "domain" => [ "app/**", "db/**", "config/**" ],
47
- "docs" => [ "docs/**", "*.md" ]
48
- }
36
+ "path" => "~/.carson/hooks",
37
+ "managed" => [ "pre-commit", "prepare-commit-msg", "pre-merge-commit", "pre-push" ]
49
38
  },
50
39
  "template" => {
51
40
  "managed_files" => [ ".github/carson.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ],
52
- "superseded_files" => [ ".github/carson-instructions.md", ".github/workflows/carson-lint.yml", ".github/.mega-linter.yml" ],
53
41
  "canonical" => nil
54
42
  },
55
- "lint" => {
56
- "policy_source" => "wanghailei/lint.git"
57
- },
58
43
  "workflow" => {
59
44
  "style" => "branch"
60
45
  },
@@ -63,7 +48,7 @@ module Carson
63
48
  "wait_seconds" => 10,
64
49
  "poll_seconds" => 15,
65
50
  "max_polls" => 20,
66
- "required_disposition_prefix" => "Disposition:",
51
+ "disposition" => "Disposition:",
67
52
  "risk_keywords" => [ "bug", "security", "incorrect", "block", "fail", "regression" ],
68
53
  "sweep" => {
69
54
  "window_days" => 3,
@@ -79,10 +64,8 @@ module Carson
79
64
  },
80
65
  "govern" => {
81
66
  "repos" => [],
82
- "merge" => {
83
- "authority" => true,
84
- "method" => "squash"
85
- },
67
+ "auto_merge" => true,
68
+ "merge_method" => "squash",
86
69
  "agent" => {
87
70
  "provider" => "auto",
88
71
  "codex" => {},
@@ -90,11 +73,8 @@ module Carson
90
73
  },
91
74
  "dispatch_state_path" => "~/.carson/govern/dispatch_state.json",
92
75
  "check_wait" => 30
93
- },
94
- "style" => {
95
- "ruby_indentation" => "tabs"
96
- }
97
76
  }
77
+ }
98
78
  end
99
79
 
100
80
  def self.load_global_config_data( repo_root: )
@@ -147,8 +127,8 @@ module Carson
147
127
  def self.apply_env_overrides( data: )
148
128
  copy = deep_dup_value( value: data )
149
129
  hooks = fetch_hash_section( data: copy, key: "hooks" )
150
- hooks_path = ENV.fetch( "CARSON_HOOKS_BASE_PATH", "" ).to_s.strip
151
- hooks[ "base_path" ] = hooks_path unless hooks_path.empty?
130
+ hooks_path = ENV.fetch( "CARSON_HOOKS_PATH", "" ).to_s.strip
131
+ hooks[ "path" ] = hooks_path unless hooks_path.empty?
152
132
  workflow = fetch_hash_section( data: copy, key: "workflow" )
153
133
  workflow_style = ENV.fetch( "CARSON_WORKFLOW_STYLE", "" ).to_s.strip
154
134
  workflow[ "style" ] = workflow_style unless workflow_style.empty?
@@ -156,8 +136,8 @@ module Carson
156
136
  review[ "wait_seconds" ] = env_integer( key: "CARSON_REVIEW_WAIT_SECONDS", fallback: review.fetch( "wait_seconds" ) )
157
137
  review[ "poll_seconds" ] = env_integer( key: "CARSON_REVIEW_POLL_SECONDS", fallback: review.fetch( "poll_seconds" ) )
158
138
  review[ "max_polls" ] = env_integer( key: "CARSON_REVIEW_MAX_POLLS", fallback: review.fetch( "max_polls" ) )
159
- disposition_prefix = ENV.fetch( "CARSON_REVIEW_DISPOSITION_PREFIX", "" ).to_s.strip
160
- review[ "required_disposition_prefix" ] = disposition_prefix unless disposition_prefix.empty?
139
+ disposition = ENV.fetch( "CARSON_REVIEW_DISPOSITION", "" ).to_s.strip
140
+ review[ "disposition" ] = disposition unless disposition.empty?
161
141
  sweep = fetch_hash_section( data: review, key: "sweep" )
162
142
  sweep[ "window_days" ] = env_integer( key: "CARSON_REVIEW_SWEEP_WINDOW_DAYS", fallback: sweep.fetch( "window_days" ) )
163
143
  states = env_string_array( key: "CARSON_REVIEW_SWEEP_STATES" )
@@ -167,20 +147,13 @@ module Carson
167
147
  audit = fetch_hash_section( data: copy, key: "audit" )
168
148
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
169
149
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
170
- lint = fetch_hash_section( data: copy, key: "lint" )
171
- lint_policy_source_env = ENV.fetch( "CARSON_LINT_POLICY_SOURCE", "" ).to_s.strip
172
- lint[ "policy_source" ] = lint_policy_source_env unless lint_policy_source_env.empty?
173
- style = fetch_hash_section( data: copy, key: "style" )
174
- ruby_indentation = ENV.fetch( "CARSON_RUBY_INDENTATION", "" ).to_s.strip
175
- style[ "ruby_indentation" ] = ruby_indentation unless ruby_indentation.empty?
176
150
  govern = fetch_hash_section( data: copy, key: "govern" )
177
151
  govern_repos = env_string_array( key: "CARSON_GOVERN_REPOS" )
178
152
  govern[ "repos" ] = govern_repos unless govern_repos.empty?
179
- merge = fetch_hash_section( data: govern, key: "merge" )
180
- govern_authority = ENV.fetch( "CARSON_GOVERN_MERGE_AUTHORITY", "" ).to_s.strip
181
- merge[ "authority" ] = ( govern_authority == "true" ) unless govern_authority.empty?
153
+ govern_auto_merge = ENV.fetch( "CARSON_GOVERN_AUTO_MERGE", "" ).to_s.strip
154
+ govern[ "auto_merge" ] = ( govern_auto_merge == "true" ) unless govern_auto_merge.empty?
182
155
  govern_method = ENV.fetch( "CARSON_GOVERN_MERGE_METHOD", "" ).to_s.strip
183
- merge[ "method" ] = govern_method unless govern_method.empty?
156
+ govern[ "merge_method" ] = govern_method unless govern_method.empty?
184
157
  agent = fetch_hash_section( data: govern, key: "agent" )
185
158
  govern_provider = ENV.fetch( "CARSON_GOVERN_AGENT_PROVIDER", "" ).to_s.strip
186
159
  agent[ "provider" ] = govern_provider unless govern_provider.empty?
@@ -212,17 +185,12 @@ module Carson
212
185
  @main_branch = fetch_string( hash: fetch_hash( hash: data, key: "git" ), key: "main_branch" )
213
186
  @protected_branches = fetch_string_array( hash: fetch_hash( hash: data, key: "git" ), key: "protected_branches" )
214
187
 
215
- @hooks_base_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "base_path" )
216
- @required_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "required_hooks" )
217
-
218
- @path_groups = fetch_hash( hash: fetch_hash( hash: data, key: "scope" ), key: "path_groups" ).transform_values { |value| normalize_patterns( value: value ) }
188
+ @hooks_path = fetch_string( hash: fetch_hash( hash: data, key: "hooks" ), key: "path" )
189
+ @managed_hooks = fetch_string_array( hash: fetch_hash( hash: data, key: "hooks" ), key: "managed" )
219
190
 
220
191
  @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
221
- @template_superseded_files = fetch_optional_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "superseded_files" )
222
192
  @template_canonical = fetch_optional_path( hash: fetch_hash( hash: data, key: "template" ), key: "canonical" )
223
193
  resolve_canonical_files!
224
- lint_hash = fetch_hash( hash: data, key: "lint" )
225
- @lint_policy_source = lint_hash.fetch( "policy_source", "" ).to_s.strip
226
194
 
227
195
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
228
196
  @workflow_style = fetch_string( hash: workflow_hash, key: "style" ).downcase
@@ -231,7 +199,7 @@ module Carson
231
199
  @review_wait_seconds = fetch_non_negative_integer( hash: review_hash, key: "wait_seconds" )
232
200
  @review_poll_seconds = fetch_non_negative_integer( hash: review_hash, key: "poll_seconds" )
233
201
  @review_max_polls = fetch_positive_integer( hash: review_hash, key: "max_polls" )
234
- @review_disposition_prefix = fetch_string( hash: review_hash, key: "required_disposition_prefix" )
202
+ @review_disposition = fetch_string( hash: review_hash, key: "disposition" )
235
203
  @review_risk_keywords = fetch_string_array( hash: review_hash, key: "risk_keywords" )
236
204
  sweep_hash = fetch_hash( hash: review_hash, key: "sweep" )
237
205
  @review_sweep_window_days = fetch_positive_integer( hash: sweep_hash, key: "window_days" )
@@ -242,14 +210,11 @@ module Carson
242
210
  @review_bot_usernames = fetch_optional_string_array( hash: review_hash, key: "bot_usernames" )
243
211
  audit_hash = fetch_hash( hash: data, key: "audit" )
244
212
  @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
245
- style_hash = fetch_hash( hash: data, key: "style" )
246
- @ruby_indentation = fetch_string( hash: style_hash, key: "ruby_indentation" ).downcase
247
213
 
248
214
  govern_hash = fetch_hash( hash: data, key: "govern" )
249
215
  @govern_repos = fetch_optional_string_array( hash: govern_hash, key: "repos" ).map { |p| safe_expand_path( p ) }
250
- govern_merge_hash = fetch_hash( hash: govern_hash, key: "merge" )
251
- @govern_merge_authority = fetch_optional_boolean( hash: govern_merge_hash, key: "authority", default: true, key_path: "govern.merge.authority" )
252
- @govern_merge_method = fetch_string( hash: govern_merge_hash, key: "method" ).downcase
216
+ @govern_auto_merge = fetch_optional_boolean( hash: govern_hash, key: "auto_merge", default: true, key_path: "govern.auto_merge" )
217
+ @govern_merge_method = fetch_string( hash: govern_hash, key: "merge_method" ).downcase
253
218
  govern_agent_hash = fetch_hash( hash: govern_hash, key: "agent" )
254
219
  @govern_agent_provider = fetch_string( hash: govern_agent_hash, key: "provider" ).downcase
255
220
  dispatch_path = govern_hash.fetch( "dispatch_state_path" ).to_s
@@ -265,17 +230,15 @@ module Carson
265
230
  raise ConfigError, "git.remote cannot be empty" if git_remote.empty?
266
231
  raise ConfigError, "git.main_branch cannot be empty" if main_branch.empty?
267
232
  raise ConfigError, "git.protected_branches must include #{main_branch}" unless protected_branches.include?( main_branch )
268
- raise ConfigError, "hooks.base_path cannot be empty" if hooks_base_path.empty?
269
- raise ConfigError, "hooks.required_hooks cannot be empty" if required_hooks.empty?
270
- raise ConfigError, "scope.path_groups cannot be empty" if path_groups.empty?
271
- raise ConfigError, "review.required_disposition_prefix cannot be empty" if review_disposition_prefix.empty?
233
+ raise ConfigError, "hooks.path cannot be empty" if hooks_path.empty?
234
+ raise ConfigError, "hooks.managed cannot be empty" if managed_hooks.empty?
235
+ raise ConfigError, "review.disposition cannot be empty" if review_disposition.empty?
272
236
  raise ConfigError, "review.risk_keywords cannot be empty" if review_risk_keywords.empty?
273
237
  raise ConfigError, "review.sweep.states must contain one or both of open, closed" if ( review_sweep_states - [ "open", "closed" ] ).any? || review_sweep_states.empty?
274
238
  raise ConfigError, "review.sweep.states cannot contain duplicates" unless review_sweep_states.uniq.length == review_sweep_states.length
275
239
  raise ConfigError, "review.tracking_issue.title cannot be empty" if review_tracking_issue_title.empty?
276
240
  raise ConfigError, "review.tracking_issue.label cannot be empty" if review_tracking_issue_label.empty?
277
241
  raise ConfigError, "workflow.style must be one of trunk, branch" unless [ "trunk", "branch" ].include?( workflow_style )
278
- raise ConfigError, "style.ruby_indentation must be one of tabs, spaces, either" unless [ "tabs", "spaces", "either" ].include?( ruby_indentation )
279
242
  raise ConfigError, "govern.merge.method must be one of merge, squash, rebase" unless [ "merge", "squash", "rebase" ].include?( govern_merge_method )
280
243
  raise ConfigError, "govern.agent.provider must be one of auto, codex, claude" unless [ "auto", "codex", "claude" ].include?( govern_agent_provider )
281
244
  end
@@ -328,13 +291,6 @@ module Carson
328
291
  rescue ArgumentError, TypeError
329
292
  raise ConfigError, "config key #{key} must be an integer"
330
293
  end
331
-
332
- def normalize_patterns( value: )
333
- patterns = Array( value ).map { |entry| entry.to_s.strip }.reject( &:empty? )
334
- raise ConfigError, "scope.path_groups entries must contain at least one glob" if patterns.empty?
335
- patterns
336
- end
337
-
338
294
  def fetch_optional_boolean( hash:, key:, default:, key_path: nil )
339
295
  value = hash.fetch( key, default )
340
296
  return true if value == true
@@ -24,7 +24,7 @@ module Carson
24
24
  hooks_ok = hooks_health_report
25
25
  unless hooks_ok
26
26
  audit_state = "block"
27
- audit_concise_problems << "Hooks: mismatch — run carson prepare."
27
+ audit_concise_problems << "Hooks: mismatch — run carson refresh."
28
28
  end
29
29
  puts_verbose ""
30
30
  puts_verbose "[Main Sync Status]"
@@ -92,15 +92,6 @@ module Carson
92
92
  elsif baseline_st == "skipped"
93
93
  audit_concise_problems << "Baseline: skipped (#{default_branch_baseline.fetch( :skip_reason )})."
94
94
  end
95
- scope_guard = print_scope_integrity_guard
96
- audit_state = "attention" if audit_state == "ok" && scope_guard.fetch( :status ) == "attention"
97
- if scope_guard.fetch( :status ) == "attention"
98
- if scope_guard.fetch( :split_required )
99
- audit_concise_problems << "Scope: multiple module groups touched."
100
- else
101
- audit_concise_problems << "Scope: unmatched paths — classify via scope.path_groups."
102
- end
103
- end
104
95
  if config.template_canonical.nil? || config.template_canonical.to_s.empty?
105
96
  puts_verbose ""
106
97
  puts_verbose "[Canonical Templates]"
@@ -124,64 +115,6 @@ module Carson
124
115
  audit_state == "block" ? EXIT_BLOCK : EXIT_OK
125
116
  end
126
117
 
127
- # Thin focused command: show required-check status for the current branch's open PR.
128
- # Always exits 0 for pending or passing so callers never see a false "Error: Exit code 8".
129
- def check!
130
- unless head_exists?
131
- puts_line "Checks: no commits yet."
132
- return EXIT_OK
133
- end
134
- unless gh_available?
135
- puts_line "Checks: gh CLI not available."
136
- return EXIT_ERROR
137
- end
138
-
139
- pr_stdout, pr_stderr, pr_success, = gh_run(
140
- "pr", "view", current_branch,
141
- "--json", "number,title,url"
142
- )
143
- unless pr_success
144
- error_text = gh_error_text( stdout_text: pr_stdout, stderr_text: pr_stderr, fallback: "no open PR for branch #{current_branch}" )
145
- puts_line "Checks: #{error_text}."
146
- return EXIT_ERROR
147
- end
148
- pr_data = JSON.parse( pr_stdout )
149
- pr_number = pr_data[ "number" ].to_s
150
-
151
- checks_stdout, checks_stderr, checks_success, checks_exit = gh_run(
152
- "pr", "checks", pr_number, "--required", "--json", "name,state,bucket,workflow,link"
153
- )
154
- if checks_stdout.to_s.strip.empty?
155
- error_text = gh_error_text( stdout_text: checks_stdout, stderr_text: checks_stderr, fallback: "required checks unavailable" )
156
- puts_line "Checks: #{error_text}."
157
- return EXIT_ERROR
158
- end
159
-
160
- checks_data = JSON.parse( checks_stdout )
161
- pending = checks_data.select { |e| e[ "bucket" ].to_s == "pending" }
162
- failing = checks_data.select { |e| check_entry_failing?( entry: e ) }
163
- total = checks_data.count
164
- # gh exits 8 when required checks are still pending (not a failure).
165
- is_pending = !checks_success && checks_exit == 8
166
-
167
- if failing.any?
168
- puts_line "Checks: FAIL (#{failing.count} of #{total} failing)."
169
- normalise_check_entries( entries: failing ).each { |e| puts_line " #{e.fetch( :workflow )} / #{e.fetch( :name )} #{e.fetch( :link )}".strip }
170
- return EXIT_BLOCK
171
- end
172
-
173
- if is_pending || pending.any?
174
- puts_line "Checks: pending (#{total - pending.count} of #{total} complete)."
175
- return EXIT_OK
176
- end
177
-
178
- puts_line "Checks: all passing (#{total} required)."
179
- EXIT_OK
180
- rescue JSON::ParserError => e
181
- puts_line "Checks: invalid gh response (#{e.message})."
182
- EXIT_ERROR
183
- end
184
-
185
118
  private
186
119
  def pr_and_check_report
187
120
  report = {
@@ -554,112 +487,6 @@ module Carson
554
487
  lines.join( "\n" )
555
488
  end
556
489
 
557
- # Evaluates scope integrity using staged paths first, then working-tree paths as fallback.
558
- def print_scope_integrity_guard
559
- staged = staged_files
560
- files = staged.empty? ? changed_files : staged
561
- files_source = staged.empty? ? "working_tree" : "staged"
562
- return { status: "ok", split_required: false } if files.empty?
563
-
564
- scope = scope_integrity_status( files: files, branch: current_branch )
565
- puts_verbose ""
566
- puts_verbose "[Scope Integrity Guard]"
567
- puts_verbose "scope_file_source: #{files_source}"
568
- puts_verbose "scope_file_count: #{files.count}"
569
- puts_verbose "branch: #{scope.fetch( :branch )}"
570
- puts_verbose "scope_basis: changed_paths_only"
571
- puts_verbose "detected_groups: #{scope.fetch( :detected_groups ).sort.join( ', ' )}"
572
- puts_verbose "core_groups: #{scope.fetch( :core_groups ).empty? ? 'none' : scope.fetch( :core_groups ).sort.join( ', ' )}"
573
- puts_verbose "non_doc_groups: #{scope.fetch( :non_doc_groups ).empty? ? 'none' : scope.fetch( :non_doc_groups ).sort.join( ', ' )}"
574
- puts_verbose "docs_only_changes: #{scope.fetch( :docs_only )}"
575
- puts_verbose "unmatched_paths_count: #{scope.fetch( :unmatched_paths ).count}"
576
- scope.fetch( :unmatched_paths ).each { |path| puts_verbose "unmatched_path: #{path}" }
577
- puts_verbose "violating_files_count: #{scope.fetch( :violating_files ).count}"
578
- scope.fetch( :violating_files ).each { |path| puts_verbose "violating_file: #{path} (group=#{scope.fetch( :grouped_paths ).fetch( path )})" }
579
- puts_verbose "checklist_single_business_intent: pass"
580
- puts_verbose "checklist_single_scope_group: #{scope.fetch( :split_required ) ? 'advisory' : 'pass'}"
581
- puts_verbose "checklist_cross_boundary_changes_justified: #{( scope.fetch( :split_required ) || scope.fetch( :misc_present ) ) ? 'advisory' : 'pass'}"
582
- if scope.fetch( :split_required )
583
- puts_verbose "ACTION: multiple module groups detected (informational only)."
584
- elsif scope.fetch( :misc_present )
585
- puts_verbose "ACTION: unmatched paths detected; classify via scope.path_groups for stricter module checks."
586
- else
587
- puts_verbose "ACTION: scope integrity is within commit policy."
588
- end
589
- { status: scope.fetch( :status ), split_required: scope.fetch( :split_required ) }
590
- end
591
-
592
- # Evaluates whether changed files stay within one core module group.
593
- def scope_integrity_status( files:, branch: )
594
- grouped_paths = files.map { |path| [ path, scope_group_for_path( path: path ) ] }.to_h
595
- detected_groups = grouped_paths.values.uniq
596
- non_doc_groups = detected_groups - [ "docs" ]
597
- # Tests are supporting changes; they may travel with one core module group.
598
- core_groups = non_doc_groups - [ "test", "misc" ]
599
- mixed_core_groups = core_groups.length > 1
600
- misc_present = non_doc_groups.include?( "misc" )
601
- split_required = mixed_core_groups
602
- unmatched_paths = files.select { |path| grouped_paths.fetch( path ) == "misc" }
603
- violating_files = if split_required
604
- files.select do |path|
605
- group = grouped_paths.fetch( path )
606
- next false if [ "docs", "test", "misc" ].include?( group )
607
- core_groups.include?( group )
608
- end
609
- else
610
- []
611
- end
612
- {
613
- branch: branch,
614
- grouped_paths: grouped_paths,
615
- detected_groups: detected_groups,
616
- non_doc_groups: non_doc_groups,
617
- core_groups: core_groups,
618
- docs_only: non_doc_groups.empty?,
619
- mixed_core_groups: mixed_core_groups,
620
- misc_present: misc_present,
621
- split_required: split_required,
622
- unmatched_paths: unmatched_paths,
623
- violating_files: violating_files,
624
- status: ( split_required || misc_present ) ? "attention" : "ok"
625
- }
626
- end
627
-
628
- # Resolves a path to configured scope group; unmatched paths become misc.
629
- def scope_group_for_path( path: )
630
- config.path_groups.each do |group, patterns|
631
- return group if patterns.any? { |pattern| pattern_matches_path?( pattern: pattern, path: path ) }
632
- end
633
- "misc"
634
- end
635
-
636
- # Supports directory-wide /** prefixes and fnmatch for other patterns.
637
- def pattern_matches_path?( pattern:, path: )
638
- if pattern.end_with?( "/**" )
639
- prefix = pattern.delete_suffix( "/**" )
640
- return path == prefix || path.start_with?( "#{prefix}/" )
641
- end
642
- File.fnmatch?( pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH )
643
- end
644
-
645
- # Uses index-only paths so commit hooks evaluate exactly what is being committed.
646
- def staged_files
647
- git_capture!( "diff", "--cached", "--name-only" ).lines.map do |line|
648
- raw_path = line.to_s.strip
649
- next if raw_path.empty?
650
- raw_path.split( " -> " ).last
651
- end.compact
652
- end
653
-
654
- # Parses `git status --porcelain` and normalises rename targets.
655
- def changed_files
656
- git_capture!( "status", "--porcelain" ).lines.map do |line|
657
- raw_path = line[ 3.. ].to_s.strip
658
- next if raw_path.empty?
659
- raw_path.split( " -> " ).last
660
- end.compact
661
- end
662
-
663
490
  # True when there are no staged/unstaged/untracked file changes.
664
491
  end
665
492
 
@@ -81,18 +81,6 @@ module Carson
81
81
  EXIT_OK
82
82
  end
83
83
 
84
- # Standalone housekeep: sync + prune.
85
- def housekeep!
86
- puts_verbose ""
87
- puts_verbose "[Housekeep]"
88
- sync_status = sync!
89
- if sync_status != EXIT_OK
90
- puts_line "housekeep: sync returned #{sync_status}; skipping prune."
91
- return sync_status
92
- end
93
- prune!
94
- end
95
-
96
84
  private
97
85
 
98
86
  # Resolves the list of repo paths to govern from config.
@@ -209,10 +197,6 @@ module Carson
209
197
  return [ TRIAGE_REVIEW_BLOCKED, "changes requested by reviewer" ]
210
198
  end
211
199
 
212
- # Run audit and review gate checks for deeper analysis
213
- audit_status, audit_detail = check_audit_status( pr: pr, repo_path: repo_path )
214
- return [ TRIAGE_NEEDS_ATTENTION, audit_detail ] unless audit_status == :pass
215
-
216
200
  review_status, review_detail = check_review_gate_status( pr: pr, repo_path: repo_path )
217
201
  return [ TRIAGE_REVIEW_BLOCKED, review_detail ] unless review_status == :pass
218
202
 
@@ -245,20 +229,6 @@ module Carson
245
229
  [ "PENDING", "QUEUED", "IN_PROGRESS", "WAITING", "REQUESTED" ].include?( state.upcase )
246
230
  end
247
231
 
248
- # Checks if the PR's branch is available locally and defers audit. Returns [:pass/:fail, detail].
249
- def check_audit_status( pr:, repo_path: )
250
- branch = pr[ "headRefName" ].to_s
251
- stdout_text, stderr_text, status = Open3.capture3(
252
- "git", "rev-parse", "--verify", "refs/heads/#{branch}",
253
- chdir: repo_path
254
- )
255
- unless status.success?
256
- return [ :pass, "branch not local; skipping audit" ]
257
- end
258
-
259
- [ :pass, "audit deferred to merge gate" ]
260
- end
261
-
262
232
  # Checks review gate status. Returns [:pass/:fail, detail].
263
233
  def check_review_gate_status( pr:, repo_path: )
264
234
  review_decision = pr[ "reviewDecision" ].to_s.upcase
@@ -308,7 +278,7 @@ module Carson
308
278
 
309
279
  # Merges a PR that has passed all gates.
310
280
  def merge_if_ready!( pr:, repo_path: )
311
- unless config.govern_merge_authority
281
+ unless config.govern_auto_merge
312
282
  puts_line " merge authority disabled; skipping merge"
313
283
  return
314
284
  end
@@ -373,14 +343,15 @@ module Carson
373
343
  puts_line " agent result: #{result.status} — #{result.summary.to_s[0, 120]}"
374
344
  end
375
345
 
376
- # Runs housekeep in the given repo after a successful merge.
346
+ # Runs sync + prune in the given repo after a successful merge.
377
347
  def housekeep_repo!( repo_path: )
378
- if repo_path == self.repo_root
379
- housekeep!
348
+ rt = if repo_path == self.repo_root
349
+ self
380
350
  else
381
- rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
382
- rt.housekeep!
351
+ Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err )
383
352
  end
353
+ sync_status = rt.sync!
354
+ rt.prune! if sync_status == EXIT_OK
384
355
  end
385
356
 
386
357
  # Selects which agent provider to use based on config and availability.
@@ -0,0 +1,142 @@
1
+ module Carson
2
+ class Runtime
3
+ module Local
4
+ private
5
+
6
+ # Installs required hook files and enforces repository hook path.
7
+ def prepare!
8
+ fingerprint_status = block_if_outsider_fingerprints!
9
+ return fingerprint_status unless fingerprint_status.nil?
10
+
11
+ FileUtils.mkdir_p( hooks_dir )
12
+ missing_templates = config.managed_hooks.reject { |name| File.file?( hook_template_path( hook_name: name ) ) }
13
+ unless missing_templates.empty?
14
+ puts_line "BLOCK: missing hook templates in Carson: #{missing_templates.join( ', ' )}."
15
+ return EXIT_BLOCK
16
+ end
17
+
18
+ symlinked = symlink_hook_files
19
+ unless symlinked.empty?
20
+ puts_line "BLOCK: symlink hook files are not allowed: #{symlinked.join( ', ' )}."
21
+ return EXIT_BLOCK
22
+ end
23
+
24
+ config.managed_hooks.each do |hook_name|
25
+ source_path = hook_template_path( hook_name: hook_name )
26
+ target_path = File.join( hooks_dir, hook_name )
27
+ FileUtils.cp( source_path, target_path )
28
+ FileUtils.chmod( 0o755, target_path )
29
+ puts_verbose "hook_written: #{relative_path( target_path )}"
30
+ end
31
+ git_system!( "config", "core.hooksPath", hooks_dir )
32
+ File.write( File.join( hooks_dir, "workflow_style" ), config.workflow_style )
33
+ puts_verbose "configured_hooks_path: #{hooks_dir}"
34
+ puts_line "Hooks installed (#{config.managed_hooks.count} hooks)."
35
+ EXIT_OK
36
+ end
37
+
38
+ # Canonical hook template location inside Carson repository.
39
+ def hook_template_path( hook_name: )
40
+ File.join( tool_root, "hooks", hook_name )
41
+ end
42
+
43
+ # Reports full hook health and can enforce stricter action messaging in `check`.
44
+ def hooks_health_report( strict: false )
45
+ configured = configured_hooks_path
46
+ expected = hooks_dir
47
+ hooks_path_ok = print_hooks_path_status( configured: configured, expected: expected )
48
+ print_required_hook_status
49
+ hooks_integrity = hook_integrity_state
50
+ hooks_ok = hooks_integrity_ok?( hooks_integrity: hooks_integrity )
51
+ print_hook_action(
52
+ strict: strict,
53
+ hooks_ok: hooks_path_ok && hooks_ok,
54
+ hooks_path_ok: hooks_path_ok,
55
+ configured: configured,
56
+ expected: expected
57
+ )
58
+ hooks_path_ok && hooks_ok
59
+ end
60
+
61
+ def print_hooks_path_status( configured:, expected: )
62
+ configured_abs = configured.nil? ? nil : File.expand_path( configured )
63
+ hooks_path_ok = configured_abs == expected
64
+ puts_verbose "hooks_path: #{configured || '(unset)'}"
65
+ puts_verbose "hooks_path_expected: #{expected}"
66
+ puts_verbose( hooks_path_ok ? "hooks_path_status: ok" : "hooks_path_status: attention" )
67
+ hooks_path_ok
68
+ end
69
+
70
+ def print_required_hook_status
71
+ required_hook_paths.each do |path|
72
+ exists = File.file?( path )
73
+ symlink = File.symlink?( path )
74
+ executable = exists && !symlink && File.executable?( path )
75
+ puts_verbose "hook_file: #{relative_path( path )} exists=#{exists} symlink=#{symlink} executable=#{executable}"
76
+ end
77
+ end
78
+
79
+ def hook_integrity_state
80
+ {
81
+ missing: missing_hook_files,
82
+ non_executable: non_executable_hook_files,
83
+ symlinked: symlink_hook_files
84
+ }
85
+ end
86
+
87
+ def hooks_integrity_ok?( hooks_integrity: )
88
+ missing_ok = hooks_integrity.fetch( :missing ).empty?
89
+ non_executable_ok = hooks_integrity.fetch( :non_executable ).empty?
90
+ symlinked_ok = hooks_integrity.fetch( :symlinked ).empty?
91
+ missing_ok && non_executable_ok && symlinked_ok
92
+ end
93
+
94
+ def print_hook_action( strict:, hooks_ok:, hooks_path_ok:, configured:, expected: )
95
+ return if hooks_ok
96
+
97
+ if strict && !hooks_path_ok
98
+ configured_text = configured.to_s.strip
99
+ if configured_text.empty?
100
+ puts_verbose "ACTION: hooks path is unset (expected=#{expected})."
101
+ else
102
+ puts_verbose "ACTION: hooks path mismatch (configured=#{configured_text}, expected=#{expected})."
103
+ end
104
+ end
105
+ message = strict ? "ACTION: run carson refresh to align hooks with Carson #{Carson::VERSION}." : "ACTION: run carson refresh to enforce local main protections."
106
+ puts_verbose message
107
+ end
108
+
109
+ # Reads configured core.hooksPath and normalises empty values to nil.
110
+ def configured_hooks_path
111
+ stdout_text, = git_capture_soft( "config", "--get", "core.hooksPath" )
112
+ value = stdout_text.to_s.strip
113
+ value.empty? ? nil : value
114
+ end
115
+
116
+ # Fully-qualified required hook file locations in the target repository.
117
+ def required_hook_paths
118
+ config.managed_hooks.map { |name| File.join( hooks_dir, name ) }
119
+ end
120
+
121
+ # Missing required hook files.
122
+ def missing_hook_files
123
+ required_hook_paths.reject { |path| File.file?( path ) }.map { |path| relative_path( path ) }
124
+ end
125
+
126
+ # Required hook files that exist but are not executable.
127
+ def non_executable_hook_files
128
+ required_hook_paths.select { |path| File.file?( path ) && !File.executable?( path ) }.map { |path| relative_path( path ) }
129
+ end
130
+
131
+ # Symlink hooks are disallowed to prevent bypassing managed hook content.
132
+ def symlink_hook_files
133
+ required_hook_paths.select { |path| File.symlink?( path ) }.map { |path| relative_path( path ) }
134
+ end
135
+
136
+ # Local directory where managed hooks are installed.
137
+ def hooks_dir
138
+ File.expand_path( File.join( config.hooks_path, Carson::VERSION ) )
139
+ end
140
+ end
141
+ end
142
+ end