carson 4.3.1 → 4.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aaef0edd290f56dd797540049069bdb07d2d5e86cef8ca30c6c40d735d37740e
4
- data.tar.gz: 6b5d0df422508ef217f275fdaed1ecbc0a6b3b1e800acab0c1520c38a3cd58cd
3
+ metadata.gz: cef1919871b9d457a8b1ad95b2faa127cbb1a740fa211bf48e1b18110141be5a
4
+ data.tar.gz: bd4f1107862848b8a60d5ef794f43127a6b8f14146ed781bfe72d841220f4190
5
5
  SHA512:
6
- metadata.gz: eede4e41d8d530bb528d0515352f6879b2c4b8f64dacec0c767128f4f8151dcb22b620d0d946ebac395e5edb199f0296acfd7120c4aa211c3e70fc0069910fe1
7
- data.tar.gz: ce5aa317bba3e47807e918a1432ae2ff9b7254f8f921e09420062e84cefd4bdee3eb3de20073e6d8c1167c6ccde487ac267842f4fc17ec2b6dce4ca6eedf70f8
6
+ metadata.gz: c4047e729026abc15be0601316a3521249cdc392c60b24b6ebf61bf5454746d48d649f16f2cda705e5a01ed2c9cce91ac2b407fd8d10ad670453fd2d0742d243
7
+ data.tar.gz: 9dee7343ec14dbf351fce53e0da6b406e52fe64bb71e736bb70ad23dc22c2e82013d34705d901d7b304d79b6cb40ef1cb8cc0c3412da9796ab6d853e81648156
@@ -62,10 +62,6 @@ jobs:
62
62
  working-directory: host
63
63
  run: carson refresh
64
64
 
65
- - name: Carson audit
66
- working-directory: host
67
- run: carson audit
68
-
69
65
  - name: Carson review gate
70
66
  working-directory: host
71
67
  env:
data/API.md CHANGED
@@ -21,7 +21,7 @@ Repo-scoped commands: `carson <repo> <command>` or `carson <command>` when CWD i
21
21
  |---|---|
22
22
  | `carson setup` | Interactive quiz to configure remote, main branch, workflow, and canonical lint-policy path. Writes `~/.carson/config.json`. |
23
23
  | `carson onboard <repo_path>` | Apply one-command baseline setup for a target git repository. Auto-triggers `setup` on first run. Installs or refreshes Carson-managed global hooks. |
24
- | `carson refresh` | Re-apply hooks, templates, and audit across all governed repos after upgrading Carson. Auto-propagates template updates to the remote via worktree (branch workflow: PR on `carson/template-sync`; trunk workflow: push to main). Skips repos with active worktrees or uncommitted changes. |
24
+ | `carson refresh` | Re-apply hooks and templates across all governed repos after upgrading Carson. Auto-propagates template updates to the remote via worktree (branch workflow: PR on `carson/template-sync`; trunk workflow: push to main). Skips repos with active worktrees or uncommitted changes. |
25
25
  | `carson offboard <repo_path>` | Remove Carson-managed host artefacts, detach Carson hooks path, and deregister from `govern.repos`. |
26
26
  | `carson list [--json]` | List all governed repositories. |
27
27
 
@@ -29,9 +29,8 @@ Repo-scoped commands: `carson <repo> <command>` or `carson <command>` when CWD i
29
29
 
30
30
  | Command | Purpose |
31
31
  |---|---|
32
- | `carson audit` | Evaluate governance status and generate report output. |
33
32
  | `carson deliver [--commit MESSAGE]` | Run Carson-owned branch delivery for the current checkout. Plain `deliver` transports existing commits only; `--commit` creates one all-dirty delivery commit first. Before push, Carson verifies the branch is fresh against the configured remote `main`; behind or unknown freshness blocks delivery without creating or refreshing a PR. If freshness is good, Carson pushes, creates or refreshes the PR, watches the delivery for a bounded settle window, merges when clear, syncs local `main`, and reports merge proof for the delivered branch. Non-integrated exits report `Merge deferred` or `Merge blocked` with explicit handoff commands. |
34
- | `carson recover --check NAME [--json]` | Run the exceptional governed recovery path when one governance-owned required check is already red on the default branch. Carson proves the named baseline failure, keeps every other gate intact, merges through the recovery path, and records a machine-readable audit event. |
33
+ | `carson recover --check NAME [--json]` | Run the exceptional governed recovery path when one governance-owned required check is already red on the default branch. Carson proves the named baseline failure, keeps every other gate intact, and merges through the recovery path. |
35
34
  | `carson sync` | Fast-forward local `main` from configured remote when tree is clean. |
36
35
  | `carson prune` | Remove stale local branches whose upstream refs no longer exist. |
37
36
  | `carson housekeep [--json] [--dry-run]` | Attempt to sync the current repo, then reap worktrees with strong abandonment evidence, reconcile integrated delivery worktree records from the ledger, and prune stale branches. Safe cleanup still runs when sync is blocked. |
data/MANUAL.md CHANGED
@@ -40,7 +40,7 @@ On first run (no `~/.carson/config.json` exists), `onboard` launches `carson set
40
40
  - Repository `core.hooksPath` alignment to Carson global hooks.
41
41
  - Commit-time governance gate via managed `pre-commit` hook.
42
42
  - Canonical `.github/*` template synchronisation (when `lint.canonical` is configured).
43
- - Initial governance audit.
43
+ - Initial governance status check.
44
44
 
45
45
  ### Reconfigure later
46
46
 
@@ -78,7 +78,7 @@ jobs:
78
78
  Notes:
79
79
  - When upgrading Carson, update both `carson_ref` and `carson_version` together.
80
80
  - `CARSON_READ_TOKEN` must have read access to your policy source repository.
81
- - The reusable workflow installs a pinned RuboCop gem before `carson audit`; mirror the same pin in host governance workflows for deterministic checks.
81
+ - The reusable workflow installs a pinned RuboCop gem before lint checks; mirror the same pin in host governance workflows for deterministic checks.
82
82
 
83
83
  ### Canonical Templates
84
84
 
@@ -112,7 +112,7 @@ Carson discovers files in this directory and syncs them to governed repos. Root
112
112
 
113
113
  ## Operating Strategies
114
114
 
115
- These strategies are the audit lens for Carson. If behaviour departs from them, either the product model or the implementation needs attention.
115
+ These strategies are the design lens for Carson. If behaviour departs from them, either the product model or the implementation needs attention.
116
116
 
117
117
  ### Git Strategist
118
118
 
@@ -181,7 +181,7 @@ When a governance-owned required check is already red on the default branch, the
181
181
  carson recover --check "Carson governance"
182
182
  ```
183
183
 
184
- Recovery is narrow. Carson proves that the named check is red on the default branch, verifies that the current branch is repairing the governance surface, requires every other required check and the review gate to pass, then records a machine-readable audit event before reporting success.
184
+ Recovery is narrow. Carson proves that the named check is red on the default branch, verifies that the current branch is repairing the governance surface, requires every other required check and the review gate to pass, then records a recovery event before reporting success.
185
185
 
186
186
  If Carson refuses recovery, the message explains the exact missing proof or remaining gate and tells you what to do next.
187
187
 
@@ -255,13 +255,13 @@ The two tools serve different layers. EnterWorktree owns the session (CWD switch
255
255
 
256
256
  ```bash
257
257
  carson sync # fast-forward local main
258
- carson audit # full governance check
258
+ carson status # check repository state
259
259
  ```
260
260
 
261
261
  **Before push or PR update:**
262
262
 
263
263
  ```bash
264
- carson audit
264
+ carson status
265
265
  carson template check
266
266
  ```
267
267
 
@@ -287,7 +287,7 @@ carson list --json # machine-readable output
287
287
  **Portfolio maintenance:**
288
288
 
289
289
  ```bash
290
- carson refresh # re-apply hooks, templates, audit across all repos
290
+ carson refresh # re-apply hooks and templates across all repos
291
291
  carson list # list all governed repositories
292
292
  ```
293
293
 
@@ -411,7 +411,7 @@ Change: `CARSON_REVIEW_DISPOSITION`.
411
411
 
412
412
  How much Carson prints.
413
413
 
414
- - Default: **concise**. A healthy audit prints one line. Problems print actionable summaries with cause and fix.
414
+ - Default: **concise**. A healthy run prints one line. Problems print actionable summaries with cause and fix.
415
415
  - `--verbose` restores full diagnostic key-value output for debugging.
416
416
 
417
417
  ## Configuration
data/README.md CHANGED
@@ -66,7 +66,7 @@ carson housekeep
66
66
 
67
67
  `carson deliver` runs Carson-owned branch delivery. Before any push, Carson verifies that the branch is fresh against the configured remote `main` using local git. If freshness is behind or unknown, delivery stops with an explicit block and no PR side effect. After a PR exists, Carson delegates merge eligibility to GitHub's `mergeStateStatus` — if GitHub reports `CLEAN`, Carson proceeds regardless of local ancestor status. Plain `deliver` transports existing commits only; `carson deliver --commit "..."` creates one all-dirty delivery commit first, then continues the same flow. If the branch is fresh, Carson pushes it, creates or refreshes the PR, watches the delivery for a bounded settle window, merges when clear, and syncs local `main`. If the settle window expires without integration, Carson exits with an explicit `Merge deferred` or `Merge blocked` handoff instead of leaving the PR mysteriously open. Deferred and blocked exits say whether Carson attempted merge and list the next commands in order.
68
68
 
69
- When one Carson-governed required check is already red on the default branch and the current PR is the repair, use `carson recover --check "..."`. Recovery is the explicit exceptional path: Carson proves the baseline failure, keeps every other gate intact, records an audit event, and never teaches operators to step outside Carson first.
69
+ When one Carson-governed required check is already red on the default branch and the current PR is the repair, use `carson recover --check "..."`. Recovery is the explicit exceptional path: Carson proves the baseline failure, keeps every other gate intact, records a recovery event, and never teaches operators to step outside Carson first.
70
70
 
71
71
  `carson worktree list` is the visibility surface for cleanup: it shows every registered worktree, the branch, PR state, whether the content is already on `main`, and Carson's keep or reap recommendation. Content matching `main` is diagnostic, not standalone proof that a worktree is abandoned. `carson housekeep` is the main cleanup pass: sync main, reap worktrees with strong abandonment evidence (for example a missing directory, merged PR, or closed abandoned PR), and prune stale branches. When work needs to be abandoned instead of landed, use `carson abandon <pr-number|pr-url|branch>` to close the PR and clean up the branch/worktree safely.
72
72
 
data/RELEASE.md CHANGED
@@ -7,6 +7,24 @@ Release-note scope rule:
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Removed
11
+
12
+ - **`carson audit` dissolved.** The audit command and its pre-commit gate have been removed entirely. Three of its seven checks were already enforced by the commands that need them (sealed workbench by `pack!`, working tree by Courier, main sync by `sync!`). The remaining four (PR status, CI baseline, hooks health, outsider fingerprints) were either unproven at commit time or already called by individual commands. Bare `carson` now defaults to `status` instead of `audit`. The pre-commit hook is now a no-op reserved for future use.
13
+
14
+ ### Why
15
+
16
+ Audit was a remote-centred pre-commit gate — it blocked commits based on delivery-time concerns (PR checks, CI baseline) that don't belong at commit time. In local-centred mode it was purely dead weight. Rather than relocate it to bureau, we verified that none of its unique checks had proven value at any gate, and dissolved the whole thing. Scars, not speculation.
17
+
18
+ ## 4.3.2
19
+
20
+ ### Fixed
21
+
22
+ - **Vault gives correct recovery when main worktree is dirty.** `vault_blocked` now inspects stderr from `git merge --ff-only` to distinguish dirty-tree conflicts from diverged-history blocks. Previously, both cases got the generic "rebase" advice — misleading when the branch was already a valid fast-forward descendant and the real problem was uncommitted files in the main worktree.
23
+
24
+ ### Why
25
+
26
+ An agent rebased three times on an already-correct branch because Carson misdiagnosed a dirty-tree block as divergence. The fix: read stderr, give the right advice. Scars, not speculation.
27
+
10
28
  ## 4.3.1
11
29
 
12
30
  ### Changed
data/VERSION CHANGED
@@ -1 +1 @@
1
- 4.3.1
1
+ 4.3.2
@@ -1,19 +1,4 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
-
4
- if [[ -n "${CARSON_BIN:-}" ]]; then
5
- carson_command=( "$CARSON_BIN" )
6
- elif command -v carson >/dev/null 2>&1; then
7
- carson_command=( "carson" )
8
- else
9
- echo "Carson policy: 'carson' command is required for pre-commit governance checks." >&2
10
- echo "Install Carson and rerun 'carson prepare'." >&2
11
- exit 1
12
- fi
13
-
14
- if ! "${carson_command[@]}" audit; then
15
- echo "Carson policy: commit blocked by governance audit." >&2
16
- echo "Resolve reported policy blocks before committing." >&2
17
- echo "If lint policy files are missing, run: carson lint policy --source <path-or-git-url>" >&2
18
- exit 1
19
- fi
3
+ # Pre-commit hook — reserved for future use.
4
+ exit 0
@@ -3,7 +3,7 @@ module Carson
3
3
  module Adapters
4
4
  module Agent
5
5
  WorkOrder = Struct.new( :repo, :branch, :pr_number, :objective, :context, :acceptance_checks, keyword_init: true )
6
- # objective: "fix_ci" | "address_review" | "fix_audit"
6
+ # objective: "fix_ci" | "address_review"
7
7
  # context: String (legacy — PR title) or Hash with structured evidence:
8
8
  # fix_ci: { title:, ci_logs:, ci_run_url:, prior_attempt: { summary:, dispatched_at: } }
9
9
  # address_review: { title:, review_findings: [{ kind:, url:, body: }], prior_attempt: ... }
data/lib/carson/config.rb CHANGED
@@ -28,7 +28,6 @@ module Carson
28
28
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
29
29
  :review_sweep_states, :review_disposition, :review_risk_keywords,
30
30
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
31
- :audit_advisory_check_names,
32
31
  :workflow_style,
33
32
  :govern_repos, :govern_merge_method,
34
33
  :govern_agent_provider, :govern_state_path,
@@ -80,9 +79,6 @@ module Carson
80
79
  "label" => "carson-review-sweep"
81
80
  }
82
81
  },
83
- "audit" => {
84
- "advisory_check_names" => [ "Scheduled review sweep", "Carson governance", "Tag, release, publish" ]
85
- },
86
82
  "deliver" => {
87
83
  "poll_interval_at_bureau" => 30
88
84
  },
@@ -170,9 +166,6 @@ module Carson
170
166
  sweep[ "states" ] = states unless states.empty?
171
167
  bot_usernames = env_string_array( key: "CARSON_REVIEW_BOT_USERNAMES" )
172
168
  review[ "bot_usernames" ] = bot_usernames unless bot_usernames.empty?
173
- audit = fetch_hash_section( data: copy, key: "audit" )
174
- advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
175
- audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
176
169
  deliver = fetch_hash_section( data: copy, key: "deliver" )
177
170
  deliver[ "poll_interval_at_bureau" ] = env_integer( key: "CARSON_POLL_INTERVAL_AT_BUREAU", fallback: deliver.fetch( "poll_interval_at_bureau" ) )
178
171
  govern = fetch_hash_section( data: copy, key: "govern" )
@@ -243,9 +236,6 @@ module Carson
243
236
  @review_tracking_issue_title = fetch_string( hash: tracking_issue_hash, key: "title" )
244
237
  @review_tracking_issue_label = fetch_string( hash: tracking_issue_hash, key: "label" )
245
238
  @review_bot_usernames = fetch_optional_string_array( hash: review_hash, key: "bot_usernames" )
246
- audit_hash = fetch_hash( hash: data, key: "audit" )
247
- @audit_advisory_check_names = fetch_optional_string_array( hash: audit_hash, key: "advisory_check_names" )
248
-
249
239
  deliver_hash = fetch_hash( hash: data, key: "deliver" )
250
240
  @poll_interval_at_bureau = fetch_non_negative_integer( hash: deliver_hash, key: "poll_interval_at_bureau" )
251
241
 
@@ -1,12 +1,12 @@
1
1
  # Repository onboarding and refresh lifecycle.
2
- # Onboard: detect remote, install hooks, apply templates, run initial audit.
2
+ # Onboard: detect remote, install hooks, apply templates.
3
3
  # Refresh: re-apply hooks and templates after Carson upgrade.
4
4
  # Refresh all: batch refresh across governed portfolio with safety checks.
5
5
  module Carson
6
6
  class Runtime
7
7
  module Local
8
8
  # One-command onboarding for new repositories: detect remote, install hooks,
9
- # apply templates, and run initial audit.
9
+ # and apply templates.
10
10
  def onboard!
11
11
  fingerprint_status = block_if_outsider_fingerprints!
12
12
  return fingerprint_status unless fingerprint_status.nil?
@@ -32,7 +32,7 @@ module Carson
32
32
  onboard_apply!
33
33
  end
34
34
 
35
- # Re-applies hooks, templates, and audit after upgrading Carson.
35
+ # Re-applies hooks and templates after upgrading Carson.
36
36
  def refresh!
37
37
  fingerprint_status = block_if_outsider_fingerprints!
38
38
  return fingerprint_status unless fingerprint_status.nil?
@@ -55,13 +55,8 @@ module Carson
55
55
 
56
56
  @template_sync_result = template_propagate!( drift_count: drift_count + stale_count )
57
57
 
58
- audit_status = audit!
59
- if audit_status == EXIT_OK
60
- puts_line "OK: Carson refresh completed for #{repo_root}."
61
- elsif audit_status == EXIT_BLOCK
62
- puts_line "Refresh complete — some checks need attention. Run carson audit for details."
63
- end
64
- return audit_status
58
+ puts_line "OK: Carson refresh completed for #{repo_root}."
59
+ return EXIT_OK
65
60
  end
66
61
 
67
62
  puts_line "Refresh"
@@ -82,12 +77,11 @@ module Carson
82
77
 
83
78
  @template_sync_result = template_propagate!( drift_count: total_drift )
84
79
 
85
- audit_status = audit!
86
80
  puts_line "Refresh complete."
87
- audit_status
81
+ EXIT_OK
88
82
  end
89
83
 
90
- # Re-applies hooks, templates, and audit across all governed repositories.
84
+ # Re-applies hooks and templates across all governed repositories.
91
85
  # Checks each repo for safety (active worktrees, uncommitted changes) and
92
86
  # marks unsafe repos as pending to avoid disrupting active work.
93
87
  def refresh_all!
@@ -202,7 +196,7 @@ module Carson
202
196
 
203
197
  private
204
198
 
205
- # Concise onboard orchestration: hooks, templates, remote, audit, guidance.
199
+ # Concise onboard orchestration: hooks, templates, remote, guidance.
206
200
  def onboard_apply!
207
201
  hook_status = with_captured_output { prepare! }
208
202
  return hook_status unless hook_status == EXIT_OK
@@ -218,7 +212,6 @@ module Carson
218
212
  end
219
213
 
220
214
  onboard_report_remote!
221
- audit_status = onboard_run_audit!
222
215
 
223
216
  puts_line ""
224
217
  puts_line "Carson at your service."
@@ -235,7 +228,7 @@ module Carson
235
228
  puts_line ""
236
229
  puts_line "To adjust any setting: carson setup"
237
230
 
238
- audit_status
231
+ EXIT_OK
239
232
  end
240
233
 
241
234
  # Friendly remote status for onboard output.
@@ -247,33 +240,6 @@ module Carson
247
240
  end
248
241
  end
249
242
 
250
- # Runs audit with captured output; reports summary instead of full detail.
251
- def onboard_run_audit!
252
- audit_error = nil
253
- audit_status = with_captured_output { audit! }
254
- rescue StandardError => exception
255
- audit_error = exception
256
- audit_status = EXIT_OK
257
- ensure
258
- return onboard_print_audit_result( status: audit_status, error: audit_error )
259
- end
260
-
261
- def onboard_print_audit_result( status:, error: )
262
- if error
263
- if error.message.to_s.match?( /HEAD|rev-parse/ )
264
- puts_line "No commits yet — run carson audit after your first commit."
265
- else
266
- puts_line "Audit skipped — run carson audit for details."
267
- end
268
- return EXIT_OK
269
- end
270
-
271
- if status == EXIT_BLOCK
272
- puts_line "Some checks need attention — run carson audit for details."
273
- end
274
- status
275
- end
276
-
277
243
  # Verifies configured remote exists and logs status without mutating remotes.
278
244
  def report_detected_remote!
279
245
  if git_remote_exists?( remote_name: config.git_remote )
@@ -365,7 +365,7 @@ module Carson
365
365
  case cause
366
366
  when "ci" then "fix_ci"
367
367
  when "review" then "address_review"
368
- else "fix_audit"
368
+ else "fix_ci"
369
369
  end
370
370
  end
371
371
 
@@ -84,14 +84,14 @@ module Carson
84
84
  }
85
85
  if baseline.fetch( :status ) == "skipped"
86
86
  result[ :error ] = "unable to verify the default-branch baseline: #{baseline.fetch( :skip_reason )}"
87
- result[ :recovery ] = "run carson audit after fixing GitHub access"
87
+ result[ :recovery ] = "run carson status after fixing GitHub access"
88
88
  return recover_finish( result: result, exit_code: EXIT_ERROR, json_output: json_output )
89
89
  end
90
90
 
91
91
  baseline_entry = recovery_baseline_entry( baseline: baseline, check_name: check_name )
92
92
  if baseline_entry.nil?
93
93
  result[ :error ] = "#{check_name} is not red on #{baseline.fetch( :default_branch, config.main_branch )}"
94
- result[ :recovery ] = "run carson audit to confirm the baseline check state"
94
+ result[ :recovery ] = "run carson status to confirm the baseline check state"
95
95
  return recover_finish( result: result, exit_code: EXIT_BLOCK, json_output: json_output )
96
96
  end
97
97
 
@@ -408,7 +408,7 @@ module Carson
408
408
  elsif result[ :synced ]
409
409
  puts_line "Synced local #{result.fetch( :main_branch )}."
410
410
  end
411
- puts_line "Recorded recovery audit for #{result.fetch( :check )}."
411
+ puts_line "Recorded recovery for #{result.fetch( :check )}."
412
412
  puts_line "Check back with #{result.fetch( :next_step )}" if result[ :next_step ]
413
413
  end
414
414
  end
@@ -163,7 +163,7 @@ module Carson
163
163
  }
164
164
  end
165
165
 
166
- # When sweep is clear, close prior tracking issue and add one clear audit comment.
166
+ # When sweep is clear, close prior tracking issue and add a clear comment.
167
167
  def close_review_sweep_issue_if_open( repo_slug:, issue: )
168
168
  return { action: "none", issue: nil } if issue.nil?
169
169
  return { action: "none", issue: issue } unless issue.fetch( :state ) == "OPEN"
@@ -14,8 +14,6 @@ module Carson
14
14
  EXIT_ERROR = 1
15
15
  EXIT_BLOCK = 2
16
16
 
17
- REPORT_MD = "pr_report_latest.md".freeze
18
- REPORT_JSON = "pr_report_latest.json".freeze
19
17
  REVIEW_GATE_REPORT_MD = "review_gate_latest.md".freeze
20
18
  REVIEW_GATE_REPORT_JSON = "review_gate_latest.json".freeze
21
19
  REVIEW_SWEEP_REPORT_MD = "review_sweep_latest.md".freeze
@@ -43,7 +41,7 @@ module Carson
43
41
  attr_reader :template_sync_result
44
42
 
45
43
  # Lazy ledger: only constructed when a command actually needs delivery state.
46
- # Read-only commands (worktree list, audit, prune, sync) never touch the
44
+ # Read-only commands (worktree list, prune, sync) never touch the
47
45
  # govern state lock file.
48
46
  def ledger
49
47
  @ledger ||= Ledger.new( path: @config.govern_state_path )
@@ -115,7 +113,7 @@ module Carson
115
113
  success
116
114
  end
117
115
 
118
- # Human-readable plural suffix helper for audit messaging.
116
+ # Human-readable plural suffix helper.
119
117
  def plural_suffix( count: )
120
118
  count.to_i == 1 ? "" : "s"
121
119
  end
@@ -366,7 +364,6 @@ module Carson
366
364
  end
367
365
 
368
366
  require_relative "runtime/local"
369
- require_relative "runtime/audit"
370
367
  require_relative "runtime/loop_runner"
371
368
  require_relative "runtime/housekeep"
372
369
  require_relative "runtime/list"
@@ -63,12 +63,22 @@ module Carson
63
63
  end
64
64
 
65
65
  # Build the blocked/error result when vault acceptance fails.
66
+ # Distinguishes dirty-tree conflicts from diverged-history blocks
67
+ # so the agent gets the correct recovery advice.
66
68
  def vault_blocked( parcel, stderr )
67
- {
68
- status: "block",
69
- error: "#{parcel.label} cannot be fast-forwarded into #{@main_label}.",
70
- recovery: "Rebase onto #{@main_label} and deliver again."
71
- }
69
+ if stderr.to_s.include?( "would be overwritten" )
70
+ {
71
+ status: "block",
72
+ error: "Main worktree has uncommitted changes that conflict with #{parcel.label}.",
73
+ recovery: "Commit or discard the dirty files in the main worktree, then deliver again."
74
+ }
75
+ else
76
+ {
77
+ status: "block",
78
+ error: "#{parcel.label} cannot be fast-forwarded into #{@main_label}.",
79
+ recovery: "Rebase onto #{@main_label} and deliver again."
80
+ }
81
+ end
72
82
  end
73
83
 
74
84
  end
data/lib/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "optparse"
5
5
  module Carson
6
6
  class CLI
7
7
  PORTFOLIO_COMMANDS = %w[onboard offboard list refresh version].freeze
8
- REPO_COMMANDS = %w[deliver receive sync status audit prune housekeep worktree abandon recover review template setup checkin checkout].freeze
8
+ REPO_COMMANDS = %w[deliver receive sync status prune housekeep worktree abandon recover review template setup checkin checkout].freeze
9
9
  ALL_COMMANDS = ( PORTFOLIO_COMMANDS + REPO_COMMANDS ).freeze
10
10
 
11
11
  def self.start( arguments:, repo_root:, tool_root:, output:, error: )
@@ -154,7 +154,7 @@ module Carson
154
154
  return { command: :help }
155
155
  end
156
156
  return { command: "version" } if [ "--version", "-v" ].include?( first )
157
- return { command: "audit" } if arguments.empty?
157
+ return { command: "status" } if arguments.empty?
158
158
 
159
159
  nil
160
160
  end
@@ -217,8 +217,6 @@ module Carson
217
217
  parse_sync_command( arguments: arguments, error: error )
218
218
  when "status"
219
219
  parse_status_command( arguments: arguments, error: error )
220
- when "audit"
221
- parse_audit_command( arguments: arguments, error: error )
222
220
  when "prune"
223
221
  parse_prune_command( arguments: arguments, error: error )
224
222
  when "housekeep"
@@ -284,7 +282,7 @@ module Carson
284
282
  parser.banner = "Usage: carson onboard <REPO_PATH>"
285
283
  parser.separator ""
286
284
  parser.separator "Register a repository for Carson governance."
287
- parser.separator "Detects the remote, installs hooks, applies templates, and runs initial audit."
285
+ parser.separator "Detects the remote, installs hooks, and applies templates."
288
286
  parser.separator ""
289
287
  parser.separator "Examples:"
290
288
  parser.separator " carson onboard ~/Dev/app Onboard a specific repository"
@@ -677,36 +675,6 @@ module Carson
677
675
  { command: :invalid }
678
676
  end
679
677
 
680
- # --- audit ---
681
-
682
- def self.parse_audit_command( arguments:, error: )
683
- options = { json: false }
684
- audit_parser = OptionParser.new do |parser|
685
- parser.banner = "Usage: carson audit [--json]"
686
- parser.separator ""
687
- parser.separator "Run pre-commit health checks on the repository."
688
- parser.separator "Validates hooks, main-branch sync, PR status, and CI baseline."
689
- parser.separator "Exits with a non-zero status when policy violations are found."
690
- parser.separator ""
691
- parser.separator "Options:"
692
- parser.on( "--json", "Machine-readable JSON output" ) { options[ :json ] = true }
693
- parser.separator ""
694
- parser.separator "Examples:"
695
- parser.separator " carson audit Check repository health (also the default command)"
696
- parser.separator " carson audit --json Structured output for agent consumption"
697
- end
698
- audit_parser.parse!( arguments )
699
- unless arguments.empty?
700
- error.puts "#{BADGE} Unexpected arguments for audit: #{arguments.join( ' ' )}"
701
- return { command: :invalid }
702
- end
703
- { command: "audit", json: options[ :json ] }
704
- rescue OptionParser::ParseError => exception
705
- error.puts "#{BADGE} #{exception.message}"
706
- error.puts audit_parser
707
- { command: :invalid }
708
- end
709
-
710
678
  # --- abandon ---
711
679
 
712
680
  def self.parse_abandon_command( arguments:, error: )
@@ -854,7 +822,7 @@ module Carson
854
822
  parser.banner = "Usage: carson recover --check NAME [--json]"
855
823
  parser.separator ""
856
824
  parser.separator "Merge the current repair PR when one governance-owned required check is already red on the default branch."
857
- parser.separator "Recovery is narrow: Carson verifies the baseline failure, keeps every other gate intact, and records an audit event."
825
+ parser.separator "Recovery is narrow: Carson verifies the baseline failure, keeps every other gate intact, and records a recovery event."
858
826
  parser.separator ""
859
827
  parser.separator "Options:"
860
828
  parser.on( "--check NAME", "Name of the governance-owned required check to recover" ) { |value| options[ :check_name ] = value }
@@ -988,8 +956,6 @@ module Carson
988
956
  runtime.status!( json_output: parsed.fetch( :json, false ) )
989
957
  when "setup"
990
958
  runtime.setup!( cli_choices: parsed.fetch( :cli_choices, {} ) )
991
- when "audit"
992
- runtime.audit!( json_output: parsed.fetch( :json, false ) )
993
959
  when "abandon"
994
960
  runtime.abandon!( target: parsed.fetch( :target ), json_output: parsed.fetch( :json, false ) )
995
961
  when "sync"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carson
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.3.1
4
+ version: 4.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -57,7 +57,6 @@ files:
57
57
  - lib/carson/revision.rb
58
58
  - lib/carson/runtime.rb
59
59
  - lib/carson/runtime/abandon.rb
60
- - lib/carson/runtime/audit.rb
61
60
  - lib/carson/runtime/deliver.rb
62
61
  - lib/carson/runtime/housekeep.rb
63
62
  - lib/carson/runtime/list.rb
@@ -1,603 +0,0 @@
1
- # Pre-commit audit — checks hooks, main sync, PR checks, and CI baseline.
2
- # Exits with EXIT_BLOCK when policy violations are found.
3
- # Supports --json for machine-readable structured output.
4
- require "cgi"
5
-
6
- module Carson
7
- class Runtime
8
- module Audit
9
- def audit!( json_output: false )
10
- # Sealed workbench guard — hard block before anything else.
11
- # The warehouse seals the workbench when a parcel ships.
12
- # No commits allowed until the delivery outcome is confirmed.
13
- sealed_result = audit_sealed_workbench( json_output: json_output )
14
- return sealed_result unless sealed_result.nil?
15
-
16
- fingerprint_status = block_if_outsider_fingerprints!
17
- return fingerprint_status unless fingerprint_status.nil?
18
- unless head_exists?
19
- if json_output
20
- output.puts JSON.pretty_generate( { command: "audit", status: "skipped", reason: "no commits yet", exit_code: EXIT_OK } )
21
- else
22
- puts_line "No commits yet — audit skipped for initial commit."
23
- end
24
- return EXIT_OK
25
- end
26
- audit_state = "ok"
27
- audit_concise_problems = []
28
- puts_verbose ""
29
- puts_verbose "[Repository]"
30
- puts_verbose "root: #{repo_root}"
31
- puts_verbose "current_branch: #{current_branch}"
32
- puts_verbose ""
33
- puts_verbose "[Working Tree]"
34
- puts_verbose git_capture!( "status", "--short", "--branch" ).strip
35
- working_tree = audit_working_tree_report
36
- if working_tree.fetch( :status ) == "block"
37
- puts_verbose "ACTION: #{working_tree.fetch( :error )}; #{working_tree.fetch( :recovery )}."
38
- audit_state = "block"
39
- audit_concise_problems << "Working tree: #{working_tree.fetch( :error )} — #{working_tree.fetch( :recovery )}."
40
- end
41
- puts_verbose ""
42
- puts_verbose "[Hooks]"
43
- hooks_ok = hooks_health_report
44
- hooks_status = hooks_ok ? "ok" : "mismatch"
45
- unless hooks_ok
46
- audit_state = "block"
47
- audit_concise_problems << "Hooks don't match — run carson refresh."
48
- end
49
- puts_verbose ""
50
- puts_verbose "[Main Sync Status]"
51
- ahead_count, behind_count, main_error = main_sync_counts
52
- main_sync = { ahead: 0, behind: 0, status: "ok" }
53
- if main_error
54
- puts_verbose "main_vs_remote_main: unknown"
55
- puts_verbose "WARN: unable to calculate main sync status (#{main_error})."
56
- audit_state = "attention" if audit_state == "ok"
57
- audit_concise_problems << "Main sync: unable to determine — check remote connectivity."
58
- main_sync = { ahead: 0, behind: 0, status: "unknown", error: main_error }
59
- elsif ahead_count.positive?
60
- puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
61
- puts_verbose "main_vs_remote_main_behind: #{behind_count}"
62
- puts_verbose "ACTION: local #{config.main_branch} is ahead of #{config.git_remote}/#{config.main_branch} by #{ahead_count} commit#{plural_suffix( count: ahead_count )}; reset local drift before commit/push workflows."
63
- audit_state = "block"
64
- audit_concise_problems << "Main sync (#{config.git_remote}): ahead by #{ahead_count} — git fetch #{config.git_remote}, or carson setup to switch remote."
65
- main_sync = { ahead: ahead_count, behind: behind_count, status: "ahead" }
66
- elsif behind_count.positive?
67
- puts_verbose "main_vs_remote_main_ahead: #{ahead_count}"
68
- puts_verbose "main_vs_remote_main_behind: #{behind_count}"
69
- puts_verbose "ACTION: local #{config.main_branch} is behind #{config.git_remote}/#{config.main_branch} by #{behind_count} commit#{plural_suffix( count: behind_count )}; run carson sync."
70
- audit_state = "attention" if audit_state == "ok"
71
- audit_concise_problems << "Main sync (#{config.git_remote}): behind by #{behind_count} — run carson sync."
72
- main_sync = { ahead: ahead_count, behind: behind_count, status: "behind" }
73
- else
74
- puts_verbose "main_vs_remote_main_ahead: 0"
75
- puts_verbose "main_vs_remote_main_behind: 0"
76
- puts_verbose "ACTION: local #{config.main_branch} is in sync with #{config.git_remote}/#{config.main_branch}."
77
- end
78
- puts_verbose ""
79
- puts_verbose "[PR and Required Checks (gh)]"
80
- monitor_report = pr_and_check_report
81
- audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( monitor_report.fetch( :status ) )
82
- if monitor_report.fetch( :status ) == "attention"
83
- checks = monitor_report.fetch( :checks )
84
- failing_count = checks.fetch( :failing_count )
85
- pending_count = checks.fetch( :pending_count )
86
- total = checks.fetch( :required_total )
87
- fail_names = checks.fetch( :failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
88
- if failing_count.positive? && pending_count.positive?
89
- audit_concise_problems << "Checks: #{failing_count} failing (#{fail_names}), #{pending_count} pending of #{total} required."
90
- elsif failing_count.positive?
91
- audit_concise_problems << "Checks: #{failing_count} of #{total} failing (#{fail_names})."
92
- elsif pending_count.positive?
93
- audit_concise_problems << "Checks: pending (#{total - pending_count} of #{total} complete)."
94
- end
95
- end
96
- puts_verbose ""
97
- puts_verbose "[Default Branch CI Baseline (gh)]"
98
- default_branch_baseline = default_branch_ci_baseline_report
99
- audit_state = "attention" if audit_state == "ok" && !%w[ok skipped].include?( default_branch_baseline.fetch( :status ) )
100
- baseline_status = default_branch_baseline.fetch( :status )
101
- if baseline_status == "block"
102
- parts = []
103
- if default_branch_baseline.fetch( :failing_count ).positive?
104
- names = default_branch_baseline.fetch( :failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
105
- parts << "#{default_branch_baseline.fetch( :failing_count )} failing (#{names})"
106
- end
107
- if default_branch_baseline.fetch( :pending_count ).positive?
108
- names = default_branch_baseline.fetch( :pending ).map { |entry| entry.fetch( :name ) }.join( ", " )
109
- parts << "#{default_branch_baseline.fetch( :pending_count )} pending (#{names})"
110
- end
111
- parts << "no check-runs for active workflows" if default_branch_baseline.fetch( :no_check_evidence )
112
- audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )} — fix before merge."
113
- elsif baseline_status == "attention"
114
- parts = []
115
- if default_branch_baseline.fetch( :advisory_failing_count ).positive?
116
- names = default_branch_baseline.fetch( :advisory_failing ).map { |entry| entry.fetch( :name ) }.join( ", " )
117
- parts << "#{default_branch_baseline.fetch( :advisory_failing_count )} advisory failing (#{names})"
118
- end
119
- if default_branch_baseline.fetch( :advisory_pending_count ).positive?
120
- names = default_branch_baseline.fetch( :advisory_pending ).map { |entry| entry.fetch( :name ) }.join( ", " )
121
- parts << "#{default_branch_baseline.fetch( :advisory_pending_count )} advisory pending (#{names})"
122
- end
123
- audit_concise_problems << "Baseline (#{default_branch_baseline.fetch( :default_branch, config.main_branch )}): #{parts.join( ', ' )}."
124
- end
125
- if config.lint_canonical.nil? || config.lint_canonical.to_s.empty?
126
- puts_verbose ""
127
- puts_verbose "[Canonical Lint Policy]"
128
- puts_verbose "HINT: lint.canonical not configured — run carson setup to enable."
129
- end
130
- write_and_print_pr_monitor_report(
131
- report: monitor_report.merge(
132
- default_branch_baseline: default_branch_baseline,
133
- audit_status: audit_state
134
- )
135
- )
136
- exit_code = audit_state == "block" ? EXIT_BLOCK : EXIT_OK
137
-
138
- if json_output
139
- result = {
140
- command: "audit",
141
- status: audit_state,
142
- branch: current_branch,
143
- working_tree: working_tree,
144
- hooks: { status: hooks_status },
145
- main_sync: main_sync,
146
- pr: monitor_report[ :pr ],
147
- checks: monitor_report.fetch( :checks ),
148
- baseline: {
149
- status: default_branch_baseline.fetch( :status ),
150
- repository: default_branch_baseline[ :repository ],
151
- failing_count: default_branch_baseline.fetch( :failing_count ),
152
- pending_count: default_branch_baseline.fetch( :pending_count ),
153
- advisory_failing_count: default_branch_baseline.fetch( :advisory_failing_count ),
154
- advisory_pending_count: default_branch_baseline.fetch( :advisory_pending_count )
155
- },
156
- problems: audit_concise_problems,
157
- exit_code: exit_code
158
- }
159
- output.puts JSON.pretty_generate( result )
160
- else
161
- puts_verbose ""
162
- puts_verbose "[Audit Result]"
163
- puts_verbose "status: #{audit_state}"
164
- puts_verbose( audit_state == "block" ? "ACTION: local policy block must be resolved before commit/push." : "ACTION: no local hard block detected." )
165
- unless verbose?
166
- audit_concise_problems.each { |problem| puts_line problem }
167
- puts_line format_audit_state( audit_state )
168
- end
169
- end
170
- exit_code
171
- end
172
-
173
- # rubocop:disable Layout/AccessModifierIndentation -- tab-width calculation produces unfixable mixed tabs+spaces
174
- private
175
- # rubocop:enable Layout/AccessModifierIndentation
176
-
177
- def format_audit_state( state )
178
- case state
179
- when "ok" then "Audit passed."
180
- when "block" then "Audit blocked."
181
- when "attention" then "Audit: needs attention."
182
- else "Audit: #{state}"
183
- end
184
- end
185
-
186
- # Check if the workbench is sealed (parcel in flight).
187
- # Returns EXIT_BLOCK if sealed, nil otherwise.
188
- # Delegates to Warehouse so the seal path is resolved in one place.
189
- def audit_sealed_workbench( json_output: )
190
- warehouse = Warehouse.new( path: work_dir )
191
- return nil unless warehouse.sealed?
192
-
193
- tracking_number = warehouse.sealed_tracking_number || "unknown"
194
- if json_output
195
- require "json"
196
- output.puts JSON.pretty_generate( {
197
- command: "audit",
198
- status: "block",
199
- reason: "workbench_sealed",
200
- tracking_number: tracking_number,
201
- recovery: "carson worktree create <name>",
202
- exit_code: EXIT_BLOCK
203
- } )
204
- else
205
- puts_line "Branch is locked — PR ##{tracking_number} in flight."
206
- puts_line " \u2192 carson worktree create <name>"
207
- end
208
- EXIT_BLOCK
209
- end
210
-
211
- def audit_working_tree_report
212
- dirty_reason = dirty_worktree_reason
213
- return { dirty: false, context: nil, status: "ok" } if dirty_reason.nil?
214
-
215
- if dirty_reason == "main_worktree"
216
- {
217
- dirty: true,
218
- context: dirty_reason,
219
- status: "block",
220
- error: "main working tree has uncommitted changes",
221
- recovery: "create a worktree with carson worktree create <name>"
222
- }
223
- else
224
- { dirty: true, context: dirty_reason, status: "ok" }
225
- end
226
- end
227
-
228
- def pr_and_check_report
229
- report = {
230
- generated_at: Time.now.utc.iso8601,
231
- branch: current_branch,
232
- status: "ok",
233
- skip_reason: nil,
234
- pr: nil,
235
- checks: {
236
- status: "unknown",
237
- skip_reason: nil,
238
- required_total: 0,
239
- failing_count: 0,
240
- pending_count: 0,
241
- failing: [],
242
- pending: []
243
- }
244
- }
245
- unless gh_available?
246
- report[ :status ] = "skipped"
247
- report[ :skip_reason ] = "gh CLI not available in PATH"
248
- puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
249
- return report
250
- end
251
- pr_stdout, pr_stderr, pr_success, = gh_run( "pr", "view", current_branch, "--json", "number,title,url,state,reviewDecision" )
252
- unless pr_success
253
- error_text = gh_error_text( stdout_text: pr_stdout, stderr_text: pr_stderr, fallback: "unable to read PR for branch #{current_branch}" )
254
- report[ :status ] = "skipped"
255
- report[ :skip_reason ] = error_text
256
- puts_verbose "SKIP: #{error_text}"
257
- return report
258
- end
259
- pr_data = JSON.parse( pr_stdout )
260
- report[ :pr ] = {
261
- number: pr_data[ "number" ],
262
- title: pr_data[ "title" ].to_s,
263
- url: pr_data[ "url" ].to_s,
264
- state: pr_data[ "state" ].to_s,
265
- review_decision: blank_to( value: pr_data[ "reviewDecision" ], default: "NONE" )
266
- }
267
- puts_verbose "pr: ##{report.dig( :pr, :number )} #{report.dig( :pr, :title )}"
268
- puts_verbose "url: #{report.dig( :pr, :url )}"
269
- puts_verbose "review_decision: #{report.dig( :pr, :review_decision )}"
270
- checks_stdout, checks_stderr, checks_success, checks_exit = gh_run( "pr", "checks", report.dig( :pr, :number ).to_s, "--required", "--json", "name,state,bucket,workflow,link" )
271
- if checks_stdout.to_s.strip.empty?
272
- error_text = gh_error_text( stdout_text: checks_stdout, stderr_text: checks_stderr, fallback: "required checks unavailable" )
273
- report[ :checks ][ :status ] = "skipped"
274
- report[ :checks ][ :skip_reason ] = error_text
275
- report[ :status ] = "attention"
276
- puts_verbose "checks: SKIP (#{error_text})"
277
- return report
278
- end
279
- checks_data = JSON.parse( checks_stdout )
280
- pending = checks_data.select { |entry| entry[ "bucket" ].to_s == "pending" }
281
- failing = checks_data.select { |entry| check_entry_failing?( entry: entry ) }
282
- report[ :checks ][ :status ] = checks_success ? "ok" : ( checks_exit == 8 ? "pending" : "attention" )
283
- report[ :checks ][ :required_total ] = checks_data.count
284
- report[ :checks ][ :failing_count ] = failing.count
285
- report[ :checks ][ :pending_count ] = pending.count
286
- report[ :checks ][ :failing ] = normalise_check_entries( entries: failing )
287
- report[ :checks ][ :pending ] = normalise_check_entries( entries: pending )
288
- puts_verbose "required_checks_total: #{report.dig( :checks, :required_total )}"
289
- puts_verbose "required_checks_failing: #{report.dig( :checks, :failing_count )}"
290
- puts_verbose "required_checks_pending: #{report.dig( :checks, :pending_count )}"
291
- report.dig( :checks, :failing ).each { |entry| puts_verbose "check_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
292
- report.dig( :checks, :pending ).each { |entry| puts_verbose "check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
293
- report[ :status ] = "attention" if report.dig( :checks, :failing_count ).positive? || report.dig( :checks, :pending_count ).positive?
294
- report
295
- rescue JSON::ParserError => exception
296
- report[ :status ] = "skipped"
297
- report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
298
- puts_verbose "SKIP: #{report.fetch( :skip_reason )}"
299
- report
300
- end
301
-
302
- # Evaluates default-branch CI health so stale workflow drift blocks before merge.
303
- def default_branch_ci_baseline_report
304
- report = {
305
- status: "ok",
306
- skip_reason: nil,
307
- repository: nil,
308
- default_branch: nil,
309
- head_sha: nil,
310
- workflows_total: 0,
311
- check_runs_total: 0,
312
- failing_count: 0,
313
- pending_count: 0,
314
- advisory_failing_count: 0,
315
- advisory_pending_count: 0,
316
- no_check_evidence: false,
317
- failing: [],
318
- pending: [],
319
- advisory_failing: [],
320
- advisory_pending: []
321
- }
322
- unless gh_available?
323
- report[ :status ] = "skipped"
324
- report[ :skip_reason ] = "gh CLI not available in PATH"
325
- puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
326
- return report
327
- end
328
- owner, repo = repository_coordinates
329
- report[ :repository ] = "#{owner}/#{repo}"
330
- repository_data = gh_json_payload!(
331
- "api", "repos/#{owner}/#{repo}",
332
- "--method", "GET",
333
- fallback: "unable to read repository metadata for #{owner}/#{repo}"
334
- )
335
- default_branch = blank_to( value: repository_data[ "default_branch" ], default: config.main_branch )
336
- report[ :default_branch ] = default_branch
337
- branch_data = gh_json_payload!(
338
- "api", "repos/#{owner}/#{repo}/branches/#{CGI.escape( default_branch )}",
339
- "--method", "GET",
340
- fallback: "unable to read default branch #{default_branch}"
341
- )
342
- head_sha = branch_data.dig( "commit", "sha" ).to_s.strip
343
- raise "default branch #{default_branch} has no commit SHA" if head_sha.empty?
344
- report[ :head_sha ] = head_sha
345
- workflow_entries = default_branch_workflow_entries(
346
- owner: owner,
347
- repo: repo,
348
- default_branch: default_branch
349
- )
350
- report[ :workflows_total ] = workflow_entries.count
351
- check_runs_payload = gh_json_payload!(
352
- "api", "repos/#{owner}/#{repo}/commits/#{head_sha}/check-runs",
353
- "--method", "GET",
354
- fallback: "unable to read check-runs for #{default_branch}@#{head_sha}"
355
- )
356
- check_runs = Array( check_runs_payload[ "check_runs" ] )
357
- failing, pending = partition_default_branch_check_runs( check_runs: check_runs )
358
- advisory_names = config.audit_advisory_check_names
359
- critical_failing, advisory_failing = separate_advisory_check_entries( entries: failing, advisory_names: advisory_names )
360
- critical_pending, advisory_pending = separate_advisory_check_entries( entries: pending, advisory_names: advisory_names )
361
- report[ :check_runs_total ] = check_runs.count
362
- report[ :failing ] = normalise_default_branch_check_entries( entries: critical_failing )
363
- report[ :pending ] = normalise_default_branch_check_entries( entries: critical_pending )
364
- report[ :advisory_failing ] = normalise_default_branch_check_entries( entries: advisory_failing )
365
- report[ :advisory_pending ] = normalise_default_branch_check_entries( entries: advisory_pending )
366
- report[ :failing_count ] = report.fetch( :failing ).count
367
- report[ :pending_count ] = report.fetch( :pending ).count
368
- report[ :advisory_failing_count ] = report.fetch( :advisory_failing ).count
369
- report[ :advisory_pending_count ] = report.fetch( :advisory_pending ).count
370
- report[ :no_check_evidence ] = report.fetch( :workflows_total ).positive? && report.fetch( :check_runs_total ).zero?
371
- report[ :status ] = "block" if report.fetch( :failing_count ).positive?
372
- report[ :status ] = "block" if report.fetch( :pending_count ).positive?
373
- report[ :status ] = "block" if report.fetch( :no_check_evidence )
374
- report[ :status ] = "attention" if report.fetch( :status ) == "ok" && ( report.fetch( :advisory_failing_count ).positive? || report.fetch( :advisory_pending_count ).positive? )
375
- puts_verbose "default_branch_repository: #{report.fetch( :repository )}"
376
- puts_verbose "default_branch_name: #{report.fetch( :default_branch )}"
377
- puts_verbose "default_branch_head_sha: #{report.fetch( :head_sha )}"
378
- puts_verbose "default_branch_workflows_total: #{report.fetch( :workflows_total )}"
379
- puts_verbose "default_branch_check_runs_total: #{report.fetch( :check_runs_total )}"
380
- puts_verbose "default_branch_failing: #{report.fetch( :failing_count )}"
381
- puts_verbose "default_branch_pending: #{report.fetch( :pending_count )}"
382
- puts_verbose "default_branch_advisory_failing: #{report.fetch( :advisory_failing_count )}"
383
- puts_verbose "default_branch_advisory_pending: #{report.fetch( :advisory_pending_count )}"
384
- report.fetch( :failing ).each { |entry| puts_verbose "default_branch_check_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
385
- report.fetch( :pending ).each { |entry| puts_verbose "default_branch_check_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} #{entry.fetch( :link )}".strip }
386
- report.fetch( :advisory_failing ).each { |entry| puts_verbose "default_branch_check_advisory_fail: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (advisory) #{entry.fetch( :link )}".strip }
387
- report.fetch( :advisory_pending ).each { |entry| puts_verbose "default_branch_check_advisory_pending: #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (advisory) #{entry.fetch( :link )}".strip }
388
- if report.fetch( :no_check_evidence )
389
- puts_verbose "ACTION: default branch has workflow files but no check-runs; align workflow triggers and branch protection check names."
390
- end
391
- report
392
- rescue JSON::ParserError => exception
393
- report[ :status ] = "skipped"
394
- report[ :skip_reason ] = "invalid gh JSON response (#{exception.message})"
395
- puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
396
- report
397
- rescue StandardError => exception
398
- report[ :status ] = "skipped"
399
- report[ :skip_reason ] = exception.message
400
- puts_verbose "baseline: SKIP (#{report.fetch( :skip_reason )})"
401
- report
402
- end
403
-
404
- # Reads JSON API payloads and raises a detailed error when gh reports non-success.
405
- def gh_json_payload!( *args, fallback: )
406
- stdout_text, stderr_text, success, = gh_run( *args )
407
- unless success
408
- error_text = gh_error_text( stdout_text: stdout_text, stderr_text: stderr_text, fallback: fallback )
409
- raise error_text
410
- end
411
- JSON.parse( stdout_text )
412
- end
413
-
414
- # Reads workflow files from default branch; missing workflow directory is valid and returns none.
415
- def default_branch_workflow_entries( owner:, repo:, default_branch: )
416
- stdout_text, stderr_text, success, = gh_run(
417
- "api", "repos/#{owner}/#{repo}/contents/.github/workflows",
418
- "--method", "GET",
419
- "-f", "ref=#{default_branch}"
420
- )
421
- unless success
422
- error_text = gh_error_text(
423
- stdout_text: stdout_text,
424
- stderr_text: stderr_text,
425
- fallback: "unable to read workflow files for #{default_branch}"
426
- )
427
- return [] if error_text.match?( /\b404\b/ )
428
- raise error_text
429
- end
430
- payload = JSON.parse( stdout_text )
431
- Array( payload ).select do |entry|
432
- entry.is_a?( Hash ) &&
433
- entry[ "type" ].to_s == "file" &&
434
- entry[ "name" ].to_s.match?( /\.ya?ml\z/i )
435
- end
436
- end
437
-
438
- # Splits default-branch check-runs into failing and pending policy buckets.
439
- def partition_default_branch_check_runs( check_runs: )
440
- failing = []
441
- pending = []
442
- Array( check_runs ).each do |entry|
443
- if default_branch_check_run_failing?( entry: entry )
444
- failing << entry
445
- elsif default_branch_check_run_pending?( entry: entry )
446
- pending << entry
447
- end
448
- end
449
- [ failing, pending ]
450
- end
451
-
452
- # Separates check-run entries into critical and advisory buckets based on configured advisory names.
453
- def separate_advisory_check_entries( entries:, advisory_names: )
454
- advisory, critical = Array( entries ).partition do |entry|
455
- advisory_names.include?( entry[ "name" ].to_s.strip )
456
- end
457
- [ critical, advisory ]
458
- end
459
-
460
- # Returns true when a required-check entry is in a non-passing, non-pending state.
461
- # Cancelled, errored, timed-output, and any unknown bucket all count as failing.
462
- def check_entry_failing?( entry: )
463
- !%w[pass pending].include?( entry[ "bucket" ].to_s )
464
- end
465
-
466
- # Failing means completed with a non-successful conclusion.
467
- def default_branch_check_run_failing?( entry: )
468
- status = entry[ "status" ].to_s.strip.downcase
469
- conclusion = entry[ "conclusion" ].to_s.strip.downcase
470
- status == "completed" && !conclusion.empty? && !%w[success neutral skipped].include?( conclusion )
471
- end
472
-
473
- # Pending includes non-completed checks and completed checks missing conclusion.
474
- def default_branch_check_run_pending?( entry: )
475
- status = entry[ "status" ].to_s.strip.downcase
476
- conclusion = entry[ "conclusion" ].to_s.strip.downcase
477
- return true if status.empty?
478
- return true unless status == "completed"
479
-
480
- conclusion.empty?
481
- end
482
-
483
- # Normalises default-branch check-runs to report layout used by markdown output.
484
- def normalise_default_branch_check_entries( entries: )
485
- Array( entries ).map do |entry|
486
- state = if entry[ "status" ].to_s.strip.downcase == "completed"
487
- blank_to( value: entry[ "conclusion" ], default: "UNKNOWN" )
488
- else
489
- blank_to( value: entry[ "status" ], default: "UNKNOWN" )
490
- end
491
- {
492
- workflow: blank_to( value: entry.dig( "app", "name" ), default: "workflow" ),
493
- name: blank_to( value: entry[ "name" ], default: "check" ),
494
- state: state.upcase,
495
- link: entry[ "html_url" ].to_s
496
- }
497
- end
498
- end
499
-
500
- # Writes monitor report artefacts and prints their locations.
501
- def write_and_print_pr_monitor_report( report: )
502
- markdown_path, json_path = write_pr_monitor_report( report: report )
503
- puts_verbose "report_markdown: #{markdown_path}"
504
- puts_verbose "report_json: #{json_path}"
505
- rescue StandardError => exception
506
- puts_verbose "report_write: SKIP (#{exception.message})"
507
- end
508
-
509
- # Persists report in both machine-readable JSON and human-readable Markdown.
510
- def write_pr_monitor_report( report: )
511
- report_dir = report_dir_path
512
- FileUtils.mkdir_p( report_dir )
513
- markdown_path = File.join( report_dir, REPORT_MD )
514
- json_path = File.join( report_dir, REPORT_JSON )
515
- File.write( json_path, JSON.pretty_generate( report ) )
516
- File.write( markdown_path, render_pr_monitor_markdown( report: report ) )
517
- [ markdown_path, json_path ]
518
- end
519
-
520
- # Renders Markdown summary used by humans during merge-readiness reviews.
521
- def render_pr_monitor_markdown( report: )
522
- lines = []
523
- lines << "# Carson PR Monitor Report"
524
- lines << ""
525
- lines << "- Generated at: #{report.fetch( :generated_at )}"
526
- lines << "- Branch: #{report.fetch( :branch )}"
527
- lines << "- Audit status: #{report.fetch( :audit_status, 'unknown' )}"
528
- lines << "- Monitor status: #{report.fetch( :status )}"
529
- lines << "- Skip reason: #{report.fetch( :skip_reason )}" unless report.fetch( :skip_reason ).nil?
530
- lines << ""
531
- lines << "## PR"
532
- pr = report[ :pr ]
533
- if pr.nil?
534
- lines << "- not available"
535
- else
536
- lines << "- Number: ##{pr.fetch( :number )}"
537
- lines << "- Title: #{pr.fetch( :title )}"
538
- lines << "- URL: #{pr.fetch( :url )}"
539
- lines << "- State: #{pr.fetch( :state )}"
540
- lines << "- Review decision: #{pr.fetch( :review_decision )}"
541
- end
542
- lines << ""
543
- lines << "## Required Checks"
544
- checks = report.fetch( :checks )
545
- lines << "- Status: #{checks.fetch( :status )}"
546
- lines << "- Skip reason: #{checks.fetch( :skip_reason )}" unless checks.fetch( :skip_reason ).nil?
547
- lines << "- Total: #{checks.fetch( :required_total )}"
548
- lines << "- Failing: #{checks.fetch( :failing_count )}"
549
- lines << "- Pending: #{checks.fetch( :pending_count )}"
550
- lines << ""
551
- lines << "### Failing"
552
- if checks.fetch( :failing ).empty?
553
- lines << "- none"
554
- else
555
- checks.fetch( :failing ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
556
- end
557
- lines << ""
558
- lines << "### Pending"
559
- if checks.fetch( :pending ).empty?
560
- lines << "- none"
561
- else
562
- checks.fetch( :pending ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
563
- end
564
- lines << ""
565
- lines << "## Default Branch CI Baseline"
566
- baseline = report[ :default_branch_baseline ]
567
- if baseline.nil?
568
- lines << "- not available"
569
- else
570
- lines << "- Status: #{baseline.fetch( :status )}"
571
- lines << "- Skip reason: #{baseline.fetch( :skip_reason )}" unless baseline.fetch( :skip_reason ).nil?
572
- lines << "- Repository: #{baseline.fetch( :repository )}" unless baseline.fetch( :repository ).nil?
573
- lines << "- Branch: #{baseline.fetch( :default_branch )}" unless baseline.fetch( :default_branch ).nil?
574
- lines << "- Head SHA: #{baseline.fetch( :head_sha )}" unless baseline.fetch( :head_sha ).nil?
575
- lines << "- Workflow files: #{baseline.fetch( :workflows_total )}"
576
- lines << "- Check-runs: #{baseline.fetch( :check_runs_total )}"
577
- lines << "- Failing: #{baseline.fetch( :failing_count )}"
578
- lines << "- Pending: #{baseline.fetch( :pending_count )}"
579
- lines << "- No check evidence: #{baseline.fetch( :no_check_evidence )}"
580
- lines << ""
581
- lines << "### Baseline Failing"
582
- if baseline.fetch( :failing ).empty?
583
- lines << "- none"
584
- else
585
- baseline.fetch( :failing ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
586
- end
587
- lines << ""
588
- lines << "### Baseline Pending"
589
- if baseline.fetch( :pending ).empty?
590
- lines << "- none"
591
- else
592
- baseline.fetch( :pending ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
593
- end
594
- end
595
- lines << ""
596
- lines.join( "\n" )
597
- end
598
-
599
- end
600
-
601
- include Audit
602
- end
603
- end