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.
- checksums.yaml +4 -4
- data/.github/pull_request_template.md +0 -2
- data/.github/workflows/carson_policy.yml +5 -14
- data/API.md +6 -36
- data/MANUAL.md +11 -47
- data/README.md +9 -22
- data/RELEASE.md +21 -0
- data/SKILL.md +5 -7
- data/VERSION +1 -1
- data/lib/carson/cli.rb +1 -59
- data/lib/carson/config.rb +25 -69
- data/lib/carson/runtime/audit.rb +1 -174
- data/lib/carson/runtime/govern.rb +7 -36
- data/lib/carson/runtime/local/hooks.rb +142 -0
- data/lib/carson/runtime/local/onboard.rb +356 -0
- data/lib/carson/runtime/local/prune.rb +292 -0
- data/lib/carson/runtime/local/sync.rb +93 -0
- data/lib/carson/runtime/local/template.rb +347 -0
- data/lib/carson/runtime/local.rb +6 -1225
- data/lib/carson/runtime/review/gate_support.rb +1 -1
- data/lib/carson/runtime/review/sweep_support.rb +1 -1
- data/lib/carson/runtime/review/utility.rb +2 -2
- data/lib/carson/runtime/setup.rb +8 -2
- data/lib/carson/runtime.rb +0 -1
- data/templates/.github/carson.md +0 -1
- data/templates/.github/pull_request_template.md +0 -2
- metadata +6 -2
- data/lib/carson/runtime/lint.rb +0 -154
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, :
|
|
11
|
-
:
|
|
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, :
|
|
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, :
|
|
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
|
-
"
|
|
39
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
83
|
-
|
|
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( "
|
|
151
|
-
hooks[ "
|
|
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
|
-
|
|
160
|
-
review[ "
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
216
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
251
|
-
@
|
|
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.
|
|
269
|
-
raise ConfigError, "hooks.
|
|
270
|
-
raise ConfigError, "
|
|
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
|
data/lib/carson/runtime/audit.rb
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
348
|
+
rt = if repo_path == self.repo_root
|
|
349
|
+
self
|
|
380
350
|
else
|
|
381
|
-
|
|
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
|