diff --git a/openspec/changes/archive/2026-09-22-add-run-per-language/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-run-per-language/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-run-per-language/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-run-per-language/design.md b/openspec/changes/archive/2026-09-22-add-run-per-language/design.md new file mode 100644 index 0000000..1ef1ad7 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-run-per-language/design.md @@ -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: `,不真跑。`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` 即可。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-run-per-language/proposal.md b/openspec/changes/archive/2026-09-22-add-run-per-language/proposal.md new file mode 100644 index 0000000..8410aa1 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-run-per-language/proposal.md @@ -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` 不变。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-run-per-language/specs/idiomatic-runner/spec.md b/openspec/changes/archive/2026-09-22-add-run-per-language/specs/idiomatic-runner/spec.md new file mode 100644 index 0000000..faaf78a --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-run-per-language/specs/idiomatic-runner/spec.md @@ -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: "`. + +#### 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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-run-per-language/tasks.md b/openspec/changes/archive/2026-09-22-add-run-per-language/tasks.md new file mode 100644 index 0000000..2fced74 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-run-per-language/tasks.md @@ -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. \ No newline at end of file diff --git a/openspec/specs/idiomatic-runner/spec.md b/openspec/specs/idiomatic-runner/spec.md new file mode 100644 index 0000000..9745864 --- /dev/null +++ b/openspec/specs/idiomatic-runner/spec.md @@ -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: "`. + +#### 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 + diff --git a/scripts/fix.py b/scripts/fix.py index 381529b..7ea6525 100755 --- a/scripts/fix.py +++ b/scripts/fix.py @@ -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, ) @@ -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()) \ No newline at end of file diff --git a/scripts/run_check.py b/scripts/run_check.py index 6c9d9c8..281a80c 100755 --- a/scripts/run_check.py +++ b/scripts/run_check.py @@ -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="只跑指定语言(逗号分隔)") @@ -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"): diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py new file mode 100644 index 0000000..4c1c472 --- /dev/null +++ b/scripts/run_per_language.py @@ -0,0 +1,87 @@ +"""scripts/run_per_language.py:按语言串行执行 + 结果聚合的共享入口。 + +暴露两个纯函数供 CLI 复用: +- `run_check(languages, project_root, *, timeout, fix, dry_run)` — 跑 lint。 +- `run_fix(languages, project_root, *, timeout, dry_run)` — 跑 format。 + +CLI(run_check.py / fix.py)只负责 argparse + 报告格式;任何 subprocess +调用约定、退出码归一化、超时与命令缺失的处置都集中在本文件。 + +`run_check(fix=True)` 走「lint 失败 → 自动 fix → 复检」三段式。 +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +from detect_lang import LANG_COMMANDS + +__all__ = ["run_check", "run_fix"] + + +def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]: + """subprocess 调用统一封装:归一化超时(124) / 命令缺失(127) / 其它返回原码。""" + try: + proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return 124, "", f"timeout after {timeout}s" + except FileNotFoundError as e: + return 127, "", f"command not found: {e}" + return proc.returncode, proc.stdout, proc.stderr + + +def run_check(languages: list[str], project_root: Path, + *, timeout: int = 120, fix: bool = False, + dry_run: bool = False) -> list[dict]: + """对每个语言跑 lint;fix=True 时失败后自动跑 format 复检一次。""" + results: list[dict] = [] + for lang in languages: + cmd_def = LANG_COMMANDS.get(lang) + if not cmd_def: + results.append({"language": lang, "passed": False, "error": "no command defined"}) + continue + lint_cmd = cmd_def.get("lint") + if dry_run or not lint_cmd: + results.append({"language": lang, "passed": True, + "dry_run": True, "command": lint_cmd or []}) + continue + rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) + passed = rc == 0 + if not passed and fix: + fmt = cmd_def.get("format") + if fmt: + _run(fmt, cwd=project_root, timeout=timeout + 60) + rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) + passed = rc == 0 + results.append({ + "language": lang, + "passed": passed, + "exit_code": rc, + "stderr_tail": err[-2000:] if err else "", + "stdout_tail": out[-1000:] if out else "", + }) + return results + + +def run_fix(languages: list[str], project_root: Path, + *, timeout: int = 120, dry_run: bool = False) -> list[dict]: + """对每个语言跑 format;命令缺失归失败;dry_run 仅打印 would-run 行。""" + results: list[dict] = [] + for lang in languages: + cmd_def = LANG_COMMANDS.get(lang) + if not cmd_def: + results.append({"language": lang, "fixed": False, "error": "no command defined"}) + continue + fmt = cmd_def.get("format") + if dry_run or not fmt: + results.append({"language": lang, "fixed": True, + "dry_run": True, "command": fmt or []}) + continue + rc, out, err = _run(fmt, cwd=project_root, timeout=timeout) + results.append({ + "language": lang, + "fixed": rc == 0, + "exit_code": rc, + "stderr_tail": err[-2000:] if err else "", + }) + return results \ No newline at end of file