Skip to content

Commit 57b6467

Browse files
committed
feat: 抽 run_per_language.py 共享 run_check/fix 的 per-language 编排
- 新增 scripts/run_per_language.py:暴露 run_check / run_fix 两个纯函数, 封装 per-language 串行执行 + 退出码归一化(0/124/127)+ 结果聚合; - scripts/run_check.py 删本地 run/check_one/fix_one,仅保留 argparse + 报告; - scripts/fix.py 删内联 subprocess 循环,仅保留 argparse + 报告; - 行为不变:CLI 退出码、--fix 语义、--dry-run 输出格式、报告格式 100% 兼容; - 验证:unittest 14/14 + run_all 141/141/0 + openspec strict 5/5 + diff clean; - spec: idiomatic-runner (4 Requirements) 已合入
1 parent 9df5c43 commit 57b6467

9 files changed

Lines changed: 331 additions & 68 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-09-22
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
## Context
2+
3+
代码图谱与全文阅读确认:
4+
- `scripts/run_check.py` 内含 `run()`(subprocess 封装)/ `check_one()`(lint 调用)/ `fix_one()`(format 调用)/ `cli_main()`(CLI 编排 + 报告)。
5+
- `scripts/fix.py` 内含 `main()`(detect_languages 解析 + LANG_COMMANDS 解包 + subprocess.run 串行 + 失败聚合)。
6+
- 两脚本对「失败 = exit code != 0」与「command not found」的处置逻辑基本相同但**分散**:run_check 用 `run()` 集中捕获 `FileNotFoundError → return 127`,fix.py 用 try/except 捕获 `FileNotFoundError → continue + failed.append(lang)`。
7+
8+
重复的代价:
9+
- 同一 lint/format 调用约定的所有演化(subprocess timeout、retry、probe、dry-run、节流)必须双改。
10+
- MCP 模式占位(`run_check.py::mcp_main`)与 `fix.py` 互相不可见但语义重叠。
11+
- 未来要支持 `--json` 输出格式、并发执行、retries 时三处同步。
12+
13+
## Decisions
14+
15+
### 共享边界 = 「per-language 串行执行 + 结果聚合」
16+
17+
`scripts/run_per_language.py` 暴露两个对外函数:
18+
19+
```python
20+
def run_check(languages: list[str], project_root: Path,
21+
*, timeout: int = 120, fix: bool = False,
22+
dry_run: bool = False) -> list[dict]: ...
23+
def run_fix(languages: list[str], project_root: Path,
24+
*, timeout: int = 120, dry_run: bool = False) -> list[dict]: ...
25+
```
26+
27+
返回 dict 与现有 `check_one` / `fix_one` 形态兼容(`passed` / `fixed` / `exit_code` / `stderr_tail` / `stdout_tail`),CLI 层零报告格式改动。
28+
29+
### `dry_run` 显式契约
30+
31+
`run_check(dry_run=True)` / `run_fix(dry_run=True)` 仅打印 `would run: <cmd>`,不真跑。`fix.py` 当前就是这种行为(`print(f" {lang:12s} would run: ...")`),`run_check.py` 无该选项但 change 不引入新 CLI flag;新增的 dry_run 行为仅作为 run_per_language 内部契约。
32+
33+
### fix=True 走 run_check 内部的 retry
34+
35+
`run_check.py` 当前的「lint fail → 自动 fix → 复检」三段式保留在 `run_per_language.run_check` 内部,作为其 fix 参数的实现细节;`run_fix` 不再做这件事——它本身就是 fix。
36+
37+
### subprocess 错误码统一
38+
39+
- 成功:`exit_code == 0`
40+
- 超时:`exit_code == 124`,`stderr_tail == "timeout after {timeout}s"`
41+
- 命令缺失:`exit_code == 127`,`stderr_tail == "command not found: ..."`
42+
43+
`run_fix` 在命令缺失时仍把 lang 加入失败列表(与现有 fix.py 行为一致)。
44+
45+
### CLI 文件极简化
46+
47+
`run_check.py` 与 `fix.py` 各自只留:argparse 解析、读调用 `run_per_language` 函数、格式化输出(含人类可读状态行 + 汇总)、设置 sys.exit 码。两份文件均 < 100 行。
48+
49+
### 不引入并发 / 不改调用约定
50+
51+
`run_per_language` 保持串行——并发是 MCP/批量场景的未来 change,不在本 change 范围内。`LANG_COMMANDS` 解包与 `subprocess.run` 调用细节都封装在 runner 内,CLI 只见 dict 结果。
52+
53+
## Risks / Trade-offs
54+
55+
- **fix.py 现有 `--dry-run` 行为**:CLI 层接受 `--dry-run` 时透传给 runner,runner 据此切换打印路径。`fix.py` 当前无 `--dry-run`(注释「只看,不真改」与 argparse `--dry-run` 是同一选项),保持兼容。
56+
- **新模块导入路径**:`run_check.py` 与 `fix.py` 现都 `sys.path.insert(0, scripts/)`;新模块位于同目录,零额外配置。`run_per_language` 也走相同 import 路径。
57+
- **MCP 入口**:`run_check.py::mcp_main` 仍是占位,change 不动它;未来 MCP 落地直接调 `run_per_language` 即可。
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## Why
2+
3+
`scripts/run_check.py` 与 `scripts/fix.py` 重复编排同一逻辑:枚举 `detect_languages` → 取 `LANG_COMMANDS[lang].lint/format` → `subprocess.run` → 收集结果。两份脚本共 ~198 行,编排代码占约 80%,只有 argparse 与报告格式不同。任何 lint/format 调用约定变更都要两处同步改;任何新加 per-language 行为(pre-flight probe、retries、dry-run)也要改两处。
4+
5+
## What Changes
6+
7+
- 新增 `scripts/run_per_language.py`:暴露 `run_check(languages, project_root, *, timeout, fix=False, dry_run=False) -> list[dict]` 与 `run_fix(languages, project_root, *, timeout, dry_run=False) -> list[dict]` 两个纯函数,封装「per-language 串行执行 + 结果聚合」逻辑。
8+
- `scripts/run_check.py` 仅保留 CLI 入口(argparse + 报告输出),调用 `run_per_language.run_check`;删除 `check_one` / `fix_one` / `run` 局部实现。
9+
- `scripts/fix.py` 仅保留 CLI 入口,调用 `run_per_language.run_fix`;删除内联 subprocess 与 LANG_COMMANDS 解包。
10+
- 不引入新依赖;不改变 CLI 行为(输出格式、`--fix` 语义、退出码 0/2)。
11+
12+
## Capabilities
13+
14+
### New Capabilities
15+
16+
- `idiomatic-runner`: Defines the per-language execution runner shared between `run_check.py` and `fix.py`.
17+
18+
### Modified Capabilities
19+
20+
None.
21+
22+
## Impact
23+
24+
新增 1 个 Python 文件 `scripts/run_per_language.py`(预计 ~120 行);修改 `scripts/run_check.py`(128 → ~80 行)与 `scripts/fix.py`(70 → ~40 行);合计 ~110 行净增(注释与日志占大头)。`commands/`、`hooks/`、`tests/`、`skills/`、`openspec/specs/` 全部不变;`scripts/languages.json` 与 `skills.lock.json` 不变。
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Per-language execution SHALL live in scripts/run_per_language.py
4+
5+
The functions `run_check(languages, project_root, *, timeout=120, fix=False, dry_run=False) -> list[dict]` and `run_fix(languages, project_root, *, timeout=120, dry_run=False) -> list[dict]` SHALL be defined in `scripts/run_per_language.py`. They SHALL encapsulate the per-language subprocess invocation, exit-code normalization, and result aggregation.
6+
7+
#### Scenario: A CLI script delegates to run_per_language
8+
9+
- **WHEN** `scripts/run_check.py` parses args, identifies the project's languages, and needs to execute the lint pass
10+
- **THEN** it calls `run_per_language.run_check(languages, project_root, timeout=args.timeout, fix=args.fix)` and prints the results
11+
12+
#### Scenario: fix.py delegates the format pass
13+
14+
- **WHEN** `scripts/fix.py` is invoked with a project root and detected languages
15+
- **THEN** it calls `run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and prints the results
16+
17+
### Requirement: run_check.py and fix.py SHALL NOT define subprocess-level runners themselves
18+
19+
After the refactor, `scripts/run_check.py` SHALL NOT define its own `run` / `check_one` / `fix_one` functions or invoke `subprocess.run` directly. Same for `scripts/fix.py`. Any subprocess execution lives in `run_per_language.py`.
20+
21+
#### Scenario: A new CLI flag is added to run_check.py
22+
23+
- **WHEN** a new flag (e.g. `--json`) requires reusing the lint pass
24+
- **THEN** the implementer calls `run_per_language.run_check(...)` and converts the list of dicts to JSON, without re-implementing the subprocess loop
25+
26+
### Requirement: subprocess exit codes SHALL be normalized
27+
28+
`run_per_language` SHALL map subprocess outcomes to:
29+
- 0 for success
30+
- 124 for `subprocess.TimeoutExpired`
31+
- 127 for `FileNotFoundError`
32+
33+
When a command is not found, the result SHALL include `stderr_tail == "command not found: <cmd>"`.
34+
35+
#### Scenario: linter binary is missing
36+
37+
- **WHEN** the project's linter binary is not installed
38+
- **THEN** `run_per_language.run_check` returns a dict with `exit_code == 127` and `passed == False`
39+
40+
### Requirement: dry_run SHALL only print would-run lines
41+
42+
When `dry_run=True`, neither `run_check` nor `run_fix` SHALL spawn a subprocess; they SHALL emit one line per language showing the command that would have been executed.
43+
44+
#### Scenario: fix.py invoked with --dry-run on a mixed-language project
45+
46+
- **WHEN** the user passes `--dry-run` to `scripts/fix.py`
47+
- **THEN** the output contains one `would run: ...` line per detected language and no subprocess is launched
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
## 1. New file
2+
3+
- [ ] 1.1 Create `scripts/run_per_language.py` with `run_check` and `run_fix` per the design; export `__all__` listing them.
4+
5+
## 2. run_check.py slim down
6+
7+
- [ ] 2.1 Remove local `run` / `check_one` / `fix_one` from `scripts/run_check.py`.
8+
- [ ] 2.2 Replace the per-language loop body in `cli_main()` with `results = run_per_language.run_check(languages, project_root, timeout=args.timeout, fix=args.fix)`.
9+
- [ ] 2.3 Keep the argparse, the `mcp_main` placeholder, and the human-readable summary lines untouched.
10+
11+
## 3. fix.py slim down
12+
13+
- [ ] 3.1 Remove the in-line subprocess loop from `scripts/fix.py`.
14+
- [ ] 3.2 Replace it with `results = run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and the existing summary lines.
15+
16+
## 4. Validation
17+
18+
- [ ] 4.1 `python3 -m unittest discover -s tests -p 'test_*.py' -v` — both `test_plugin_manifests.py` and `test_skill_vendor.py` must pass.
19+
- [ ] 4.2 `python3 tests/run_all.py` — full 141 / 0 / 0 must hold (hooks/manifests/vendored skills).
20+
- [ ] 4.3 Smoke `python3 scripts/run_check.py --help` and `python3 scripts/fix.py --help` — exit 0, argparse output unchanged from `v0.6.1`.
21+
- [ ] 4.4 Smoke `python3 scripts/fix.py --dry-run .` on this repo — each detected language prints `would run: ...` and no subprocess is launched.
22+
- [ ] 4.5 `ruff check scripts/run_per_language.py scripts/run_check.py scripts/fix.py` — zero new errors attributable to this change.
23+
- [ ] 4.6 `git diff --check` — clean.
24+
- [ ] 4.7 `openspec validate --all --strict` — pass (3 pre-existing + new `idiomatic-runner`).
25+
26+
## 5. Release
27+
28+
- [ ] 5.1 `node scripts/bump-plugin.mjs codeguard patch` — bump 0.6.1 → 0.6.2.
29+
- [ ] 5.2 Fix in-repo `.agents/plugins/marketplace.json` ref/icon to `v0.6.2`.
30+
- [ ] 5.3 Open PR, wait for vendor-check, merge.
31+
- [ ] 5.4 Push tag `v0.6.2`.
32+
- [ ] 5.5 Sync market repo catalog + README codeguard row to `0.6.2` and push.
33+
- [ ] 5.6 `openspec archive add-run-per-language` after all tasks above are checked.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# idiomatic-runner Specification
2+
3+
## Purpose
4+
TBD - created by archiving change add-run-per-language. Update Purpose after archive.
5+
## Requirements
6+
### Requirement: Per-language execution SHALL live in scripts/run_per_language.py
7+
8+
The functions `run_check(languages, project_root, *, timeout=120, fix=False, dry_run=False) -> list[dict]` and `run_fix(languages, project_root, *, timeout=120, dry_run=False) -> list[dict]` SHALL be defined in `scripts/run_per_language.py`. They SHALL encapsulate the per-language subprocess invocation, exit-code normalization, and result aggregation.
9+
10+
#### Scenario: A CLI script delegates to run_per_language
11+
12+
- **WHEN** `scripts/run_check.py` parses args, identifies the project's languages, and needs to execute the lint pass
13+
- **THEN** it calls `run_per_language.run_check(languages, project_root, timeout=args.timeout, fix=args.fix)` and prints the results
14+
15+
#### Scenario: fix.py delegates the format pass
16+
17+
- **WHEN** `scripts/fix.py` is invoked with a project root and detected languages
18+
- **THEN** it calls `run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and prints the results
19+
20+
### Requirement: run_check.py and fix.py SHALL NOT define subprocess-level runners themselves
21+
22+
After the refactor, `scripts/run_check.py` SHALL NOT define its own `run` / `check_one` / `fix_one` functions or invoke `subprocess.run` directly. Same for `scripts/fix.py`. Any subprocess execution lives in `run_per_language.py`.
23+
24+
#### Scenario: A new CLI flag is added to run_check.py
25+
26+
- **WHEN** a new flag (e.g. `--json`) requires reusing the lint pass
27+
- **THEN** the implementer calls `run_per_language.run_check(...)` and converts the list of dicts to JSON, without re-implementing the subprocess loop
28+
29+
### Requirement: subprocess exit codes SHALL be normalized
30+
31+
`run_per_language` SHALL map subprocess outcomes to:
32+
- 0 for success
33+
- 124 for `subprocess.TimeoutExpired`
34+
- 127 for `FileNotFoundError`
35+
36+
When a command is not found, the result SHALL include `stderr_tail == "command not found: <cmd>"`.
37+
38+
#### Scenario: linter binary is missing
39+
40+
- **WHEN** the project's linter binary is not installed
41+
- **THEN** `run_per_language.run_check` returns a dict with `exit_code == 127` and `passed == False`
42+
43+
### Requirement: dry_run SHALL only print would-run lines
44+
45+
When `dry_run=True`, neither `run_check` nor `run_fix` SHALL spawn a subprocess; they SHALL emit one line per language showing the command that would have been executed.
46+
47+
#### Scenario: fix.py invoked with --dry-run on a mixed-language project
48+
49+
- **WHEN** the user passes `--dry-run` to `scripts/fix.py`
50+
- **THEN** the output contains one `would run: ...` line per detected language and no subprocess is launched
51+

‎scripts/fix.py‎

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@
33
CLI:
44
python3 fix.py # 自动检测并修复所有语言
55
python3 fix.py --lang java # 只修 java
6+
python3 fix.py --dry-run # 只看,不真改
7+
8+
按语言执行细节委托给 `scripts/run_per_language.py`;本文件仅负责
9+
argparse、目标语言过滤、报告输出。
610
"""
711
from __future__ import annotations
812

913
import argparse
10-
import subprocess
1114
import sys
1215
from pathlib import Path
1316

1417
sys.path.insert(0, str(Path(__file__).resolve().parent))
18+
import run_per_language
1519
from detect_lang import (
16-
LANG_COMMANDS,
1720
detect_languages,
1821
find_project_root,
1922
)
@@ -39,32 +42,26 @@ def main() -> int:
3942
print(f"[codeguard] languages: {languages} dry-run={args.dry_run}")
4043
print()
4144

42-
failed = []
43-
for lang in languages:
44-
cmd_def = LANG_COMMANDS.get(lang)
45-
if not cmd_def:
46-
continue
47-
if args.dry_run:
48-
print(f" {lang:12s} would run: {' '.join(cmd_def['format'])}")
49-
continue
50-
try:
51-
proc = subprocess.run(
52-
cmd_def["format"], cwd=project_root, capture_output=True, text=True, timeout=180
53-
)
54-
except FileNotFoundError as e:
55-
print(f" {lang:12s} \u274c command not found: {e}")
56-
failed.append(lang)
45+
results = run_per_language.run_fix(
46+
languages, project_root, dry_run=args.dry_run,
47+
)
48+
failed: list[str] = []
49+
for r, lang in zip(results, languages):
50+
if r.get("dry_run"):
51+
print(f" {lang:12s} would run: {' '.join(r.get('command') or [])}")
5752
continue
58-
if proc.returncode == 0:
53+
if r.get("fixed"):
5954
print(f" {lang:12s} \u2705 fixed")
6055
else:
61-
print(f" {lang:12s} \u274c failed: exit={proc.returncode}")
62-
if proc.stderr:
63-
print(f" \u2514\u2500 {proc.stderr.strip().splitlines()[-1][:120]}")
56+
print(f" {lang:12s} \u274c failed: exit={r.get('exit_code')}")
57+
if r.get("stderr_tail"):
58+
last = r["stderr_tail"].strip().splitlines()
59+
tail = last[-1] if last else ""
60+
print(f" \u2514\u2500 {tail[:120]}")
6461
failed.append(lang)
6562

6663
return 1 if failed else 0
6764

6865

6966
if __name__ == "__main__":
70-
sys.exit(main())
67+
sys.exit(main())

‎scripts/run_check.py‎

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,60 +5,25 @@
55
python3 run_check.py --lang java # 只跑 java
66
python3 run_check.py --fix # 失败时自动修复
77
python3 run_check.py --mcp # MCP server 模式
8+
9+
按语言执行细节(subprocess 调用、退出码归一化、fix 复检)全部委托给
10+
`scripts/run_per_language.py`;本文件仅负责 argparse + 报告输出。
811
"""
912
from __future__ import annotations
1013

1114
import argparse
12-
import subprocess
1315
import sys
1416
from pathlib import Path
1517

1618
sys.path.insert(0, str(Path(__file__).resolve().parent))
19+
import run_per_language
1720
from detect_lang import (
18-
LANG_COMMANDS,
1921
detect_languages,
2022
find_project_root,
2123
load_user_config,
2224
)
2325

2426

25-
def run(cmd: list[str], cwd: Path, timeout: int = 120) -> tuple[int, str, str]:
26-
try:
27-
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
28-
return proc.returncode, proc.stdout, proc.stderr
29-
except subprocess.TimeoutExpired:
30-
return 124, "", f"timeout after {timeout}s"
31-
except FileNotFoundError as e:
32-
return 127, "", f"command not found: {e}"
33-
34-
35-
def check_one(lang: str, project_root: Path, timeout: int) -> dict:
36-
cmd_def = LANG_COMMANDS.get(lang)
37-
if not cmd_def:
38-
return {"language": lang, "passed": False, "error": "no command defined"}
39-
rc, out, err = run(cmd_def["lint"], cwd=project_root, timeout=timeout)
40-
return {
41-
"language": lang,
42-
"passed": rc == 0,
43-
"exit_code": rc,
44-
"stderr_tail": err[-2000:] if err else "",
45-
"stdout_tail": out[-1000:] if out else "",
46-
}
47-
48-
49-
def fix_one(lang: str, project_root: Path, timeout: int) -> dict:
50-
cmd_def = LANG_COMMANDS.get(lang)
51-
if not cmd_def:
52-
return {"language": lang, "fixed": False, "error": "no command defined"}
53-
rc, out, err = run(cmd_def["format"], cwd=project_root, timeout=timeout + 60)
54-
return {
55-
"language": lang,
56-
"fixed": rc == 0,
57-
"exit_code": rc,
58-
"stderr_tail": err[-2000:] if err else "",
59-
}
60-
61-
6227
def cli_main():
6328
parser = argparse.ArgumentParser()
6429
parser.add_argument("--lang", help="只跑指定语言(逗号分隔)")
@@ -89,15 +54,15 @@ def cli_main():
8954
print(f"[codeguard] languages: {languages}")
9055
print()
9156

92-
results = []
93-
for lang in languages:
94-
r = check_one(lang, project_root, args.timeout)
57+
results = run_per_language.run_check(
58+
languages, project_root,
59+
timeout=args.timeout, fix=args.fix,
60+
)
61+
for r, lang in zip(results, languages):
62+
if r.get("dry_run"):
63+
continue
9564
if not r["passed"] and args.fix:
9665
print(f"[codeguard] \u26a0\ufe0f {lang} lint failed, attempting auto-fix...")
97-
fix = fix_one(lang, project_root, args.timeout)
98-
if fix["fixed"]:
99-
r = check_one(lang, project_root, args.timeout)
100-
results.append(r)
10166
status = "\u2705 passed" if r["passed"] else f"\u274c FAILED (exit={r.get('exit_code')})"
10267
print(f" {lang:12s} {status}")
10368
if not r["passed"] and r.get("stderr_tail"):

0 commit comments

Comments
 (0)