carson 2.12.0 → 2.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87aef62f0df0f4a298b22d5fd2dcc033b3fb8ff382e7c43fcc37399fb9e28b17
4
- data.tar.gz: 50120a7c2252236e86736fbab59eeade47b80488c89b2901849b16dc77ed2c7f
3
+ metadata.gz: d7fde15079ed380bb8c6e4d476fb8157dd7980ea6a250e06706188a645c1d160
4
+ data.tar.gz: 85a76cdb10a52573ba463a884079178e2b69befe21b8b377bb425f620057bd65
5
5
  SHA512:
6
- metadata.gz: 790d1b70d2df69440acc5c7d13ed7566b243e3b69a7f6d5735bc6713c275702e72273cd13ff6c958e3635669ea2411af6289eefc69e6782d3182a7dd390e1f61
7
- data.tar.gz: e9367dd0605946d9a2f7202c0f41cb2ebebfe6e19b463e606bb88e4e1d719b3c5fbdfc76911bf8b7be1a225f5dbbdd4518b88d5f9f0a5a92ad9a841d11f1f92d
6
+ metadata.gz: 910cfd8d4ee3095b3ea559ba66bcd372d764043d02131f38d2d75071542aa04e1ea7cf5bdf10ab1aabd792f8ff21762ea31063720c23c719cc361d4313ea38fd
7
+ data.tar.gz: 236417f25de6aba2640a3ec992c5f756851b931f961f213408f57533533403f20d011cf4169cb8384ed980d58100456e49ea9209ed08fc2bb87a2d9729fcd9e4
@@ -71,7 +71,7 @@ jobs:
71
71
  env:
72
72
  CARSON_READ_TOKEN: ${{ secrets.CARSON_READ_TOKEN }}
73
73
  run: |
74
- carson lint setup --source https://github.com/wanghailei/ai.git --ref main --force
74
+ carson lint policy --source https://github.com/wanghailei/ai.git --ref main --force
75
75
  carson prepare
76
76
  carson audit
77
77
 
data/API.md CHANGED
@@ -98,8 +98,6 @@ Environment overrides:
98
98
  - `CARSON_REVIEW_SWEEP_STATES`
99
99
  - `CARSON_WORKFLOW_STYLE`
100
100
  - `CARSON_RUBY_INDENTATION`
101
- - `CARSON_LINT_COMMAND`
102
- - `CARSON_LINT_ENFORCEMENT`
103
101
  - `CARSON_LINT_POLICY_SOURCE`
104
102
 
105
103
  `lint` schema:
@@ -107,29 +105,17 @@ Environment overrides:
107
105
  ```json
108
106
  {
109
107
  "lint": {
110
- "command": "make lint",
111
- "enforcement": "strict",
112
108
  "policy_source": "wanghailei/lint.git"
113
109
  }
114
110
  }
115
111
  ```
116
112
 
117
113
  `lint` semantics:
118
- - `command`: string or array — local lint command executed during `carson audit`. If `null` (default), local lint is skipped.
119
- - `enforcement`: `"strict"` (default) blocks on lint failure; `"advisory"` warns but does not block.
120
114
  - `policy_source`: default source for `carson lint policy` when `--source` is not specified.
121
115
 
122
116
  Environment overrides:
123
- - `CARSON_LINT_COMMAND` — overrides `lint.command`.
124
- - `CARSON_LINT_ENFORCEMENT` — overrides `lint.enforcement`.
125
117
  - `CARSON_LINT_POLICY_SOURCE` — overrides `lint.policy_source`.
126
118
 
127
- Lint target file source precedence in `carson audit`:
128
- - staged files for local commit-time execution.
129
- - PR changed files in GitHub `pull_request` events.
130
- - full repository tracked files in GitHub non-PR events.
131
- - local working-tree changes as fallback.
132
-
133
119
  Private-source clone token for `carson lint policy`:
134
120
  - `CARSON_READ_TOKEN` (used when `--source` points to a private GitHub repository).
135
121
 
data/MANUAL.md CHANGED
@@ -27,7 +27,7 @@ carson version
27
27
 
28
28
  ### Step 1: Prepare your lint policy
29
29
 
30
- Carson distributes lint configuration files from a central policy source — a directory or git repository you control — into each governed repo's `.github/linters/` directory, where MegaLinter auto-discovers them.
30
+ Carson distributes lint configuration files from a central policy source — a directory or git repository you control — into each governed repo's `.github/linters/` directory, where MegaLinter auto-discovers them in CI.
31
31
 
32
32
  ```bash
33
33
  carson lint policy --source /path/to/your-policy-repo
@@ -35,6 +35,8 @@ carson lint policy --source /path/to/your-policy-repo
35
35
 
36
36
  After this command, `.github/linters/` contains your lint configs (`.rubocop.yml`, `biome.json`, `ruff.toml`, etc.). MegaLinter uses these in CI. Every governed repository gets the same rules — this is how Carson keeps lint consistent across languages.
37
37
 
38
+ Carson distributes policy; MegaLinter enforces it. Carson does not run linters itself — that responsibility belongs to MegaLinter in CI and to the developer's own local tooling.
39
+
38
40
  Options:
39
41
  - `--source <path-or-git-url>` — where to read policy files from (required).
40
42
  - `--ref <git-ref>` — branch or tag when `--source` is a git URL.
@@ -94,9 +96,20 @@ jobs:
94
96
 
95
97
  Notes:
96
98
  - When upgrading Carson, update both `carson_ref` and `carson_version` together.
97
- - `CARSON_READ_TOKEN` must have read access to your policy source repository so CI can run `carson lint setup`.
99
+ - `CARSON_READ_TOKEN` must have read access to your policy source repository so CI can run `carson lint policy`.
98
100
  - The reusable workflow installs a pinned RuboCop gem before `carson audit`; mirror the same pin in host governance workflows for deterministic checks.
99
101
 
102
+ ### MegaLinter in CI
103
+
104
+ Carson manages a MegaLinter workflow template (`carson-lint.yml`) that is applied to governed repositories via `carson template apply`. MegaLinter auto-discovers lint configs from `.github/linters/` — the same directory populated by `carson lint policy`. This means:
105
+
106
+ - **Policy source** (your central lint repo) defines the rules.
107
+ - **`carson lint policy`** distributes the rules into each repo.
108
+ - **MegaLinter** (in GitHub Actions) enforces the rules.
109
+ - **`carson govern`** reads CI check status (including MegaLinter results) and acts on it.
110
+
111
+ Carson's `audit` gates on CI check status reported by GitHub — it does not duplicate MegaLinter's work locally. If MegaLinter fails in CI, `carson govern` classifies the PR accordingly and can dispatch a coding agent to fix the issues.
112
+
100
113
  ## Daily Operations
101
114
 
102
115
  **Start of work:**
@@ -146,6 +159,12 @@ The loop is built-in and cross-platform — no cron, launchd, or Task Scheduler
146
159
 
147
160
  Each cycle runs independently: if one cycle fails (network error, GitHub API timeout), the error is logged and the next cycle proceeds normally. Press `Ctrl-C` to stop — Carson exits cleanly with a cycle count summary.
148
161
 
162
+ ### Govern and Coding Agents
163
+
164
+ `carson govern` dispatches coding agents (Codex or Claude) when a PR has failing CI checks. The agent receives the failure context and attempts to fix the issues in a follow-up commit. If the agent succeeds, the PR re-enters the governance pipeline. If it fails or times out, the PR is escalated for human attention.
165
+
166
+ The agent provider is configurable via `govern.agent.provider` (`auto`, `codex`, or `claude`). In `auto` mode, Carson selects the first available provider.
167
+
149
168
  ## Merge Method and Linear History
150
169
 
151
170
  Carson's `govern.merge.method` controls how `carson govern` merges ready PRs. The options are `squash`, `merge`, and `rebase` (default: `squash`). Set this in `~/.carson/config.json`:
@@ -181,7 +200,7 @@ Carson's `govern.merge.method` controls how `carson govern` merges ready PRs. Th
181
200
  These define what Carson *is*. They are not configurable.
182
201
 
183
202
  - **Outsider boundary** — Carson never places its own artefacts inside a governed repository.
184
- - **Centralised lint** — one policy source distributed into each repo's `.github/linters/`, zero per-repo drift.
203
+ - **Centralised lint** — one policy source distributed into each repo's `.github/linters/`, zero per-repo drift. MegaLinter enforces it in CI.
185
204
  - **Active review** — undisposed reviewer findings block merge; feedback must be acknowledged.
186
205
  - **Self-diagnosing output** — every message names the cause and the fix.
187
206
  - **Transparent governance** — Carson prepares everything for merge but never makes decisions without telling you.
@@ -241,7 +260,7 @@ Where lint configuration files come from and where they land.
241
260
  - Default source: **`wanghailei/lint.git`**. A central repository containing lint configs for all languages.
242
261
  - Target: **`<repo>/.github/linters/`**. MegaLinter auto-discovers configs here in CI.
243
262
 
244
- Change: `carson lint policy --source <path-or-git-url>` or `lint.policy_source` in config.
263
+ Change: `carson lint policy --source <path-or-git-url>` or `lint.policy_source` in config. After changing policy, run `carson refresh --all` to propagate to all governed repositories.
245
264
 
246
265
  #### Scope integrity
247
266
 
@@ -332,11 +351,9 @@ carson template apply
332
351
  carson template check
333
352
  ```
334
353
 
335
- **Audit blocks on lint failure**
336
- - If `lint.command` is configured and fails, audit blocks (in `strict` mode) or warns (in `advisory` mode). Fix the lint issues and re-run `carson audit`.
337
-
338
354
  **Hook version mismatch after upgrade**
339
355
  - Run `carson refresh` to re-apply hooks and templates for the new Carson version.
356
+ - Run `carson refresh --all` to refresh all governed repositories at once.
340
357
 
341
358
  ## Offboard a Repository
342
359
 
data/README.md CHANGED
@@ -12,12 +12,12 @@ If you govern more than a handful of repositories, you know the pattern: lint co
12
12
 
13
13
  Carson is an autonomous governance runtime that lives on your workstation and in CI, never inside the repositories it governs. It operates at two levels:
14
14
 
15
- **Per-commit governance** — Carson enforces lint policy, gates merges on unresolved review comments, synchronises templates, and keeps your local branches clean. Every commit triggers `carson audit` through managed hooks; the same checks run in GitHub Actions.
15
+ **Per-commit governance** — Carson gates merges on unresolved review comments, synchronises templates, and keeps your local branches clean. Every commit triggers `carson audit` through managed hooks; the same checks run in GitHub Actions. Lint execution is handled entirely by MegaLinter in CI and by the developer's own local tooling — Carson distributes the shared lint configuration but does not run linters itself.
16
16
 
17
17
  **Portfolio-level autonomy** — `carson govern` is a scheduled triage loop that scans all your repositories, classifies every open PR, and acts: merge what's ready, dispatch coding agents (Codex or Claude) to fix what's failing, and escalate what needs human judgement. One command, all your projects, unmanned.
18
18
 
19
19
  ```
20
- ┌──────────────────────────────────────────────�┐
20
+ ┌──────────────────────────────────────────────┐
21
21
  │ Your workstation │
22
22
  │ │
23
23
  │ ~/.carson/ Carson config │
@@ -39,12 +39,22 @@ Carson is an autonomous governance runtime that lives on your workstation and in
39
39
 
40
40
  This separation is Carson's defining trait — the **outsider boundary**: no Carson scripts, config files, or governance payloads are ever placed inside a governed repository.
41
41
 
42
+ ### The Governance Loop
43
+
44
+ Carson orchestrates a closed governance loop across three layers:
45
+
46
+ 1. **Policy distribution** — `carson lint policy` distributes shared lint configs from a central source into each repo's `.github/linters/`. This is the single source of truth for lint rules across all governed repositories.
47
+ 2. **CI enforcement** — MegaLinter runs in GitHub Actions (via the managed `carson-lint.yml` workflow) and auto-discovers configs from `.github/linters/`. Carson does not run linters — MegaLinter does. Carson's `audit` gates on CI check status reported by GitHub.
48
+ 3. **Autonomous triage** — `carson govern` reads CI status (including MegaLinter results), review disposition, and audit health for every open PR. Ready PRs are merged. Failing PRs get a coding agent (Codex or Claude) dispatched to fix them. Stuck PRs are escalated.
49
+
50
+ Carson's role is governance orchestration — distributing policy, gating on results, and dispatching action. The actual lint execution, CI runs, and code fixes are delegated to specialised tools: MegaLinter for linting, GitHub Actions for CI, and coding agents for remediation.
51
+
42
52
  ## Opinions
43
53
 
44
54
  Carson is opinionated about governance. These are non-negotiable principles, not configurable defaults:
45
55
 
46
56
  - **Outsider boundary** — Carson lives outside your repo, never inside. No Carson-owned artefacts in your repository. Offboarding leaves no trace.
47
- - **Centralised lint** — lint policy distributed from a central source into each repo's `.github/linters/`. One source of truth, zero drift.
57
+ - **Centralised lint** — lint policy distributed from a central source into each repo's `.github/linters/`. One source of truth, zero drift. MegaLinter enforces it in CI.
48
58
  - **Active review** — undisposed reviewer findings block merge. Feedback must be acknowledged, not buried.
49
59
  - **Self-diagnosing output** — every message names the cause and the fix. If you need to debug Carson's output, the output failed.
50
60
  - **Transparent governance** — Carson prepares everything for merge but never oversteps. It does not make decisions for you without telling you.
@@ -78,6 +88,7 @@ The data flow:
78
88
  | `carson onboard` | One-command baseline: hooks + templates + first audit. |
79
89
  | `carson prepare` | Install or refresh Carson-managed global hooks. |
80
90
  | `carson refresh` | Re-apply hooks, templates, and audit after upgrading Carson. |
91
+ | `carson refresh --all` | Refresh all governed repositories at once. |
81
92
  | `carson offboard` | Remove Carson from a repository. |
82
93
 
83
94
  **Daily** — regular development workflow:
data/RELEASE.md CHANGED
@@ -5,6 +5,28 @@ Release-note scope rule:
5
5
  - `RELEASE.md` records only version deltas, breaking changes, and migration actions.
6
6
  - Operational usage guides live in `MANUAL.md` and `API.md`.
7
7
 
8
+ ## 2.13.1 — Guided Governance Registration
9
+
10
+ ### What changed
11
+
12
+ - After `carson onboard`, Carson now prompts to register the repo for portfolio governance (`govern.repos`). Accept to include it in `carson refresh --all` and `carson govern`; decline to skip.
13
+ - Improved `refresh --all` guidance when no repos are configured — now directs users to `carson onboard`.
14
+
15
+ ## 2.13.0 — Refresh All + Strip Local Lint Execution
16
+
17
+ ### What changed
18
+
19
+ - **`carson refresh --all`** refreshes every governed repository in a single command. Iterates `govern.repos`, runs hooks + templates + audit on each, prints a per-repo summary line, and returns non-zero if any repo fails. Verbose mode streams full diagnostics per repo.
20
+ - **Removed `lint.command` and `lint.enforcement` config keys.** Local lint execution during `carson audit` has been removed. MegaLinter runs in CI and `carson govern` gates on CI check status — local lint execution was redundant. Carson now focuses on what makes it unique: **policy distribution** via `carson lint policy`. The `lint.policy_source` config key and `carson lint policy --source` command are unchanged.
21
+ - **Removed `CARSON_LINT_COMMAND` and `CARSON_LINT_ENFORCEMENT` environment overrides.**
22
+ - **Removed lint command and enforcement prompts from `carson setup`.**
23
+
24
+ ### Migration
25
+
26
+ 1. Remove `lint.command` and `lint.enforcement` from `~/.carson/config.json` if present — they are now ignored.
27
+ 2. Remove `CARSON_LINT_COMMAND` and `CARSON_LINT_ENFORCEMENT` from any CI or shell configuration.
28
+ 3. If you relied on local lint during audit, run your lint tool directly (e.g. `make lint`, `trunk check`) or let MegaLinter handle it in CI.
29
+
8
30
  ## 2.12.0 — Language-Agnostic Lint Policy Distribution + MegaLinter
9
31
 
10
32
  ### What changed
data/SKILL.md CHANGED
@@ -33,7 +33,6 @@ When you see exit 2, do NOT bypass it. Read the output, fix the root cause, and
33
33
  Carson audit output is structured as labelled key-value lines prefixed with ⧓. Key sections:
34
34
 
35
35
  - **Working Tree** — staged/unstaged status.
36
- - **Local Lint Quality** — lint command result. `lint_command_status: ok` means clean.
37
36
  - **Main Sync Status** — whether local main matches remote. If ahead, reset drift before committing.
38
37
  - **Scope Integrity Guard** — checks that commits stay within a single business intent and scope group.
39
38
  - **Audit Result** — final verdict: `status: ok` (clean), `status: attention` (advisory, not blocking), `status: block` (must fix).
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.12.0
1
+ 2.13.1
data/hooks/pre-commit CHANGED
@@ -14,6 +14,6 @@ fi
14
14
  if ! "${carson_command[@]}" audit; then
15
15
  echo "Carson policy: commit blocked by governance audit." >&2
16
16
  echo "Resolve reported policy blocks before committing." >&2
17
- echo "If lint policy files are missing, run: carson lint setup --source <path-or-git-url>" >&2
17
+ echo "If lint policy files are missing, run: carson lint policy --source <path-or-git-url>" >&2
18
18
  exit 1
19
19
  fi
data/lib/carson/cli.rb CHANGED
@@ -12,6 +12,12 @@ module Carson
12
12
  return Runtime::EXIT_OK
13
13
  end
14
14
 
15
+ if command == "refresh:all"
16
+ verbose = parsed.fetch( :verbose, false )
17
+ runtime = Runtime.new( repo_root: repo_root, tool_root: tool_root, out: out, err: err, verbose: verbose )
18
+ return dispatch( parsed: parsed, runtime: runtime )
19
+ end
20
+
15
21
  target_repo_root = parsed.fetch( :repo_root, nil )
16
22
  target_repo_root = repo_root if target_repo_root.to_s.strip.empty?
17
23
  unless Dir.exist?( target_repo_root )
@@ -47,7 +53,7 @@ module Carson
47
53
 
48
54
  def self.build_parser
49
55
  OptionParser.new do |opts|
50
- opts.banner = "Usage: carson [setup|audit|sync|prune|prepare|inspect|onboard [repo_path]|refresh [repo_path]|offboard [repo_path]|template check|template apply|lint policy --source <path-or-git-url>|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|housekeep|version]"
56
+ opts.banner = "Usage: carson [setup|audit|sync|prune|prepare|inspect|onboard [repo_path]|refresh [--all|repo_path]|offboard [repo_path]|template check|template apply|lint policy --source <path-or-git-url>|review gate|review sweep|govern [--dry-run] [--json] [--loop SECONDS]|housekeep|version]"
51
57
  end
52
58
  end
53
59
 
@@ -68,8 +74,10 @@ module Carson
68
74
  when "version"
69
75
  parser.parse!( argv )
70
76
  { command: "version" }
71
- when "onboard", "refresh", "offboard"
77
+ when "onboard", "offboard"
72
78
  parse_repo_path_command( command: command, argv: argv, parser: parser, err: err )
79
+ when "refresh"
80
+ parse_refresh_command( argv: argv, parser: parser, err: err )
73
81
  when "template"
74
82
  parse_named_subcommand( command: command, usage: "check|apply", argv: argv, parser: parser, err: err )
75
83
  when "lint"
@@ -99,6 +107,31 @@ module Carson
99
107
  }
100
108
  end
101
109
 
110
+ def self.parse_refresh_command( argv:, parser:, err: )
111
+ all_flag = argv.delete( "--all" ) ? true : false
112
+ parser.parse!( argv )
113
+
114
+ if all_flag && !argv.empty?
115
+ err.puts "#{BADGE} --all and repo_path are mutually exclusive. Use: carson refresh --all OR carson refresh [repo_path]"
116
+ err.puts parser
117
+ return { command: :invalid }
118
+ end
119
+
120
+ return { command: "refresh:all" } if all_flag
121
+
122
+ if argv.length > 1
123
+ err.puts "#{BADGE} Too many arguments for refresh. Use: carson refresh [repo_path]"
124
+ err.puts parser
125
+ return { command: :invalid }
126
+ end
127
+
128
+ repo_path = argv.first
129
+ {
130
+ command: "refresh",
131
+ repo_root: repo_path.to_s.strip.empty? ? nil : File.expand_path( repo_path )
132
+ }
133
+ end
134
+
102
135
  def self.parse_named_subcommand( command:, usage:, argv:, parser:, err: )
103
136
  action = argv.shift
104
137
  parser.parse!( argv )
@@ -206,6 +239,8 @@ module Carson
206
239
  runtime.onboard!
207
240
  when "refresh"
208
241
  runtime.refresh!
242
+ when "refresh:all"
243
+ runtime.refresh_all!
209
244
  when "offboard"
210
245
  runtime.offboard!
211
246
  when "template:check"
data/lib/carson/config.rb CHANGED
@@ -9,7 +9,7 @@ module Carson
9
9
  attr_accessor :git_remote
10
10
  attr_reader :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
11
11
  :path_groups, :template_managed_files,
12
- :lint_command, :lint_enforcement, :lint_policy_source,
12
+ :lint_policy_source,
13
13
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
14
14
  :review_sweep_states, :review_disposition_prefix, :review_risk_keywords,
15
15
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
@@ -51,8 +51,6 @@ module Carson
51
51
  "managed_files" => [ ".github/carson-instructions.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md", ".github/workflows/carson-lint.yml" ]
52
52
  },
53
53
  "lint" => {
54
- "command" => nil,
55
- "enforcement" => "strict",
56
54
  "policy_source" => "wanghailei/lint.git"
57
55
  },
58
56
  "workflow" => {
@@ -168,10 +166,6 @@ module Carson
168
166
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
169
167
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
170
168
  lint = fetch_hash_section( data: copy, key: "lint" )
171
- lint_command_env = ENV.fetch( "CARSON_LINT_COMMAND", "" ).to_s.strip
172
- lint[ "command" ] = lint_command_env unless lint_command_env.empty?
173
- lint_enforcement_env = ENV.fetch( "CARSON_LINT_ENFORCEMENT", "" ).to_s.strip
174
- lint[ "enforcement" ] = lint_enforcement_env unless lint_enforcement_env.empty?
175
169
  lint_policy_source_env = ENV.fetch( "CARSON_LINT_POLICY_SOURCE", "" ).to_s.strip
176
170
  lint[ "policy_source" ] = lint_policy_source_env unless lint_policy_source_env.empty?
177
171
  style = fetch_hash_section( data: copy, key: "style" )
@@ -223,8 +217,6 @@ module Carson
223
217
 
224
218
  @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
225
219
  lint_hash = fetch_hash( hash: data, key: "lint" )
226
- @lint_command = normalize_lint_command_setting( value: lint_hash[ "command" ] )
227
- @lint_enforcement = normalize_lint_enforcement( value: lint_hash.fetch( "enforcement", "strict" ) )
228
220
  @lint_policy_source = lint_hash.fetch( "policy_source", "" ).to_s.strip
229
221
 
230
222
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
@@ -338,23 +330,6 @@ module Carson
338
330
  patterns
339
331
  end
340
332
 
341
- def normalize_lint_command_setting( value: )
342
- return nil if value.nil?
343
- return value.to_s.strip if value.is_a?( String )
344
- if value.is_a?( Array )
345
- parts = value.map { |entry| entry.to_s.strip }.reject( &:empty? )
346
- raise ConfigError, "lint.command array must contain at least one argument" if parts.empty?
347
- return parts
348
- end
349
- raise ConfigError, "lint.command must be a string, array, or null"
350
- end
351
-
352
- def normalize_lint_enforcement( value: )
353
- text = value.to_s.strip.downcase
354
- raise ConfigError, "lint.enforcement must be one of strict, advisory" unless [ "strict", "advisory" ].include?( text )
355
- text
356
- end
357
-
358
333
  def fetch_optional_boolean( hash:, key:, default:, key_path: nil )
359
334
  value = hash.fetch( key, default )
360
335
  return true if value == true
@@ -1,5 +1,4 @@
1
1
  require "cgi"
2
- require "open3"
3
2
 
4
3
  module Carson
5
4
  class Runtime
@@ -24,17 +23,6 @@ module Carson
24
23
  audit_concise_problems << "Hooks: mismatch — run carson prepare."
25
24
  end
26
25
  puts_verbose ""
27
- puts_verbose "[Local Lint Quality]"
28
- local_lint_quality = local_lint_quality_report
29
- if local_lint_quality.fetch( :status ) == "block"
30
- audit_state = "block"
31
- blocking_langs = local_lint_quality.fetch( :languages ).select { |l| l.fetch( :status ) == "block" }
32
- blocking_langs.each do |lang|
33
- exit_code = lang.fetch( :exit_code, 1 )
34
- audit_concise_problems << "Lint: #{lang.fetch( :language )} failed (exit #{exit_code})."
35
- end
36
- end
37
- puts_verbose ""
38
26
  puts_verbose "[Main Sync Status]"
39
27
  ahead_count, behind_count, main_error = main_sync_counts
40
28
  if main_error
@@ -71,7 +59,6 @@ module Carson
71
59
  audit_state = "attention" if audit_state == "ok" && scope_guard.fetch( :status ) == "attention"
72
60
  write_and_print_pr_monitor_report(
73
61
  report: monitor_report.merge(
74
- local_lint_quality: local_lint_quality,
75
62
  default_branch_baseline: default_branch_baseline,
76
63
  audit_status: audit_state
77
64
  )
@@ -162,217 +149,6 @@ module Carson
162
149
  report
163
150
  end
164
151
 
165
- # Enforces configured lint policy before governance passes.
166
- # Runs lint.command and gates on exit code. Skips when lint.command is not set.
167
- def local_lint_quality_report
168
- unless config.lint_command
169
- report = {
170
- status: "ok",
171
- skip_reason: "lint.command not configured",
172
- target_source: "none",
173
- target_files_count: 0,
174
- blocking_languages: 0,
175
- languages: []
176
- }
177
- puts_verbose "lint: SKIP (lint.command not configured)"
178
- return report
179
- end
180
-
181
- lint_command_report
182
- rescue StandardError => e
183
- report = {
184
- status: "block",
185
- skip_reason: e.message,
186
- target_source: "unknown",
187
- target_files_count: 0,
188
- blocking_languages: 0,
189
- languages: []
190
- }
191
- puts_line "BLOCK: local lint quality check failed (#{e.message})."
192
- report
193
- end
194
-
195
- # Runs the lint.command and returns a structured report.
196
- def lint_command_report
197
- target_files, target_source = lint_target_files
198
- advisory = config.lint_enforcement == "advisory"
199
- command_value = config.lint_command
200
- command_string = command_value.is_a?( Array ) ? command_value.join( " " ) : command_value.to_s
201
-
202
- report = {
203
- status: "ok",
204
- skip_reason: nil,
205
- target_source: target_source,
206
- target_files_count: target_files.count,
207
- blocking_languages: 0,
208
- languages: []
209
- }
210
- puts_verbose "lint_target_source: #{target_source}"
211
- puts_verbose "lint_target_files_total: #{target_files.count}"
212
- puts_verbose "lint_command: #{command_string}"
213
- puts_verbose "lint_enforcement: #{config.lint_enforcement}"
214
-
215
- args = command_string.split( /\s+/ )
216
- command_name = args.first.to_s.strip
217
- unless command_available_for_lint?( command_name: command_name )
218
- language_report = {
219
- language: "lint.command",
220
- enabled: true,
221
- status: "block",
222
- reason: "command not available: #{command_name}",
223
- file_count: target_files.count,
224
- files: target_files,
225
- command: args,
226
- config_files: [],
227
- exit_code: EXIT_BLOCK
228
- }
229
- report[ :languages ] << language_report
230
- report[ :status ] = advisory ? "ok" : "block"
231
- report[ :blocking_languages ] = advisory ? 0 : 1
232
- puts_verbose "lint_command_status: #{language_report.fetch( :status )}"
233
- puts_line "WARN: lint command not available: #{command_name}" if advisory
234
- return report
235
- end
236
-
237
- stdout_text, stderr_text, success, exit_code = local_command( *args )
238
- language_report = {
239
- language: "lint.command",
240
- enabled: true,
241
- status: success ? "ok" : "block",
242
- reason: success ? nil : summarise_command_output(
243
- stdout_text: stdout_text,
244
- stderr_text: stderr_text,
245
- fallback: "lint command failed"
246
- ),
247
- file_count: target_files.count,
248
- files: target_files,
249
- command: args,
250
- config_files: [],
251
- exit_code: exit_code
252
- }
253
- report[ :languages ] << language_report
254
-
255
- unless success
256
- if advisory
257
- report[ :status ] = "ok"
258
- puts_verbose "lint_command_status: advisory_warn (exit #{exit_code})"
259
- puts_line "WARN: lint command failed (exit #{exit_code}) — advisory mode, not blocking."
260
- else
261
- report[ :status ] = "block"
262
- report[ :blocking_languages ] = 1
263
- puts_verbose "lint_command_status: block (exit #{exit_code})"
264
- end
265
- else
266
- puts_verbose "lint_command_status: ok"
267
- end
268
-
269
- report
270
- end
271
-
272
- # File selection precedence:
273
- # 1) staged files for local commit-time execution
274
- # 2) PR changed files in GitHub pull_request events
275
- # 3) full repository tracked files in GitHub non-PR events
276
- # 4) local working-tree changed files as fallback
277
- def lint_target_files
278
- staged = existing_repo_files( paths: staged_files )
279
- return [ staged, "staged" ] unless staged.empty?
280
-
281
- if github_pull_request_event?
282
- files = lint_target_files_for_pull_request
283
- return [ files, "github_pull_request" ] unless files.nil?
284
- puts_verbose "WARN: unable to resolve pull request changed files; falling back to full repository files."
285
- end
286
-
287
- if github_actions_environment?
288
- return [ lint_target_files_for_non_pr_ci, "github_full_repository" ]
289
- end
290
-
291
- [ existing_repo_files( paths: changed_files ), "working_tree" ]
292
- end
293
-
294
- def lint_target_files_for_pull_request
295
- base_ref = ENV.fetch( "GITHUB_BASE_REF", "" ).to_s.strip
296
- return nil if base_ref.empty?
297
-
298
- remote_name = config.git_remote
299
- unless git_remote_exists?( remote_name: remote_name )
300
- remote_name = "origin" if git_remote_exists?( remote_name: "origin" )
301
- end
302
-
303
- _, _, fetch_success, = git_run( "fetch", "--no-tags", "--depth", "1", remote_name, base_ref )
304
- return nil unless fetch_success
305
-
306
- base = "#{remote_name}/#{base_ref}"
307
- stdout_text, _, success, = git_run(
308
- "diff", "--name-only", "--diff-filter=ACMRTUXB", "#{base}...HEAD"
309
- )
310
- return nil unless success
311
-
312
- paths = stdout_text.lines.map { |line| line.to_s.strip }.reject( &:empty? )
313
- existing_repo_files( paths: paths )
314
- end
315
-
316
- def lint_target_files_for_non_pr_ci
317
- stdout_text = git_capture!( "ls-files" )
318
- paths = stdout_text.lines.map { |line| line.to_s.strip }.reject( &:empty? )
319
- existing_repo_files( paths: paths )
320
- end
321
-
322
- def github_actions_environment?
323
- ENV.fetch( "GITHUB_ACTIONS", "" ).to_s.strip.casecmp( "true" ).zero?
324
- end
325
-
326
- def github_pull_request_event?
327
- return false unless github_actions_environment?
328
-
329
- event_name = ENV.fetch( "GITHUB_EVENT_NAME", "" ).to_s.strip
330
- [ "pull_request", "pull_request_target" ].include?( event_name )
331
- end
332
-
333
- def existing_repo_files( paths: )
334
- Array( paths ).map do |relative|
335
- next if relative.to_s.strip.empty?
336
- absolute = resolve_repo_path!( relative_path: relative, label: "lint target file #{relative}" )
337
- next unless File.file?( absolute )
338
- relative
339
- end.compact.uniq
340
- end
341
-
342
- def command_available_for_lint?( command_name: )
343
- return false if command_name.to_s.strip.empty?
344
-
345
- if command_name.include?( "/" )
346
- path = if command_name.start_with?( "~" )
347
- File.expand_path( command_name )
348
- elsif command_name.start_with?( "/" )
349
- command_name
350
- else
351
- File.expand_path( command_name, repo_root )
352
- end
353
- return File.executable?( path )
354
- end
355
- path_entries = ENV.fetch( "PATH", "" ).split( File::PATH_SEPARATOR )
356
- path_entries.any? do |entry|
357
- next false if entry.to_s.strip.empty?
358
- File.executable?( File.join( entry, command_name ) )
359
- end
360
- end
361
-
362
- # Local command runner for repository-context tools used by audit lint checks.
363
- def local_command( *args )
364
- stdout_text, stderr_text, status = Open3.capture3( *args, chdir: repo_root )
365
- [ stdout_text, stderr_text, status.success?, status.exitstatus ]
366
- end
367
-
368
- # Compacts command output to one-line diagnostics for audit logs and JSON report payloads.
369
- def summarise_command_output( stdout_text:, stderr_text:, fallback: )
370
- combined = [ stderr_text.to_s, stdout_text.to_s ].join( "\n" )
371
- lines = combined.lines.map { |line| line.to_s.strip }.reject( &:empty? )
372
- return fallback if lines.empty?
373
- lines.first( 12 ).join( " | " )
374
- end
375
-
376
152
  # Evaluates default-branch CI health so stale workflow drift blocks before merge.
377
153
  def default_branch_ci_baseline_report
378
154
  report = {
@@ -630,28 +406,6 @@ module Carson
630
406
  checks.fetch( :pending ).each { |entry| lines << "- #{entry.fetch( :workflow )} / #{entry.fetch( :name )} (#{entry.fetch( :state )}) #{entry.fetch( :link )}".strip }
631
407
  end
632
408
  lines << ""
633
- lines << "## Local Lint Quality"
634
- lint_quality = report[ :local_lint_quality ]
635
- if lint_quality.nil?
636
- lines << "- not available"
637
- else
638
- lines << "- Status: #{lint_quality.fetch( :status )}"
639
- lines << "- Skip reason: #{lint_quality.fetch( :skip_reason )}" unless lint_quality.fetch( :skip_reason ).nil?
640
- lines << "- Target source: #{lint_quality.fetch( :target_source )}"
641
- lines << "- Target files: #{lint_quality.fetch( :target_files_count )}"
642
- lines << "- Blocking languages: #{lint_quality.fetch( :blocking_languages )}"
643
- lines << ""
644
- lines << "### Language Results"
645
- if lint_quality.fetch( :languages ).empty?
646
- lines << "- none"
647
- else
648
- lint_quality.fetch( :languages ).each do |entry|
649
- lines << "- #{entry.fetch( :language )}: status=#{entry.fetch( :status )} files=#{entry.fetch( :file_count )} exit=#{entry.fetch( :exit_code )}"
650
- lines << " reason: #{entry.fetch( :reason )}" unless entry.fetch( :reason ).nil?
651
- end
652
- end
653
- end
654
- lines << ""
655
409
  lines << "## Default Branch CI Baseline"
656
410
  baseline = report[ :default_branch_baseline ]
657
411
  if baseline.nil?
@@ -247,6 +247,41 @@ module Carson
247
247
  audit_status
248
248
  end
249
249
 
250
+ # Re-applies hooks, templates, and audit across all governed repositories.
251
+ def refresh_all!
252
+ repos = config.govern_repos
253
+ if repos.empty?
254
+ puts_line "No governed repositories configured."
255
+ puts_line " Run carson onboard in each repo to register."
256
+ return EXIT_ERROR
257
+ end
258
+
259
+ puts_line ""
260
+ puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
261
+ refreshed = 0
262
+ failed = 0
263
+
264
+ repos.each do |repo_path|
265
+ repo_name = File.basename( repo_path )
266
+ unless Dir.exist?( repo_path )
267
+ puts_line "#{repo_name}: FAIL (path not found)"
268
+ failed += 1
269
+ next
270
+ end
271
+
272
+ status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
273
+ if status == EXIT_ERROR
274
+ failed += 1
275
+ else
276
+ refreshed += 1
277
+ end
278
+ end
279
+
280
+ puts_line ""
281
+ puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
282
+ failed.zero? ? EXIT_OK : EXIT_ERROR
283
+ end
284
+
250
285
  # Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
251
286
  def offboard!
252
287
  puts_verbose ""
@@ -365,6 +400,30 @@ module Carson
365
400
 
366
401
  private
367
402
 
403
+ # Refreshes a single governed repository using a scoped Runtime.
404
+ def refresh_single_repo( repo_path:, repo_name: )
405
+ if verbose?
406
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
407
+ else
408
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
409
+ end
410
+ status = rt.refresh!
411
+ label = refresh_status_label( status: status )
412
+ puts_line "#{repo_name}: #{label}"
413
+ status
414
+ rescue StandardError => e
415
+ puts_line "#{repo_name}: FAIL (#{e.message})"
416
+ EXIT_ERROR
417
+ end
418
+
419
+ def refresh_status_label( status: )
420
+ case status
421
+ when EXIT_OK then "OK"
422
+ when EXIT_BLOCK then "BLOCK"
423
+ else "FAIL"
424
+ end
425
+ end
426
+
368
427
  def template_results
369
428
  config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
370
429
  end
@@ -755,6 +814,9 @@ module Carson
755
814
  puts_line ""
756
815
  puts_line "Carson is ready. Workflow: #{config.workflow_style}"
757
816
  puts_line "Reconfigure anytime: carson setup"
817
+
818
+ prompt_govern_registration! if self.in.respond_to?( :tty? ) && self.in.tty?
819
+
758
820
  audit_status
759
821
  end
760
822
 
@@ -39,11 +39,6 @@ module Carson
39
39
  merge_choice = prompt_merge_method
40
40
  choices[ "govern.merge.method" ] = merge_choice unless merge_choice.nil?
41
41
 
42
- lint_command_choice = prompt_lint_command
43
- choices[ "lint.command" ] = lint_command_choice unless lint_command_choice.nil?
44
-
45
- lint_enforcement_choice = prompt_lint_enforcement
46
- choices[ "lint.enforcement" ] = lint_enforcement_choice unless lint_enforcement_choice.nil?
47
42
 
48
43
  write_setup_config( choices: choices )
49
44
  end
@@ -148,34 +143,6 @@ module Carson
148
143
  prompt_choice( options: options, default: 0 )
149
144
  end
150
145
 
151
- def prompt_lint_command
152
- puts_line ""
153
- puts_line "Lint command"
154
- options = [
155
- { label: "make lint", value: "make lint" },
156
- { label: "trunk check (Recommended)", value: "trunk check" },
157
- { label: "Other (enter command)", value: :other },
158
- { label: "Skip (no local lint)", value: nil }
159
- ]
160
- choice = prompt_choice( options: options, default: 1 )
161
-
162
- if choice == :other
163
- prompt_custom_value( label: "Lint command" )
164
- else
165
- choice
166
- end
167
- end
168
-
169
- def prompt_lint_enforcement
170
- puts_line ""
171
- puts_line "Lint enforcement"
172
- options = [
173
- { label: "strict — block on lint failure (default)", value: "strict" },
174
- { label: "advisory — warn but don't block", value: "advisory" }
175
- ]
176
- prompt_choice( options: options, default: 0 )
177
- end
178
-
179
146
  def prompt_choice( options:, default: )
180
147
  options.each_with_index do |option, index|
181
148
  puts_line " #{index + 1}) #{option.fetch( :label )}"
@@ -374,6 +341,56 @@ module Carson
374
341
  path = Config.global_config_path( repo_root: repo_root )
375
342
  !path.empty? && File.file?( path )
376
343
  end
344
+
345
+ # After onboard succeeds, offer to register the repo for portfolio governance.
346
+ def prompt_govern_registration!
347
+ expanded = File.expand_path( repo_root )
348
+ if config.govern_repos.include?( expanded )
349
+ puts_verbose "govern_registration: already registered #{expanded}"
350
+ return
351
+ end
352
+
353
+ puts_line ""
354
+ puts_line "Portfolio governance"
355
+ puts_line " Register this repo so carson refresh --all and carson govern include it?"
356
+ accepted = prompt_yes_no( default: true )
357
+ if accepted
358
+ append_govern_repo!( repo_path: expanded )
359
+ puts_line "Registered."
360
+ else
361
+ puts_line "Skipped. Re-run carson onboard to register later."
362
+ end
363
+ end
364
+
365
+ # Reusable Y/n prompt following existing prompt_choice conventions.
366
+ def prompt_yes_no( default: true )
367
+ hint = default ? "Y/n" : "y/N"
368
+ out.print "#{BADGE} [#{hint}]: "
369
+ out.flush
370
+ raw = self.in.gets
371
+ return default if raw.nil?
372
+
373
+ input = raw.to_s.strip.downcase
374
+ return default if input.empty?
375
+
376
+ input.start_with?( "y" )
377
+ end
378
+
379
+ # Appends a repo path to govern.repos without replacing the array via deep_merge.
380
+ def append_govern_repo!( repo_path: )
381
+ config_path = Config.global_config_path( repo_root: repo_root )
382
+ return if config_path.empty?
383
+
384
+ existing_data = load_existing_config( path: config_path )
385
+ existing_data[ "govern" ] ||= {}
386
+ repos = Array( existing_data[ "govern" ][ "repos" ] )
387
+ repos << repo_path
388
+ existing_data[ "govern" ][ "repos" ] = repos.uniq
389
+
390
+ FileUtils.mkdir_p( File.dirname( config_path ) )
391
+ File.write( config_path, JSON.pretty_generate( existing_data ) )
392
+ reload_config_after_setup!
393
+ end
377
394
  end
378
395
 
379
396
  include Setup
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: 2.12.0
4
+ version: 2.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang