carson 2.11.3 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f2b2563b55862fdda893bf17d8686d2bab9d280c060b24b82ef956949be57cd
4
- data.tar.gz: eff8ccc66edcbed4b1cde495ad71f7430b727cb068a79d172d0ccd2a316b0a61
3
+ metadata.gz: fbc67303ff1efe52235731586e0fb13830d943764edfb67119ff3b8cd5605102
4
+ data.tar.gz: 6facbe0d10eebe0ef1f89d491799fa65d2993221574b1d3e7e898d82e3c0fc32
5
5
  SHA512:
6
- metadata.gz: 366ae543bf2639e601ffc1556cd8e3681e898ffc73550abc63f3101ccb646a6f9ef1d3faa728d0efa5df98fc2f7bb2cf1362a1a26f5f7c3ee7865826ae103697
7
- data.tar.gz: 656f7d30b869eeeae1273473698f6f6f450dc898cf0599a13c235dbd97bff266ada1251a768f38f1b0f90f3a1a2e20bdfeff3d85f9b57a7f673583a050b4f53f
6
+ metadata.gz: c606ed005e274ab872ccf9b50da945edcc8bffcf2a7e146679e0f6f9673c572d826a679e9575d9866a4c91ffe71ca648a89b415237cb63e97f7d023ffa05a453
7
+ data.tar.gz: fc3e7c8ba85a437aafc643f498c381bb8f617ee71c5527b08462af993f1b94cbcf51dbfc9298dd45b3dee92dbfad45c611513e449114051878e4c8239e4b7c23
@@ -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
@@ -16,7 +16,7 @@ carson <command> [subcommand] [arguments]
16
16
  | Command | Purpose |
17
17
  |---|---|
18
18
  | `carson setup` | Interactive quiz to configure remote, main branch, workflow, and merge method. Writes `~/.carson/config.json`. |
19
- | `carson lint setup --source <path-or-git-url> [--ref <git-ref>] [--force]` | Seed or refresh `~/.carson/lint` policy files from an explicit source. |
19
+ | `carson lint policy --source <path-or-git-url> [--ref <git-ref>] [--force]` | Distribute lint configs from a central source into the governed repo's `.github/linters/`. |
20
20
  | `carson onboard [repo_path]` | Apply one-command baseline setup for a target git repository. Auto-triggers `setup` on first run. |
21
21
  | `carson prepare` | Install or refresh Carson-managed global hooks. |
22
22
  | `carson refresh [repo_path]` | Re-apply hooks, templates, and audit after upgrading Carson. |
@@ -78,6 +78,7 @@ Allowed Carson-managed persistence in host repositories:
78
78
  - `.github/CLAUDE.md` — agent discovery pointer for Claude Code
79
79
  - `.github/AGENTS.md` — agent discovery pointer for Codex
80
80
  - `.github/pull_request_template.md` — PR template
81
+ - `.github/workflows/carson-lint.yml` — MegaLinter CI workflow
81
82
 
82
83
  ## Configuration interface
83
84
 
@@ -97,33 +98,31 @@ Environment overrides:
97
98
  - `CARSON_REVIEW_SWEEP_STATES`
98
99
  - `CARSON_WORKFLOW_STYLE`
99
100
  - `CARSON_RUBY_INDENTATION`
101
+ - `CARSON_LINT_COMMAND`
102
+ - `CARSON_LINT_ENFORCEMENT`
103
+ - `CARSON_LINT_POLICY_SOURCE`
100
104
 
101
- `lint.languages` schema:
105
+ `lint` schema:
102
106
 
103
107
  ```json
104
108
  {
105
109
  "lint": {
106
- "languages": {
107
- "ruby": {
108
- "enabled": true,
109
- "globs": ["**/*.rb"],
110
- "command": ["ruby", "/absolute/path/to/carson/lib/carson/policy/ruby/lint.rb", "{files}"],
111
- "config_files": ["~/.carson/lint/rubocop.yml"]
112
- }
113
- }
110
+ "command": "make lint",
111
+ "enforcement": "strict",
112
+ "policy_source": "wanghailei/lint.git"
114
113
  }
115
114
  }
116
115
  ```
117
116
 
118
- `lint.languages` semantics:
119
- - `enabled`: boolean toggle per language.
120
- - `globs`: file-match patterns applied to the selected audit target set.
121
- - `command`: argv array executed without shell interpolation.
122
- - `config_files`: required files that must exist before lint runs.
123
- - `{files}` token: replaced with matched files; if omitted, matched files are appended at the end of argv.
124
- - Default Ruby policy source is `~/.carson/lint/rubocop.yml`; Ruby execution logic is Carson-owned.
125
- - Client repositories containing repo-local `.rubocop.yml` are hard-blocked by `carson audit` in outsider mode.
126
- - Non-Ruby language entries (`javascript`, `css`, `html`, `erb`) are present but disabled by default.
117
+ `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
+ - `policy_source`: default source for `carson lint policy` when `--source` is not specified.
121
+
122
+ Environment overrides:
123
+ - `CARSON_LINT_COMMAND` overrides `lint.command`.
124
+ - `CARSON_LINT_ENFORCEMENT` overrides `lint.enforcement`.
125
+ - `CARSON_LINT_POLICY_SOURCE` overrides `lint.policy_source`.
127
126
 
128
127
  Lint target file source precedence in `carson audit`:
129
128
  - staged files for local commit-time execution.
@@ -131,14 +130,12 @@ Lint target file source precedence in `carson audit`:
131
130
  - full repository tracked files in GitHub non-PR events.
132
131
  - local working-tree changes as fallback.
133
132
 
134
- Private-source clone token for `carson lint setup`:
133
+ Private-source clone token for `carson lint policy`:
135
134
  - `CARSON_READ_TOKEN` (used when `--source` points to a private GitHub repository).
136
135
 
137
- Ruby source requirement for `carson lint setup` (when Ruby lint is enabled):
138
- - `CODING/rubocop.yml` must exist in the source tree.
139
-
140
136
  Policy layout requirement:
141
- - Language policy files are stored directly under `CODING/` and copied to `~/.carson/lint/` without language subdirectories.
137
+ - Lint config files sit at the root of the source repo and are copied to `<governed-repo>/.github/linters/`.
138
+ - MegaLinter auto-discovers configs in `.github/linters/` during CI.
142
139
 
143
140
  ## Output interface
144
141
 
data/MANUAL.md CHANGED
@@ -27,22 +27,20 @@ carson version
27
27
 
28
28
  ### Step 1: Prepare your lint policy
29
29
 
30
- Carson enforces lint rules from a central policy source — a directory or git repository you control that contains a `CODING/` folder. For Ruby governance, the required file is `CODING/rubocop.yml`.
31
-
32
- Run `lint setup` to copy policy files into `~/.carson/lint/`:
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.
33
31
 
34
32
  ```bash
35
- carson lint setup --source /path/to/your-policy-repo
33
+ carson lint policy --source /path/to/your-policy-repo
36
34
  ```
37
35
 
38
- After this command, `~/.carson/lint/rubocop.yml` exists and is ready for Carson to use. Every governed repository will reference these same policy files — this is how Carson keeps lint consistent.
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.
39
37
 
40
38
  Options:
41
39
  - `--source <path-or-git-url>` — where to read policy files from (required).
42
40
  - `--ref <git-ref>` — branch or tag when `--source` is a git URL.
43
- - `--force` — overwrite existing `~/.carson/lint` files.
41
+ - `--force` — overwrite existing `.github/linters/` files.
44
42
 
45
- Policy layout: language config files sit directly under `CODING/` (flat layout, no language subfolders). Non-Ruby entries are present but disabled by default.
43
+ Policy layout: lint config files sit at the root of the source repo (flat layout, no subdirectories required).
46
44
 
47
45
  ### Step 2: Onboard a repository
48
46
 
@@ -96,7 +94,7 @@ jobs:
96
94
 
97
95
  Notes:
98
96
  - When upgrading Carson, update both `carson_ref` and `carson_version` together.
99
- - `CARSON_READ_TOKEN` must have read access to your policy source repository so CI can run `carson lint setup`.
97
+ - `CARSON_READ_TOKEN` must have read access to your policy source repository so CI can run `carson lint policy`.
100
98
  - The reusable workflow installs a pinned RuboCop gem before `carson audit`; mirror the same pin in host governance workflows for deterministic checks.
101
99
 
102
100
  ## Daily Operations
@@ -105,7 +103,7 @@ Notes:
105
103
 
106
104
  ```bash
107
105
  carson sync # fast-forward local main
108
- carson lint setup --source /path/to/your-policy-repo # refresh policy if needed
106
+ carson lint policy --source /path/to/your-policy-repo # refresh policy if needed
109
107
  carson audit # full governance check
110
108
  ```
111
109
 
@@ -183,7 +181,7 @@ Carson's `govern.merge.method` controls how `carson govern` merges ready PRs. Th
183
181
  These define what Carson *is*. They are not configurable.
184
182
 
185
183
  - **Outsider boundary** — Carson never places its own artefacts inside a governed repository.
186
- - **Centralised lint** — one policy source at `~/.carson/lint/`, shared across all repos, zero per-repo drift.
184
+ - **Centralised lint** — one policy source distributed into each repo's `.github/linters/`, zero per-repo drift.
187
185
  - **Active review** — undisposed reviewer findings block merge; feedback must be acknowledged.
188
186
  - **Self-diagnosing output** — every message names the cause and the fix.
189
187
  - **Transparent governance** — Carson prepares everything for merge but never makes decisions without telling you.
@@ -238,11 +236,12 @@ Change: `CARSON_HOOKS_BASE_PATH`.
238
236
 
239
237
  #### Lint policy source
240
238
 
241
- Where lint configuration files live.
239
+ Where lint configuration files come from and where they land.
242
240
 
243
- - Default: **`~/.carson/lint/`**. Centralised: one policy source governs all repos consistently. Repo-local `.rubocop.yml` is forbidden in outsider mode to prevent per-repo drift.
241
+ - Default source: **`wanghailei/lint.git`**. A central repository containing lint configs for all languages.
242
+ - Target: **`<repo>/.github/linters/`**. MegaLinter auto-discovers configs here in CI.
244
243
 
245
- Change the source: `carson lint setup --source <path-or-git-url>`.
244
+ 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.
246
245
 
247
246
  #### Scope integrity
248
247
 
@@ -314,7 +313,7 @@ Common environment overrides:
314
313
  | `CARSON_WORKFLOW_STYLE` | Workflow style override (`branch` or `trunk`). |
315
314
  | `CARSON_RUBY_INDENTATION` | Ruby indentation policy (`tabs`, `spaces`, or `either`). |
316
315
 
317
- For the full configuration schema and `lint.languages` definition, see `API.md`.
316
+ For the full configuration schema, see `API.md`.
318
317
 
319
318
  ## Troubleshooting
320
319
 
@@ -333,11 +332,12 @@ carson template apply
333
332
  carson template check
334
333
  ```
335
334
 
336
- **Audit blocks on repo-local `.rubocop.yml`**
337
- - Carson hard-blocks governed repositories that contain their own `.rubocop.yml`. Remove the repo-local file and rely on the central policy in `~/.carson/lint/rubocop.yml`.
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`.
338
337
 
339
338
  **Hook version mismatch after upgrade**
340
339
  - Run `carson refresh` to re-apply hooks and templates for the new Carson version.
340
+ - Run `carson refresh --all` to refresh all governed repositories at once.
341
341
 
342
342
  ## Offboard a Repository
343
343
 
data/README.md CHANGED
@@ -22,7 +22,6 @@ Carson is an autonomous governance runtime that lives on your workstation and in
22
22
  │ │
23
23
  │ ~/.carson/ Carson config │
24
24
  │ ~/.carson/hooks/ Git hooks │
25
- │ ~/.carson/lint/ Lint policy │
26
25
  │ ~/.carson/cache/ Reports │
27
26
  │ ~/.carson/govern/ Dispatch state │
28
27
  │ │
@@ -45,7 +44,7 @@ This separation is Carson's defining trait — the **outsider boundary**: no Car
45
44
  Carson is opinionated about governance. These are non-negotiable principles, not configurable defaults:
46
45
 
47
46
  - **Outsider boundary** — Carson lives outside your repo, never inside. No Carson-owned artefacts in your repository. Offboarding leaves no trace.
48
- - **Centralised lint** — lint policy at `~/.carson/lint/`, shared across all repos. Repo-local config files are forbidden — one source of truth, zero drift.
47
+ - **Centralised lint** — lint policy distributed from a central source into each repo's `.github/linters/`. One source of truth, zero drift.
49
48
  - **Active review** — undisposed reviewer findings block merge. Feedback must be acknowledged, not buried.
50
49
  - **Self-diagnosing output** — every message names the cause and the fix. If you need to debug Carson's output, the output failed.
51
50
  - **Transparent governance** — Carson prepares everything for merge but never oversteps. It does not make decisions for you without telling you.
@@ -54,8 +53,8 @@ Everything else — workflow style, merge method, remote name, main branch — i
54
53
 
55
54
  The data flow:
56
55
 
57
- 1. You maintain a **policy source** — a directory or git repository containing your lint rules (e.g. `CODING/rubocop.yml`). Carson copies these to `~/.carson/lint/` via `carson lint setup`.
58
- 2. `carson onboard` installs git hooks, synchronises `.github/*` templates, and runs a first governance audit on a host repository.
56
+ 1. You maintain a **policy source** — a directory or git repository containing your lint config files (e.g. `.rubocop.yml`, `biome.json`, `ruff.toml`). `carson lint policy --source <repo>` copies these into each governed repo's `.github/linters/`, where MegaLinter auto-discovers them.
57
+ 2. `carson onboard` installs git hooks, synchronises `.github/*` templates (including a MegaLinter CI workflow), and runs a first governance audit on a host repository.
59
58
  3. From that point, every commit triggers `carson audit` through the managed `pre-commit` hook. The same `carson audit` runs in GitHub Actions. If it passes locally, it passes in CI.
60
59
  4. `carson review gate` enforces review accountability: it blocks merge until every actionable reviewer comment has been formally acknowledged by the PR author through a **disposition comment**.
61
60
  5. `carson govern` triages all open PRs across your portfolio. Ready PRs are merged and housekept. Failing PRs get a coding agent dispatched to fix them. Stuck PRs are escalated for your attention.
@@ -75,10 +74,11 @@ The data flow:
75
74
 
76
75
  | Command | What it does |
77
76
  |---|---|
78
- | `carson lint setup` | Seed `~/.carson/lint/` from your policy source. |
77
+ | `carson lint policy --source <repo>` | Distribute lint configs from policy source into `.github/linters/`. |
79
78
  | `carson onboard` | One-command baseline: hooks + templates + first audit. |
80
79
  | `carson prepare` | Install or refresh Carson-managed global hooks. |
81
80
  | `carson refresh` | Re-apply hooks, templates, and audit after upgrading Carson. |
81
+ | `carson refresh --all` | Refresh all governed repositories at once. |
82
82
  | `carson offboard` | Remove Carson from a repository. |
83
83
 
84
84
  **Daily** — regular development workflow:
@@ -116,10 +116,10 @@ gem install --user-install carson
116
116
  carson version
117
117
  ```
118
118
 
119
- **Prepare your lint policy.** A policy source is any directory (or git URL) that contains a `CODING/` folder with your lint configuration files. For Ruby, the required file is `CODING/rubocop.yml`. Carson copies these into `~/.carson/lint/` so that every governed repository uses the same rules:
119
+ **Prepare your lint policy.** A policy source is any directory (or git URL) containing your lint configuration files (`.rubocop.yml`, `biome.json`, `ruff.toml`, etc.). Carson copies these into the governed repo's `.github/linters/` where MegaLinter auto-discovers them:
120
120
 
121
121
  ```bash
122
- carson lint setup --source /path/to/your-policy-repo
122
+ carson lint policy --source /path/to/your-policy-repo
123
123
  ```
124
124
 
125
125
  **Onboard a repository:**
data/RELEASE.md CHANGED
@@ -5,6 +5,40 @@ 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.0 — Refresh All Governed Repositories
9
+
10
+ ### What changed
11
+
12
+ - **`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.
13
+
14
+ ## 2.12.0 — Language-Agnostic Lint Policy Distribution + MegaLinter
15
+
16
+ ### What changed
17
+
18
+ - **Lint policy distribution is now language-agnostic.** `carson lint policy --source <path-or-git-url>` copies all files from the source repo root into the governed repo's `.github/linters/` directory, where MegaLinter auto-discovers them. Works for any linter config: rubocop.yml, biome.json, ruff.toml, .erb-lint.yml, etc.
19
+ - **New `lint.command` config key.** Local audit lint is now a single user-configured command (e.g. `"make lint"`, `"trunk check"`, `["ruff", "check"]`). Replaces the old per-language `lint.languages` system entirely.
20
+ - **New `lint.enforcement` config key.** `"strict"` (default) blocks on lint failure; `"advisory"` warns but does not block.
21
+ - **New `lint.policy_source` config key.** Default: `wanghailei/lint.git`. Sets the default source for lint policy distribution.
22
+ - **MegaLinter CI workflow template.** `carson onboard` now installs `.github/workflows/carson-lint.yml`, which runs MegaLinter on PRs and pushes to main.
23
+ - **Interactive setup prompts.** `carson setup` now asks for lint command and enforcement mode.
24
+ - **Removed `lint.languages`** — all per-language lint configuration, Ruby-specific lint runners, and hardcoded language definitions are gone.
25
+ - **Removed `lint setup` subcommand alias** — use `carson lint policy` instead.
26
+ - **Removed `--legacy` flag** from `carson lint policy`.
27
+ - **Source repo layout simplified** — lint config files live at the source repo root; no `CODING/` subdirectory required.
28
+
29
+ ### Breaking changes
30
+
31
+ - `lint.languages` config key no longer exists. If your config references it, remove it.
32
+ - `carson lint setup` no longer works. Use `carson lint policy --source <path-or-git-url>`.
33
+ - Lint policy files are now written to `<repo>/.github/linters/` (not `~/.carson/lint/`).
34
+
35
+ ### What users must do now
36
+
37
+ 1. Upgrade Carson to `2.12.0` and run `carson refresh`.
38
+ 2. Remove any `lint.languages` entries from your Carson config.
39
+ 3. Set `lint.command` in your config if you want local lint during audit (e.g. `"make lint"`).
40
+ 4. Run `carson lint policy --source <your-policy-repo>` to distribute linter configs to `.github/linters/`.
41
+
8
42
  ## 2.11.3 — Refine RubyGems Description Tone
9
43
 
10
44
  ### What changed
data/SKILL.md CHANGED
@@ -33,7 +33,7 @@ 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** — per-language lint results. `lint_ruby_status: ok` means clean.
36
+ - **Local Lint Quality** — lint command result. `lint_command_status: ok` means clean.
37
37
  - **Main Sync Status** — whether local main matches remote. If ahead, reset drift before committing.
38
38
  - **Scope Integrity Guard** — checks that commits stay within a single business intent and scope group.
39
39
  - **Audit Result** — final verdict: `status: ok` (clean), `status: attention` (advisory, not blocking), `status: block` (must fix).
@@ -99,4 +99,4 @@ Check that `govern.merge.method` in config matches what GitHub allows. If the re
99
99
  - Carson never lives inside governed repositories. No `.carson.yml`, no `bin/carson`, no `.tools/carson/`.
100
100
  - Carson-managed files in repos are limited to `.github/*` templates.
101
101
  - Carson's hooks live at `~/.carson/hooks/<version>/`, never in `.git/hooks/`.
102
- - Lint policy lives at `~/.carson/lint/`, seeded by `carson lint setup --source <policy-repo>`.
102
+ - Lint policy is distributed via `carson lint policy --source <policy-repo>` into each repo's `.github/linters/`.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 2.11.3
1
+ 2.13.0
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 setup --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 )
@@ -112,8 +145,8 @@ module Carson
112
145
 
113
146
  def self.parse_lint_subcommand( argv:, parser:, err: )
114
147
  action = argv.shift
115
- unless action == "setup"
116
- err.puts "#{BADGE} Missing or invalid subcommand for lint. Use: carson lint setup --source <path-or-git-url> [--ref <git-ref>] [--force]"
148
+ unless action == "policy"
149
+ err.puts "#{BADGE} Missing or invalid subcommand for lint. Use: carson lint policy --source <path-or-git-url> [--ref <git-ref>] [--force]"
117
150
  err.puts parser
118
151
  return { command: :invalid }
119
152
  end
@@ -124,19 +157,19 @@ module Carson
124
157
  force: false
125
158
  }
126
159
  lint_parser = OptionParser.new do |opts|
127
- opts.banner = "Usage: carson lint setup --source <path-or-git-url> [--ref <git-ref>] [--force]"
160
+ opts.banner = "Usage: carson lint policy --source <path-or-git-url> [--ref <git-ref>] [--force]"
128
161
  opts.on( "--source SOURCE", "Source repository path or git URL that contains CODING/" ) { |value| options[ :source ] = value.to_s.strip }
129
162
  opts.on( "--ref REF", "Git ref used when --source is a git URL (default: main)" ) { |value| options[ :ref ] = value.to_s.strip }
130
- opts.on( "--force", "Overwrite existing files in ~/.carson/lint" ) { options[ :force ] = true }
163
+ opts.on( "--force", "Overwrite existing files" ) { options[ :force ] = true }
131
164
  end
132
165
  lint_parser.parse!( argv )
133
166
  if options.fetch( :source ).to_s.empty?
134
- err.puts "#{BADGE} Missing required --source for lint setup."
167
+ err.puts "#{BADGE} Missing required --source for lint policy."
135
168
  err.puts lint_parser
136
169
  return { command: :invalid }
137
170
  end
138
171
  unless argv.empty?
139
- err.puts "#{BADGE} Unexpected arguments for lint setup: #{argv.join( ' ' )}"
172
+ err.puts "#{BADGE} Unexpected arguments for lint policy: #{argv.join( ' ' )}"
140
173
  err.puts lint_parser
141
174
  return { command: :invalid }
142
175
  end
@@ -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
@@ -8,7 +8,8 @@ module Carson
8
8
  class Config
9
9
  attr_accessor :git_remote
10
10
  attr_reader :main_branch, :protected_branches, :hooks_base_path, :required_hooks,
11
- :path_groups, :template_managed_files, :lint_languages,
11
+ :path_groups, :template_managed_files,
12
+ :lint_command, :lint_enforcement, :lint_policy_source,
12
13
  :review_wait_seconds, :review_poll_seconds, :review_max_polls, :review_sweep_window_days,
13
14
  :review_sweep_states, :review_disposition_prefix, :review_risk_keywords,
14
15
  :review_tracking_issue_title, :review_tracking_issue_label, :review_bot_usernames,
@@ -47,10 +48,12 @@ module Carson
47
48
  }
48
49
  },
49
50
  "template" => {
50
- "managed_files" => [ ".github/carson-instructions.md", ".github/copilot-instructions.md", ".github/CLAUDE.md", ".github/AGENTS.md", ".github/pull_request_template.md" ]
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" ]
51
52
  },
52
53
  "lint" => {
53
- "languages" => default_lint_languages_data
54
+ "command" => nil,
55
+ "enforcement" => "strict",
56
+ "policy_source" => "wanghailei/lint.git"
54
57
  },
55
58
  "workflow" => {
56
59
  "style" => "branch"
@@ -94,42 +97,6 @@ module Carson
94
97
  }
95
98
  end
96
99
 
97
- def self.default_lint_languages_data
98
- ruby_runner = File.expand_path( "policy/ruby/lint.rb", __dir__ )
99
- {
100
- "ruby" => {
101
- "enabled" => true,
102
- "globs" => [ "**/*.rb", "Gemfile", "*.gemspec", "Rakefile" ],
103
- "command" => [ "ruby", ruby_runner, "{files}" ],
104
- "config_files" => [ "~/.carson/lint/rubocop.yml" ]
105
- },
106
- "javascript" => {
107
- "enabled" => false,
108
- "globs" => [ "**/*.js", "**/*.mjs", "**/*.cjs", "**/*.jsx" ],
109
- "command" => [ "node", "~/.carson/lint/javascript.lint.js", "{files}" ],
110
- "config_files" => [ "~/.carson/lint/javascript.lint.js" ]
111
- },
112
- "css" => {
113
- "enabled" => false,
114
- "globs" => [ "**/*.css" ],
115
- "command" => [ "node", "~/.carson/lint/css.lint.js", "{files}" ],
116
- "config_files" => [ "~/.carson/lint/css.lint.js" ]
117
- },
118
- "html" => {
119
- "enabled" => false,
120
- "globs" => [ "**/*.html" ],
121
- "command" => [ "node", "~/.carson/lint/html.lint.js", "{files}" ],
122
- "config_files" => [ "~/.carson/lint/html.lint.js" ]
123
- },
124
- "erb" => {
125
- "enabled" => false,
126
- "globs" => [ "**/*.erb" ],
127
- "command" => [ "ruby", "~/.carson/lint/erb.lint.rb", "{files}" ],
128
- "config_files" => [ "~/.carson/lint/erb.lint.rb" ]
129
- }
130
- }
131
- end
132
-
133
100
  def self.load_global_config_data( repo_root: )
134
101
  path = global_config_path( repo_root: repo_root )
135
102
  return {} if path.empty? || !File.file?( path )
@@ -200,6 +167,13 @@ module Carson
200
167
  audit = fetch_hash_section( data: copy, key: "audit" )
201
168
  advisory_names = env_string_array( key: "CARSON_AUDIT_ADVISORY_CHECK_NAMES" )
202
169
  audit[ "advisory_check_names" ] = advisory_names unless advisory_names.empty?
170
+ 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
+ lint_policy_source_env = ENV.fetch( "CARSON_LINT_POLICY_SOURCE", "" ).to_s.strip
176
+ lint[ "policy_source" ] = lint_policy_source_env unless lint_policy_source_env.empty?
203
177
  style = fetch_hash_section( data: copy, key: "style" )
204
178
  ruby_indentation = ENV.fetch( "CARSON_RUBY_INDENTATION", "" ).to_s.strip
205
179
  style[ "ruby_indentation" ] = ruby_indentation unless ruby_indentation.empty?
@@ -248,9 +222,10 @@ module Carson
248
222
  @path_groups = fetch_hash( hash: fetch_hash( hash: data, key: "scope" ), key: "path_groups" ).transform_values { |value| normalize_patterns( value: value ) }
249
223
 
250
224
  @template_managed_files = fetch_string_array( hash: fetch_hash( hash: data, key: "template" ), key: "managed_files" )
251
- @lint_languages = normalize_lint_languages(
252
- languages_hash: fetch_hash( hash: fetch_hash( hash: data, key: "lint" ), key: "languages" )
253
- )
225
+ 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
+ @lint_policy_source = lint_hash.fetch( "policy_source", "" ).to_s.strip
254
229
 
255
230
  workflow_hash = fetch_hash( hash: data, key: "workflow" )
256
231
  @workflow_style = fetch_string( hash: workflow_hash, key: "style" ).downcase
@@ -296,7 +271,6 @@ module Carson
296
271
  raise ConfigError, "hooks.base_path cannot be empty" if hooks_base_path.empty?
297
272
  raise ConfigError, "hooks.required_hooks cannot be empty" if required_hooks.empty?
298
273
  raise ConfigError, "scope.path_groups cannot be empty" if path_groups.empty?
299
- raise ConfigError, "lint.languages cannot be empty" if lint_languages.empty?
300
274
  raise ConfigError, "review.required_disposition_prefix cannot be empty" if review_disposition_prefix.empty?
301
275
  raise ConfigError, "review.risk_keywords cannot be empty" if review_risk_keywords.empty?
302
276
  raise ConfigError, "review.sweep.states must contain one or both of open, closed" if ( review_sweep_states - [ "open", "closed" ] ).any? || review_sweep_states.empty?
@@ -364,56 +338,21 @@ module Carson
364
338
  patterns
365
339
  end
366
340
 
367
- def normalize_lint_languages( languages_hash: )
368
- raise ConfigError, "lint.languages must be an object" unless languages_hash.is_a?( Hash )
369
- normalised = {}
370
- languages_hash.each do |language_key, raw_entry|
371
- language = language_key.to_s.strip.downcase
372
- raise ConfigError, "lint.languages contains blank language key" if language.empty?
373
- raise ConfigError, "lint.languages.#{language} must be an object" unless raw_entry.is_a?( Hash )
374
-
375
- normalised[ language ] = normalize_lint_language_entry( language: language, raw_entry: raw_entry )
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
376
348
  end
377
- normalised
378
- end
379
-
380
- def normalize_lint_language_entry( language:, raw_entry: )
381
- {
382
- enabled: fetch_optional_boolean(
383
- hash: raw_entry,
384
- key: "enabled",
385
- default: true,
386
- key_path: "lint.languages.#{language}.enabled"
387
- ),
388
- globs: normalize_lint_globs( language: language, value: raw_entry[ "globs" ] ),
389
- command: normalize_lint_command( language: language, value: raw_entry[ "command" ] ),
390
- config_files: normalize_lint_config_files( language: language, value: raw_entry[ "config_files" ] )
391
- }
392
- end
393
-
394
- def normalize_lint_globs( language:, value: )
395
- raise ConfigError, "lint.languages.#{language}.globs must be an array" unless value.is_a?( Array )
396
- patterns = Array( value ).map { |entry| entry.to_s.strip }.reject( &:empty? )
397
- raise ConfigError, "lint.languages.#{language}.globs must contain at least one pattern" if patterns.empty?
398
- patterns
399
- end
400
-
401
- def normalize_lint_command( language:, value: )
402
- raise ConfigError, "lint.languages.#{language}.command must be an array" unless value.is_a?( Array )
403
- command = Array( value ).map { |entry| entry.to_s.strip }.reject( &:empty? )
404
- raise ConfigError, "lint.languages.#{language}.command must contain at least one argument" if command.empty?
405
- command
349
+ raise ConfigError, "lint.command must be a string, array, or null"
406
350
  end
407
351
 
408
- def normalize_lint_config_files( language:, value: )
409
- raise ConfigError, "lint.languages.#{language}.config_files must be an array" unless value.is_a?( Array )
410
- files = Array( value ).map { |entry| entry.to_s.strip }.reject( &:empty? )
411
- raise ConfigError, "lint.languages.#{language}.config_files must contain at least one path" if files.empty?
412
- files.map do |path|
413
- expanded = path.start_with?( "~" ) ? File.expand_path( path ) : path
414
- raise ConfigError, "lint.languages.#{language}.config_files entries must be absolute paths or ~/ paths" unless expanded.start_with?( "/" )
415
- expanded
416
- end
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
417
356
  end
418
357
 
419
358
  def fetch_optional_boolean( hash:, key:, default:, key_path: nil )
@@ -162,9 +162,43 @@ module Carson
162
162
  report
163
163
  end
164
164
 
165
- # Enforces configured multi-language lint policy before governance passes.
165
+ # Enforces configured lint policy before governance passes.
166
+ # Runs lint.command and gates on exit code. Skips when lint.command is not set.
166
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
167
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
+
168
202
  report = {
169
203
  status: "ok",
170
204
  skip_reason: nil,
@@ -175,32 +209,63 @@ module Carson
175
209
  }
176
210
  puts_verbose "lint_target_source: #{target_source}"
177
211
  puts_verbose "lint_target_files_total: #{target_files.count}"
178
- config.lint_languages.each do |language, entry|
179
- language_report = lint_language_report(
180
- language: language,
181
- entry: entry,
182
- target_files: target_files
183
- )
184
- report.fetch( :languages ) << language_report
185
- next unless language_report.fetch( :status ) == "block"
212
+ puts_verbose "lint_command: #{command_string}"
213
+ puts_verbose "lint_enforcement: #{config.lint_enforcement}"
186
214
 
187
- report[ :status ] = "block"
188
- report[ :blocking_languages ] += 1
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
189
235
  end
190
- puts_verbose "lint_blocking_languages: #{report.fetch( :blocking_languages )}"
191
- report
192
- rescue StandardError => e
193
- report ||= {
194
- status: "block",
195
- skip_reason: nil,
196
- target_source: "unknown",
197
- target_files_count: 0,
198
- blocking_languages: 0,
199
- languages: []
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
200
252
  }
201
- report[ :status ] = "block"
202
- report[ :skip_reason ] = e.message
203
- puts_line "BLOCK: local lint quality check failed (#{e.message})."
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
+
204
269
  report
205
270
  end
206
271
 
@@ -274,85 +339,6 @@ module Carson
274
339
  end.compact.uniq
275
340
  end
276
341
 
277
- def lint_language_report( language:, entry:, target_files: )
278
- globs = entry.fetch( :globs )
279
- candidate_files = Array( target_files ).select do |path|
280
- globs.any? { |pattern| pattern_matches_path?( pattern: pattern, path: path ) }
281
- end
282
- report = {
283
- language: language,
284
- enabled: entry.fetch( :enabled ),
285
- status: "ok",
286
- reason: nil,
287
- file_count: candidate_files.count,
288
- files: candidate_files,
289
- command: entry.fetch( :command ),
290
- config_files: entry.fetch( :config_files ),
291
- exit_code: 0
292
- }
293
- puts_verbose "lint_language: #{language} enabled=#{report.fetch( :enabled )} files=#{report.fetch( :file_count )}"
294
- if language == "ruby" && outsider_mode?
295
- local_rubocop_path = File.join( repo_root, ".rubocop.yml" )
296
- if File.file?( local_rubocop_path )
297
- report[ :status ] = "block"
298
- report[ :reason ] = "repo-local RuboCop config is forbidden: #{relative_path( local_rubocop_path )}; remove it and use ~/.carson/lint/rubocop.yml."
299
- report[ :exit_code ] = EXIT_BLOCK
300
- puts_verbose "lint_#{language}_status: block"
301
- puts_verbose "lint_#{language}_reason: #{report.fetch( :reason )}"
302
- puts_verbose "ACTION: remove .rubocop.yml from this repository and run carson lint setup --source <path-or-git-url>."
303
- return report
304
- end
305
- end
306
- return report unless report.fetch( :enabled )
307
- return report if candidate_files.empty?
308
-
309
- missing_config_files = entry.fetch( :config_files ).reject { |path| File.file?( path ) }
310
- unless missing_config_files.empty?
311
- report[ :status ] = "block"
312
- report[ :reason ] = "missing config files: #{missing_config_files.join( ', ' )}"
313
- report[ :exit_code ] = EXIT_BLOCK
314
- puts_verbose "lint_#{language}_status: block"
315
- puts_verbose "lint_#{language}_reason: #{report.fetch( :reason )}"
316
- puts_verbose "ACTION: run carson lint setup --source <path-or-git-url> to prepare ~/.carson/lint policy files."
317
- return report
318
- end
319
-
320
- command = Array( entry.fetch( :command ) )
321
- command_name = command.first.to_s.strip
322
- if command_name.empty?
323
- report[ :status ] = "block"
324
- report[ :reason ] = "missing lint command"
325
- report[ :exit_code ] = EXIT_BLOCK
326
- puts_verbose "lint_#{language}_status: block"
327
- puts_verbose "lint_#{language}_reason: #{report.fetch( :reason )}"
328
- return report
329
- end
330
- unless command_available_for_lint?( command_name: command_name )
331
- report[ :status ] = "block"
332
- report[ :reason ] = "command not available: #{command_name}"
333
- report[ :exit_code ] = EXIT_BLOCK
334
- puts_verbose "lint_#{language}_status: block"
335
- puts_verbose "lint_#{language}_reason: #{report.fetch( :reason )}"
336
- return report
337
- end
338
-
339
- args = expanded_lint_command_args( command: command, files: candidate_files )
340
- stdout_text, stderr_text, success, exit_code = local_command( *args )
341
- report[ :exit_code ] = exit_code
342
- unless success
343
- report[ :status ] = "block"
344
- report[ :reason ] = summarise_command_output(
345
- stdout_text: stdout_text,
346
- stderr_text: stderr_text,
347
- fallback: "lint command failed for #{language}"
348
- )
349
- end
350
- puts_verbose "lint_#{language}_status: #{report.fetch( :status )}"
351
- puts_verbose "lint_#{language}_exit: #{report.fetch( :exit_code )}"
352
- puts_verbose "lint_#{language}_reason: #{report.fetch( :reason )}" unless report.fetch( :reason ).nil?
353
- report
354
- end
355
-
356
342
  def command_available_for_lint?( command_name: )
357
343
  return false if command_name.to_s.strip.empty?
358
344
 
@@ -373,26 +359,6 @@ module Carson
373
359
  end
374
360
  end
375
361
 
376
- def expanded_lint_command_args( command:, files: )
377
- expanded_command = Array( command ).map do |arg|
378
- text = arg.to_s
379
- if text == "{files}"
380
- text
381
- elsif text.start_with?( "~" )
382
- File.expand_path( text )
383
- elsif text.include?( "/" ) && !text.start_with?( "/" )
384
- File.expand_path( text, repo_root )
385
- else
386
- text
387
- end
388
- end
389
- if expanded_command.include?( "{files}" )
390
- return expanded_command.flat_map { |arg| arg == "{files}" ? Array( files ) : arg }
391
- end
392
-
393
- expanded_command + Array( files )
394
- end
395
-
396
362
  # Local command runner for repository-context tools used by audit lint checks.
397
363
  def local_command( *args )
398
364
  stdout_text, stderr_text, status = Open3.capture3( *args, chdir: repo_root )
@@ -5,13 +5,14 @@ require "tmpdir"
5
5
  module Carson
6
6
  class Runtime
7
7
  module Lint
8
- # Prepares canonical lint policy files under ~/.carson/lint from an explicit source.
9
- def lint_setup!( source:, ref: "main", force: false )
8
+ # Distributes lint policy files from a central source into the governed repository.
9
+ # Target: <repo>/.github/linters/ (MegaLinter auto-discovers here).
10
+ def lint_setup!( source:, ref: "main", force: false, **_ )
10
11
  puts_verbose ""
11
- puts_verbose "[Lint Setup]"
12
+ puts_verbose "[Lint Policy]"
12
13
  source_text = source.to_s.strip
13
14
  if source_text.empty?
14
- puts_line "ERROR: lint setup requires --source <path-or-git-url>."
15
+ puts_line "ERROR: lint policy requires --source <path-or-git-url>."
15
16
  return EXIT_ERROR
16
17
  end
17
18
 
@@ -19,40 +20,26 @@ module Carson
19
20
  ref_text = "main" if ref_text.empty?
20
21
  source_dir, cleanup = lint_setup_source_directory( source: source_text, ref: ref_text )
21
22
  begin
22
- source_coding_dir = File.join( source_dir, "CODING" )
23
- unless Dir.exist?( source_coding_dir )
24
- puts_line "ERROR: source CODING directory not found at #{source_coding_dir}."
25
- return EXIT_ERROR
26
- end
27
- target_coding_dir = ai_coding_dir
28
- copy_result = copy_lint_coding_tree(
29
- source_coding_dir: source_coding_dir,
30
- target_coding_dir: target_coding_dir,
23
+ target_dir = repo_linters_dir
24
+ copy_result = copy_lint_policy_files(
25
+ source_dir: source_dir,
26
+ target_dir: target_dir,
31
27
  force: force
32
28
  )
33
- puts_verbose "lint_setup_source: #{source_text}"
34
- puts_verbose "lint_setup_ref: #{ref_text}" if lint_source_git_url?( source: source_text )
35
- puts_verbose "lint_setup_target: #{target_coding_dir}"
36
- puts_verbose "lint_setup_created: #{copy_result.fetch( :created )}"
37
- puts_verbose "lint_setup_updated: #{copy_result.fetch( :updated )}"
38
- puts_verbose "lint_setup_skipped: #{copy_result.fetch( :skipped )}"
39
-
40
- missing_policy = missing_lint_policy_files
41
- if missing_policy.empty?
42
- puts_line "OK: lint policy setup is complete."
43
- return EXIT_OK
44
- end
45
-
46
- missing_policy.each do |entry|
47
- puts_verbose "missing_lint_policy_file: language=#{entry.fetch( :language )} path=#{entry.fetch( :path )}"
48
- end
49
- puts_line "ACTION: update source CODING policy files, rerun carson lint setup, then rerun carson audit."
50
- EXIT_ERROR
29
+ puts_verbose "lint_policy_source: #{source_text}"
30
+ puts_verbose "lint_policy_ref: #{ref_text}" if lint_source_git_url?( source: source_text )
31
+ puts_verbose "lint_policy_target: #{target_dir}"
32
+ puts_verbose "lint_policy_created: #{copy_result.fetch( :created )}"
33
+ puts_verbose "lint_policy_updated: #{copy_result.fetch( :updated )}"
34
+ puts_verbose "lint_policy_skipped: #{copy_result.fetch( :skipped )}"
35
+
36
+ puts_line "OK: lint policy synced to .github/linters/ (#{copy_result.fetch( :created )} created, #{copy_result.fetch( :updated )} updated)."
37
+ EXIT_OK
51
38
  ensure
52
39
  cleanup&.call
53
40
  end
54
41
  rescue StandardError => e
55
- puts_line "ERROR: lint setup failed (#{e.message})"
42
+ puts_line "ERROR: lint policy failed (#{e.message})"
56
43
  EXIT_ERROR
57
44
  end
58
45
 
@@ -119,22 +106,21 @@ module Carson
119
106
  "/tmp/carson"
120
107
  end
121
108
 
122
- def ai_coding_dir
123
- home = ENV.fetch( "HOME", "" ).to_s.strip
124
- raise "HOME must be an absolute path for lint setup" unless home.start_with?( "/" )
125
-
126
- File.join( home, ".carson", "lint" )
109
+ # Lint configs live inside the governed repository for MegaLinter.
110
+ def repo_linters_dir
111
+ File.join( repo_root, ".github", "linters" )
127
112
  end
128
113
 
129
- def copy_lint_coding_tree( source_coding_dir:, target_coding_dir:, force: )
130
- FileUtils.mkdir_p( target_coding_dir )
114
+ def copy_lint_policy_files( source_dir:, target_dir:, force: )
115
+ FileUtils.mkdir_p( target_dir )
131
116
  created = 0
132
117
  updated = 0
133
118
  skipped = 0
134
- Dir.glob( "**/*", File::FNM_DOTMATCH, base: source_coding_dir ).sort.each do |relative|
119
+ Dir.glob( "**/*", File::FNM_DOTMATCH, base: source_dir ).sort.each do |relative|
135
120
  next if [ ".", ".." ].include?( relative )
136
- source_path = File.join( source_coding_dir, relative )
137
- target_path = File.join( target_coding_dir, relative )
121
+ next if relative.start_with?( ".git/" ) || relative == ".git"
122
+ source_path = File.join( source_dir, relative )
123
+ target_path = File.join( target_dir, relative )
138
124
  if File.directory?( source_path )
139
125
  FileUtils.mkdir_p( target_path )
140
126
  next
@@ -161,16 +147,6 @@ module Carson
161
147
  skipped: skipped
162
148
  }
163
149
  end
164
-
165
- def missing_lint_policy_files
166
- config.lint_languages.each_with_object( [] ) do |( language, entry ), missing|
167
- next unless entry.fetch( :enabled )
168
-
169
- entry.fetch( :config_files ).each do |path|
170
- missing << { language: language, path: path } unless File.file?( path )
171
- end
172
- end
173
- end
174
150
  end
175
151
 
176
152
  include Lint
@@ -247,6 +247,40 @@ 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 "ERROR: no governed repositories configured. Add repos via carson setup or govern.repos in ~/.carson/config.json."
255
+ return EXIT_ERROR
256
+ end
257
+
258
+ puts_line ""
259
+ puts_line "Refresh all (#{repos.length} repo#{plural_suffix( count: repos.length )})"
260
+ refreshed = 0
261
+ failed = 0
262
+
263
+ repos.each do |repo_path|
264
+ repo_name = File.basename( repo_path )
265
+ unless Dir.exist?( repo_path )
266
+ puts_line "#{repo_name}: FAIL (path not found)"
267
+ failed += 1
268
+ next
269
+ end
270
+
271
+ status = refresh_single_repo( repo_path: repo_path, repo_name: repo_name )
272
+ if status == EXIT_ERROR
273
+ failed += 1
274
+ else
275
+ refreshed += 1
276
+ end
277
+ end
278
+
279
+ puts_line ""
280
+ puts_line "Refresh all complete: #{refreshed} refreshed, #{failed} failed."
281
+ failed.zero? ? EXIT_OK : EXIT_ERROR
282
+ end
283
+
250
284
  # Removes Carson-managed repository integration so a host repository can retire Carson cleanly.
251
285
  def offboard!
252
286
  puts_verbose ""
@@ -365,13 +399,41 @@ module Carson
365
399
 
366
400
  private
367
401
 
402
+ # Refreshes a single governed repository using a scoped Runtime.
403
+ def refresh_single_repo( repo_path:, repo_name: )
404
+ if verbose?
405
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: out, err: err, verbose: true )
406
+ else
407
+ rt = Runtime.new( repo_root: repo_path, tool_root: tool_root, out: StringIO.new, err: StringIO.new )
408
+ end
409
+ status = rt.refresh!
410
+ label = refresh_status_label( status: status )
411
+ puts_line "#{repo_name}: #{label}"
412
+ status
413
+ rescue StandardError => e
414
+ puts_line "#{repo_name}: FAIL (#{e.message})"
415
+ EXIT_ERROR
416
+ end
417
+
418
+ def refresh_status_label( status: )
419
+ case status
420
+ when EXIT_OK then "OK"
421
+ when EXIT_BLOCK then "BLOCK"
422
+ else "FAIL"
423
+ end
424
+ end
425
+
368
426
  def template_results
369
427
  config.template_managed_files.map { |managed_file| template_result_for_file( managed_file: managed_file ) }
370
428
  end
371
429
 
372
430
  # Calculates whole-file expected content and returns sync status plus apply payload.
373
431
  def template_result_for_file( managed_file: )
374
- template_path = File.join( github_templates_dir, File.basename( managed_file ) )
432
+ # Try subdirectory-aware path first (e.g. .github/workflows/carson-lint.yml),
433
+ # then fall back to flat basename lookup for backward compatibility.
434
+ relative_within_github = managed_file.delete_prefix( ".github/" )
435
+ template_path = File.join( github_templates_dir, relative_within_github )
436
+ template_path = File.join( github_templates_dir, File.basename( managed_file ) ) unless File.file?( template_path )
375
437
  return { file: managed_file, status: "error", reason: "missing template #{File.basename( managed_file )}", applied_content: nil } unless File.file?( template_path )
376
438
 
377
439
  expected_content = normalize_text( text: File.read( template_path ) )
@@ -39,6 +39,12 @@ 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
  write_setup_config( choices: choices )
43
49
  end
44
50
 
@@ -142,6 +148,34 @@ module Carson
142
148
  prompt_choice( options: options, default: 0 )
143
149
  end
144
150
 
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
+
145
179
  def prompt_choice( options:, default: )
146
180
  options.each_with_index do |option, index|
147
181
  puts_line " #{index + 1}) #{option.fetch( :label )}"
@@ -0,0 +1,23 @@
1
+ name: Carson Lint
2
+ on:
3
+ pull_request:
4
+ branches: [main, master]
5
+ push:
6
+ branches: [main, master]
7
+
8
+ jobs:
9
+ lint:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ issues: write
14
+ pull-requests: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 0
19
+ - uses: oxsecurity/megalinter@v8
20
+ env:
21
+ VALIDATE_ALL_CODEBASE: false
22
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
23
+ LINTER_RULES_PATH: .github/linters
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.11.3
4
+ version: 2.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hailei Wang
@@ -46,7 +46,6 @@ files:
46
46
  - lib/carson/adapters/prompt.rb
47
47
  - lib/carson/cli.rb
48
48
  - lib/carson/config.rb
49
- - lib/carson/policy/ruby/lint.rb
50
49
  - lib/carson/runtime.rb
51
50
  - lib/carson/runtime/audit.rb
52
51
  - lib/carson/runtime/govern.rb
@@ -65,6 +64,7 @@ files:
65
64
  - templates/.github/carson-instructions.md
66
65
  - templates/.github/copilot-instructions.md
67
66
  - templates/.github/pull_request_template.md
67
+ - templates/.github/workflows/carson-lint.yml
68
68
  homepage: https://github.com/wanghailei/carson
69
69
  licenses:
70
70
  - MIT
@@ -1,60 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "open3"
4
-
5
- EXIT_OK = 0
6
- EXIT_ERROR = 1
7
- EXIT_BLOCK = 2
8
-
9
- def rubocop_config_path
10
- File.expand_path( "~/.carson/lint/rubocop.yml" )
11
- end
12
-
13
- def print_stream( io, text )
14
- content = text.to_s
15
- return if content.empty?
16
- io.print( content )
17
- end
18
-
19
- def run_rubocop( files: )
20
- stdout_text, stderr_text, status = Open3.capture3(
21
- "rubocop", "--config", rubocop_config_path, *files
22
- )
23
- print_stream( $stdout, stdout_text )
24
- print_stream( $stderr, stderr_text )
25
- { status: :completed, exit_code: status.exitstatus.to_i }
26
- rescue Errno::ENOENT
27
- $stderr.puts "ERROR: RuboCop executable `rubocop` is unavailable in PATH. Install the pinned RuboCop gem before running carson audit."
28
- { status: :unavailable, exit_code: nil }
29
- rescue StandardError => e
30
- $stderr.puts "ERROR: RuboCop execution failed (#{e.message})"
31
- { status: :runtime_error, exit_code: nil }
32
- end
33
-
34
- def lint_exit_code( result: )
35
- case result.fetch( :status )
36
- when :unavailable
37
- EXIT_BLOCK
38
- when :runtime_error
39
- EXIT_ERROR
40
- else
41
- case result.fetch( :exit_code )
42
- when 0
43
- EXIT_OK
44
- when 1
45
- EXIT_BLOCK
46
- else
47
- EXIT_ERROR
48
- end
49
- end
50
- end
51
-
52
- files = ARGV.map( &:to_s ).map( &:strip ).reject( &:empty? )
53
- config_path = rubocop_config_path
54
- unless File.file?( config_path )
55
- $stderr.puts "ERROR: RuboCop config not found at #{config_path}. Run `carson lint setup --source <path-or-git-url>`."
56
- exit EXIT_ERROR
57
- end
58
-
59
- result = run_rubocop( files: files )
60
- exit lint_exit_code( result: result )