Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-22
57 changes: 57 additions & 0 deletions openspec/changes/archive/2026-09-22-add-run-per-language/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
## Context

代码图谱与全文阅读确认:
- `scripts/run_check.py` 内含 `run()`(subprocess 封装)/ `check_one()`(lint 调用)/ `fix_one()`(format 调用)/ `cli_main()`(CLI 编排 + 报告)。
- `scripts/fix.py` 内含 `main()`(detect_languages 解析 + LANG_COMMANDS 解包 + subprocess.run 串行 + 失败聚合)。
- 两脚本对「失败 = exit code != 0」与「command not found」的处置逻辑基本相同但**分散**:run_check 用 `run()` 集中捕获 `FileNotFoundError → return 127`,fix.py 用 try/except 捕获 `FileNotFoundError → continue + failed.append(lang)`。

重复的代价:
- 同一 lint/format 调用约定的所有演化(subprocess timeout、retry、probe、dry-run、节流)必须双改。
- MCP 模式占位(`run_check.py::mcp_main`)与 `fix.py` 互相不可见但语义重叠。
- 未来要支持 `--json` 输出格式、并发执行、retries 时三处同步。

## Decisions

### 共享边界 = 「per-language 串行执行 + 结果聚合」

`scripts/run_per_language.py` 暴露两个对外函数:

```python
def run_check(languages: list[str], project_root: Path,
*, timeout: int = 120, fix: bool = False,
dry_run: bool = False) -> list[dict]: ...
def run_fix(languages: list[str], project_root: Path,
*, timeout: int = 120, dry_run: bool = False) -> list[dict]: ...
```

返回 dict 与现有 `check_one` / `fix_one` 形态兼容(`passed` / `fixed` / `exit_code` / `stderr_tail` / `stdout_tail`),CLI 层零报告格式改动。

### `dry_run` 显式契约

`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 内部契约。

### fix=True 走 run_check 内部的 retry

`run_check.py` 当前的「lint fail → 自动 fix → 复检」三段式保留在 `run_per_language.run_check` 内部,作为其 fix 参数的实现细节;`run_fix` 不再做这件事——它本身就是 fix。

### subprocess 错误码统一

- 成功:`exit_code == 0`
- 超时:`exit_code == 124`,`stderr_tail == "timeout after {timeout}s"`
- 命令缺失:`exit_code == 127`,`stderr_tail == "command not found: ..."`

`run_fix` 在命令缺失时仍把 lang 加入失败列表(与现有 fix.py 行为一致)。

### CLI 文件极简化

`run_check.py` 与 `fix.py` 各自只留:argparse 解析、读调用 `run_per_language` 函数、格式化输出(含人类可读状态行 + 汇总)、设置 sys.exit 码。两份文件均 < 100 行。

### 不引入并发 / 不改调用约定

`run_per_language` 保持串行——并发是 MCP/批量场景的未来 change,不在本 change 范围内。`LANG_COMMANDS` 解包与 `subprocess.run` 调用细节都封装在 runner 内,CLI 只见 dict 结果。

## Risks / Trade-offs

- **fix.py 现有 `--dry-run` 行为**:CLI 层接受 `--dry-run` 时透传给 runner,runner 据此切换打印路径。`fix.py` 当前无 `--dry-run`(注释「只看,不真改」与 argparse `--dry-run` 是同一选项),保持兼容。
- **新模块导入路径**:`run_check.py` 与 `fix.py` 现都 `sys.path.insert(0, scripts/)`;新模块位于同目录,零额外配置。`run_per_language` 也走相同 import 路径。
- **MCP 入口**:`run_check.py::mcp_main` 仍是占位,change 不动它;未来 MCP 落地直接调 `run_per_language` 即可。
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

`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)也要改两处。

## What Changes

- 新增 `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 串行执行 + 结果聚合」逻辑。
- `scripts/run_check.py` 仅保留 CLI 入口(argparse + 报告输出),调用 `run_per_language.run_check`;删除 `check_one` / `fix_one` / `run` 局部实现。
- `scripts/fix.py` 仅保留 CLI 入口,调用 `run_per_language.run_fix`;删除内联 subprocess 与 LANG_COMMANDS 解包。
- 不引入新依赖;不改变 CLI 行为(输出格式、`--fix` 语义、退出码 0/2)。

## Capabilities

### New Capabilities

- `idiomatic-runner`: Defines the per-language execution runner shared between `run_check.py` and `fix.py`.

### Modified Capabilities

None.

## Impact

新增 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` 不变。
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
## ADDED Requirements

### Requirement: Per-language execution SHALL live in scripts/run_per_language.py

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.

#### Scenario: A CLI script delegates to run_per_language

- **WHEN** `scripts/run_check.py` parses args, identifies the project's languages, and needs to execute the lint pass
- **THEN** it calls `run_per_language.run_check(languages, project_root, timeout=args.timeout, fix=args.fix)` and prints the results

#### Scenario: fix.py delegates the format pass

- **WHEN** `scripts/fix.py` is invoked with a project root and detected languages
- **THEN** it calls `run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and prints the results

### Requirement: run_check.py and fix.py SHALL NOT define subprocess-level runners themselves

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`.

#### Scenario: A new CLI flag is added to run_check.py

- **WHEN** a new flag (e.g. `--json`) requires reusing the lint pass
- **THEN** the implementer calls `run_per_language.run_check(...)` and converts the list of dicts to JSON, without re-implementing the subprocess loop

### Requirement: subprocess exit codes SHALL be normalized

`run_per_language` SHALL map subprocess outcomes to:
- 0 for success
- 124 for `subprocess.TimeoutExpired`
- 127 for `FileNotFoundError`

When a command is not found, the result SHALL include `stderr_tail == "command not found: <cmd>"`.

#### Scenario: linter binary is missing

- **WHEN** the project's linter binary is not installed
- **THEN** `run_per_language.run_check` returns a dict with `exit_code == 127` and `passed == False`

### Requirement: dry_run SHALL only print would-run lines

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.

#### Scenario: fix.py invoked with --dry-run on a mixed-language project

- **WHEN** the user passes `--dry-run` to `scripts/fix.py`
- **THEN** the output contains one `would run: ...` line per detected language and no subprocess is launched
33 changes: 33 additions & 0 deletions openspec/changes/archive/2026-09-22-add-run-per-language/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## 1. New file

- [ ] 1.1 Create `scripts/run_per_language.py` with `run_check` and `run_fix` per the design; export `__all__` listing them.

## 2. run_check.py slim down

- [ ] 2.1 Remove local `run` / `check_one` / `fix_one` from `scripts/run_check.py`.
- [ ] 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)`.
- [ ] 2.3 Keep the argparse, the `mcp_main` placeholder, and the human-readable summary lines untouched.

## 3. fix.py slim down

- [ ] 3.1 Remove the in-line subprocess loop from `scripts/fix.py`.
- [ ] 3.2 Replace it with `results = run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and the existing summary lines.

## 4. Validation

- [ ] 4.1 `python3 -m unittest discover -s tests -p 'test_*.py' -v` — both `test_plugin_manifests.py` and `test_skill_vendor.py` must pass.
- [ ] 4.2 `python3 tests/run_all.py` — full 141 / 0 / 0 must hold (hooks/manifests/vendored skills).
- [ ] 4.3 Smoke `python3 scripts/run_check.py --help` and `python3 scripts/fix.py --help` — exit 0, argparse output unchanged from `v0.6.1`.
- [ ] 4.4 Smoke `python3 scripts/fix.py --dry-run .` on this repo — each detected language prints `would run: ...` and no subprocess is launched.
- [ ] 4.5 `ruff check scripts/run_per_language.py scripts/run_check.py scripts/fix.py` — zero new errors attributable to this change.
- [ ] 4.6 `git diff --check` — clean.
- [ ] 4.7 `openspec validate --all --strict` — pass (3 pre-existing + new `idiomatic-runner`).

## 5. Release

- [ ] 5.1 `node scripts/bump-plugin.mjs codeguard patch` — bump 0.6.1 → 0.6.2.
- [ ] 5.2 Fix in-repo `.agents/plugins/marketplace.json` ref/icon to `v0.6.2`.
- [ ] 5.3 Open PR, wait for vendor-check, merge.
- [ ] 5.4 Push tag `v0.6.2`.
- [ ] 5.5 Sync market repo catalog + README codeguard row to `0.6.2` and push.
- [ ] 5.6 `openspec archive add-run-per-language` after all tasks above are checked.
51 changes: 51 additions & 0 deletions openspec/specs/idiomatic-runner/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# idiomatic-runner Specification

## Purpose
TBD - created by archiving change add-run-per-language. Update Purpose after archive.
## Requirements
### Requirement: Per-language execution SHALL live in scripts/run_per_language.py

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.

#### Scenario: A CLI script delegates to run_per_language

- **WHEN** `scripts/run_check.py` parses args, identifies the project's languages, and needs to execute the lint pass
- **THEN** it calls `run_per_language.run_check(languages, project_root, timeout=args.timeout, fix=args.fix)` and prints the results

#### Scenario: fix.py delegates the format pass

- **WHEN** `scripts/fix.py` is invoked with a project root and detected languages
- **THEN** it calls `run_per_language.run_fix(languages, project_root, dry_run=args.dry_run)` and prints the results

### Requirement: run_check.py and fix.py SHALL NOT define subprocess-level runners themselves

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`.

#### Scenario: A new CLI flag is added to run_check.py

- **WHEN** a new flag (e.g. `--json`) requires reusing the lint pass
- **THEN** the implementer calls `run_per_language.run_check(...)` and converts the list of dicts to JSON, without re-implementing the subprocess loop

### Requirement: subprocess exit codes SHALL be normalized

`run_per_language` SHALL map subprocess outcomes to:
- 0 for success
- 124 for `subprocess.TimeoutExpired`
- 127 for `FileNotFoundError`

When a command is not found, the result SHALL include `stderr_tail == "command not found: <cmd>"`.

#### Scenario: linter binary is missing

- **WHEN** the project's linter binary is not installed
- **THEN** `run_per_language.run_check` returns a dict with `exit_code == 127` and `passed == False`

### Requirement: dry_run SHALL only print would-run lines

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.

#### Scenario: fix.py invoked with --dry-run on a mixed-language project

- **WHEN** the user passes `--dry-run` to `scripts/fix.py`
- **THEN** the output contains one `would run: ...` line per detected language and no subprocess is launched

41 changes: 19 additions & 22 deletions scripts/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@
CLI:
python3 fix.py # 自动检测并修复所有语言
python3 fix.py --lang java # 只修 java
python3 fix.py --dry-run # 只看,不真改

按语言执行细节委托给 `scripts/run_per_language.py`;本文件仅负责
argparse、目标语言过滤、报告输出。
"""
from __future__ import annotations

import argparse
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import run_per_language
from detect_lang import (
LANG_COMMANDS,
detect_languages,
find_project_root,
)
Expand All @@ -39,32 +42,26 @@ def main() -> int:
print(f"[codeguard] languages: {languages} dry-run={args.dry_run}")
print()

failed = []
for lang in languages:
cmd_def = LANG_COMMANDS.get(lang)
if not cmd_def:
continue
if args.dry_run:
print(f" {lang:12s} would run: {' '.join(cmd_def['format'])}")
continue
try:
proc = subprocess.run(
cmd_def["format"], cwd=project_root, capture_output=True, text=True, timeout=180
)
except FileNotFoundError as e:
print(f" {lang:12s} \u274c command not found: {e}")
failed.append(lang)
results = run_per_language.run_fix(
languages, project_root, dry_run=args.dry_run,
)
failed: list[str] = []
for r, lang in zip(results, languages):
if r.get("dry_run"):
print(f" {lang:12s} would run: {' '.join(r.get('command') or [])}")
continue
if proc.returncode == 0:
if r.get("fixed"):
print(f" {lang:12s} \u2705 fixed")
else:
print(f" {lang:12s} \u274c failed: exit={proc.returncode}")
if proc.stderr:
print(f" \u2514\u2500 {proc.stderr.strip().splitlines()[-1][:120]}")
print(f" {lang:12s} \u274c failed: exit={r.get('exit_code')}")
if r.get("stderr_tail"):
last = r["stderr_tail"].strip().splitlines()
tail = last[-1] if last else ""
print(f" \u2514\u2500 {tail[:120]}")
failed.append(lang)

return 1 if failed else 0


if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
57 changes: 11 additions & 46 deletions scripts/run_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,25 @@
python3 run_check.py --lang java # 只跑 java
python3 run_check.py --fix # 失败时自动修复
python3 run_check.py --mcp # MCP server 模式

按语言执行细节(subprocess 调用、退出码归一化、fix 复检)全部委托给
`scripts/run_per_language.py`;本文件仅负责 argparse + 报告输出。
"""
from __future__ import annotations

import argparse
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import run_per_language
from detect_lang import (
LANG_COMMANDS,
detect_languages,
find_project_root,
load_user_config,
)


def run(cmd: list[str], cwd: Path, timeout: int = 120) -> tuple[int, str, str]:
try:
proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
return proc.returncode, proc.stdout, proc.stderr
except subprocess.TimeoutExpired:
return 124, "", f"timeout after {timeout}s"
except FileNotFoundError as e:
return 127, "", f"command not found: {e}"


def check_one(lang: str, project_root: Path, timeout: int) -> dict:
cmd_def = LANG_COMMANDS.get(lang)
if not cmd_def:
return {"language": lang, "passed": False, "error": "no command defined"}
rc, out, err = run(cmd_def["lint"], cwd=project_root, timeout=timeout)
return {
"language": lang,
"passed": rc == 0,
"exit_code": rc,
"stderr_tail": err[-2000:] if err else "",
"stdout_tail": out[-1000:] if out else "",
}


def fix_one(lang: str, project_root: Path, timeout: int) -> dict:
cmd_def = LANG_COMMANDS.get(lang)
if not cmd_def:
return {"language": lang, "fixed": False, "error": "no command defined"}
rc, out, err = run(cmd_def["format"], cwd=project_root, timeout=timeout + 60)
return {
"language": lang,
"fixed": rc == 0,
"exit_code": rc,
"stderr_tail": err[-2000:] if err else "",
}


def cli_main():
parser = argparse.ArgumentParser()
parser.add_argument("--lang", help="只跑指定语言(逗号分隔)")
Expand Down Expand Up @@ -89,15 +54,15 @@ def cli_main():
print(f"[codeguard] languages: {languages}")
print()

results = []
for lang in languages:
r = check_one(lang, project_root, args.timeout)
results = run_per_language.run_check(
languages, project_root,
timeout=args.timeout, fix=args.fix,
)
for r, lang in zip(results, languages):
if r.get("dry_run"):
continue
if not r["passed"] and args.fix:
print(f"[codeguard] \u26a0\ufe0f {lang} lint failed, attempting auto-fix...")
fix = fix_one(lang, project_root, args.timeout)
if fix["fixed"]:
r = check_one(lang, project_root, args.timeout)
results.append(r)
status = "\u2705 passed" if r["passed"] else f"\u274c FAILED (exit={r.get('exit_code')})"
print(f" {lang:12s} {status}")
if not r["passed"] and r.get("stderr_tail"):
Expand Down
Loading
Loading