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
22 changes: 12 additions & 10 deletions hooks/env_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,14 @@

from detect_lang import (
LANG_COMMANDS,
REGISTRY,
detect_languages,
ensure_user_path,
find_project_root,
load_user_config,
probe_toolchain,
)

LINTER_CONFIG_FILES = [
("java", [".pre-commit-config.yaml", "checkstyle.xml", "pmd.xml"]),
("rust", [".pre-commit-config.yaml", "clippy.toml", ".clippy.toml"]),
("typescript", [".pre-commit-config.yaml", "eslint.config.js", ".eslintrc.json", ".eslintrc.js"]),
("python", [".pre-commit-config.yaml", "ruff.toml", ".ruff.toml", "pyproject.toml"]),
]


def notify(title: str, message: str) -> None:
"""macOS 系统通知(非 darwin 静默)"""
Expand All @@ -49,12 +43,20 @@ def notify(title: str, message: str) -> None:


def detect_linter_config(project_root: Path) -> dict:
"""返回 {language: [存在的 linter 配置文件]}"""
"""返回 {language: [存在的 linter 配置文件]},由 languages.json 驱动。

每条 languages.json 条目的 `linter_config_files` 字段声明该语言的 linter
配置文件路径(glob 模式允许,与 requiresConfig 同语义)。新增语言只需
在注册表里填字段,env_check 自动盘点——不再有硬编码常量的同步陷阱。
"""
found = {}
for lang, files in LINTER_CONFIG_FILES:
for lang_id, lang_def in REGISTRY.items():
files = lang_def.get("linter_config_files") or []
if not files:
continue
present = [f for f in files if (project_root / f).exists()]
if present:
found[lang] = present
found[lang_id] = present
return found


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-22
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
## Context

`env_check.py` 当前的实现:

```python
LINTER_CONFIG_FILES = [
("java", [".pre-commit-config.yaml", "checkstyle.xml", "pmd.xml"]),
("rust", [".pre-commit-config.yaml", "clippy.toml", ".clippy.toml"]),
("typescript", [".pre-commit-config.yaml", "eslint.config.js", ".eslintrc.json", ".eslintrc.js"]),
("python", [".pre-commit-config.yaml", "ruff.toml", ".ruff.toml", "pyproject.toml"]),
]
```

下游 `detect_linter_config(project_root)` 走这个硬编码 dict 给 4 种语言加白名单,对其他 50+ stable/beta 语言完全不报告「已配置的 linter」。

更糟的是 `requiresConfig` 已经在 `scripts/languages.json` 里存在,**概念重叠**——`requiresConfig` 是 gate 命令需要的「项目级 linter 配置」标记,用于 gate 命令运行前判定「项目是否真的接入了这个 linter」。`linter_config_files` 是 SessionStart 钩子用来「向 AI 报告:项目里已经存在这些 linter 配置文件」。

为避免混淆,**本 change 不合并 requiresConfig**——只是 env_check 改读 registry 的 `linter_config_files` 新字段。requiresConfig 保持原样。

## Decisions

### languages.json 加字段(向后兼容)

每条语言加 `"linter_config_files": []`(默认空)。**已迁移条目**(java/rust/typescript/python)从现有 `LINTER_CONFIG_FILES` 复制对应数组;**未迁移条目**保持 `[]` 不报错——但 env_check 会跳过。

迁移后 4 个条目:

```json
{
"id": "java", ..., "linter_config_files": [".pre-commit-config.yaml", "checkstyle.xml", "pmd.xml"]
},
{
"id": "rust", ..., "linter_config_files": [".pre-commit-config.yaml", "clippy.toml", ".clippy.toml"]
},
{
"id": "typescript", ..., "linter_config_files": [".pre-commit-config.yaml", "eslint.config.js", ".eslintrc.json", ".eslintrc.js"]
},
{
"id": "python", ..., "linter_config_files": [".pre-commit-config.yaml", "ruff.toml", ".ruff.toml", "pyproject.toml"]
}
```

### env_check 从 registry 派生

替换 `LINTER_CONFIG_FILES` 常量与 `detect_linter_config` body:

```python
def detect_linter_config(project_root: Path) -> dict:
"""返回 {language: [存在的 linter 配置文件]},由 languages.json 驱动。"""
from detect_lang import REGISTRY # 延迟 import:env_check 是钩子入口,避免循环
found = {}
for lang_id, lang_def in REGISTRY.items():
files = lang_def.get("linter_config_files") or []
present = [f for f in files if (project_root / f).exists()]
if present:
found[lang_id] = present
return found
```

注意:env_check 已经 `from detect_lang import ...`,但 `REGISTRY` 没在导入名单——加 `REGISTRY` 到 import。

### 校验器新增规则

`scripts/validate_languages_json.py` 加一条规则到 `_check_*` 列表:

```python
def _check_linter_config_files(reg: dict) -> list[str]:
errs = []
for lang in reg.get("languages", []):
if not isinstance(lang, dict):
continue
lid = lang.get("id", "<?>")
cf = lang.get("linter_config_files")
if cf is None:
continue
if not isinstance(cf, list) or not all(isinstance(s, str) for s in cf):
errs.append(f"{lid}:linter_config_files: must be list of strings")
return errs
```

`check()` 链追加:`errs += _check_linter_config_files(registry)`。

### 测试新增

`tests/test_validate_languages_json.py` 加 2 条:
- `test_linter_config_files_must_be_list_of_str`
- `test_linter_config_files_can_be_empty`

## Risks / Trade-offs

- **未迁移的 50+ 语言**继续在 SessionStart 钩子中不报告 linter 配置——这是历史行为,本次 change 不引入新副作用**也未修复既有缺陷**。未来哪个 change 主动补各语言 `linter_config_files` 即可。
- **类型契约变更**:languages.json 加了可选字段——下游消费方必须忽略未知字段(`detect_lang` 现状已如此)。
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Why

`hooks/env_check.py:23-30` 内置的 `LINTER_CONFIG_FILES` 是硬编码 dict,仅覆盖 java/rust/typescript/python 四种语言。MEDIUM #8 评审指出:新增语言时既要改 `languages.json`,又要同步改 `env_check.py`,否则钩子盘点会漏报已配置 linter。新增 53 种 stable/beta 语言中只有 4 种受益于此机制——其余新增语言都会撞这个对齐陷阱。

## What Changes

- `scripts/languages.json` 每条语言加新字段 `linter_config_files: list[str]`(glob 模式允许,与 `requiresConfig` 同语义),默认 `[]`。
- `hooks/env_check.py`:
- 删除 `LINTER_CONFIG_FILES` 硬编码常量;
- `detect_linter_config(project_root)` 从 `LANG_COMMANDS` 派生的元数据查询对应的 `linter_config_files`(实际通过 `REGISTRY` 直接读);
- 新增语言自动被盘点——只要它的 `linter_config_files` 非空。
- `scripts/validate_languages_json.py` 加 1 条规则:languages.json 中 `linter_config_files` 必须是 list[str](可空)。
- 不修改 `docs/LANGUAGES.md` 自动生成逻辑(`scripts/gen_language_docs.py` 仅显示 status/lint/format)。
- `tests/test_validate_languages_json.py` 加 2 条反例(类型错误、非字符串)。

## Capabilities

### New Capabilities

- `registry-driven-config`: Defines the rule that `linter_config_files` for a language is read from `scripts/languages.json` rather than hardcoded.

### Modified Capabilities

None.

## Impact

修改 1 个文件(env_check.py ~10 行);`scripts/languages.json` 加 4 个现有条目(java/rust/typescript/python)的 `linter_config_files` 值,迁移既有 `LINTER_CONFIG_FILES` 内容;新增 ~10 行校验规则与测试;spec: `registry-driven-config` (2 Requirements) 合入。
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## ADDED Requirements

### Requirement: Per-language linter config files SHALL live in scripts/languages.json

Every language entry in `scripts/languages.json` SHALL have a `linter_config_files: list[str]` field. The default empty list `[]` SHALL be accepted for languages that have no project-level linter configuration files; for languages that historically had a hardcoded list in `hooks/env_check.py`, the field SHALL be populated with the same paths.

#### Scenario: A language entry is missing linter_config_files

- **WHEN** a contributor adds a new stable language without a `linter_config_files` field
- **THEN** the validator accepts it (defaults to empty list) but `env_check` will not report linter config for that language until the field is filled

### Requirement: env_check SHALL derive linter config from registry, not a hardcoded dict

`hooks/env_check.py::detect_linter_config` SHALL NOT maintain its own `LINTER_CONFIG_FILES` constant. It SHALL iterate over the loaded language registry and read each entry's `linter_config_files` field. Adding a new language to the registry with the field filled automatically updates env_check's report without any env_check change.

#### Scenario: A new stable language is added with linter_config_files

- **WHEN** a contributor adds a new language entry to `scripts/languages.json` with a non-empty `linter_config_files`
- **THEN** `env_check` on the next run includes that language in the linter-config report without any change to `env_check.py`

#### Scenario: env_check runs against a project with only one linter configured

- **WHEN** the project's root contains `ruff.toml` but no other linter configs
- **THEN** `env_check.detect_linter_config` returns `{"python": ["ruff.toml"]}` (or equivalent from the registry) and excludes all other languages
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## 1. Registry migration

- [ ] 1.1 In `scripts/languages.json`, add `"linter_config_files": [".pre-commit-config.yaml", "checkstyle.xml", "pmd.xml"]` to the `java` entry.
- [ ] 1.2 Same for `rust`, `typescript`, `python` (copy from current `hooks/env_check.py::LINTER_CONFIG_FILES`).
- [ ] 1.3 All other language entries already default to `[]` via validation; explicitly verify no regression.

## 2. env_check refactor

- [ ] 2.1 In `hooks/env_check.py`, delete the `LINTER_CONFIG_FILES` constant (lines 23-30).
- [ ] 2.2 Add `REGISTRY` to the `from detect_lang import ...` block.
- [ ] 2.3 Rewrite `detect_linter_config` to iterate over `REGISTRY.items()` and read each entry's `linter_config_files`.
- [ ] 2.4 Verify `python3 tests/run_all.py` env_check subset passes (still 5/5 of the existing checks).

## 3. Validator rule

- [ ] 3.1 Add `_check_linter_config_files` to `scripts/validate_languages_json.py` and wire it into `check()`.
- [ ] 3.2 Add 2 unittest cases to `tests/test_validate_languages_json.py`:
- `test_linter_config_files_must_be_list_of_str`
- `test_linter_config_files_can_be_empty`

## 4. Validation

- [ ] 4.1 `python3 scripts/validate_languages_json.py` — exit 0.
- [ ] 4.2 `python3 -m unittest discover -s tests -p 'test_*.py' -v` — pass.
- [ ] 4.3 `python3 tests/run_all.py` — 141 / 0 / 0.
- [ ] 4.4 `git diff --check` — clean.
- [ ] 4.5 `openspec validate --all --strict` — pass.

## 5. Release

- [ ] 5.1 `node scripts/bump-plugin.mjs codeguard patch` — bump 0.6.4 → 0.6.5.
- [ ] 5.2 Fix in-repo `.agents/plugins/marketplace.json` ref/icon to `v0.6.5`.
- [ ] 5.3 Open PR, wait for vendor-check, merge.
- [ ] 5.4 Tag v0.6.5 (use the `--force-with-lease` workflow if origin tag v0.6.4 is stale).
- [ ] 5.5 Sync market repo catalog + README codeguard row to `0.6.5` and push.
- [ ] 5.6 `openspec archive add-linter-config-from-registry`.
28 changes: 28 additions & 0 deletions openspec/specs/registry-driven-config/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# registry-driven-config Specification

## Purpose
TBD - created by archiving change add-linter-config-from-registry. Update Purpose after archive.
## Requirements
### Requirement: Per-language linter config files SHALL live in scripts/languages.json

Every language entry in `scripts/languages.json` SHALL have a `linter_config_files: list[str]` field. The default empty list `[]` SHALL be accepted for languages that have no project-level linter configuration files; for languages that historically had a hardcoded list in `hooks/env_check.py`, the field SHALL be populated with the same paths.

#### Scenario: A language entry is missing linter_config_files

- **WHEN** a contributor adds a new stable language without a `linter_config_files` field
- **THEN** the validator accepts it (defaults to empty list) but `env_check` will not report linter config for that language until the field is filled

### Requirement: env_check SHALL derive linter config from registry, not a hardcoded dict

`hooks/env_check.py::detect_linter_config` SHALL NOT maintain its own `LINTER_CONFIG_FILES` constant. It SHALL iterate over the loaded language registry and read each entry's `linter_config_files` field. Adding a new language to the registry with the field filled automatically updates env_check's report without any env_check change.

#### Scenario: A new stable language is added with linter_config_files

- **WHEN** a contributor adds a new language entry to `scripts/languages.json` with a non-empty `linter_config_files`
- **THEN** `env_check` on the next run includes that language in the linter-config report without any change to `env_check.py`

#### Scenario: env_check runs against a project with only one linter configured

- **WHEN** the project's root contains `ruff.toml` but no other linter configs
- **THEN** `env_check.detect_linter_config` returns `{"python": ["ruff.toml"]}` (or equivalent from the registry) and excludes all other languages

24 changes: 23 additions & 1 deletion scripts/languages.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
"build.gradle",
"build.gradle.kts",
"settings.gradle"
],
"linter_config_files": [
".pre-commit-config.yaml",
"checkstyle.xml",
"pmd.xml"
]
},
{
Expand Down Expand Up @@ -64,6 +69,11 @@
"install_hint": null,
"requiresConfig": [
"Cargo.toml"
],
"linter_config_files": [
".pre-commit-config.yaml",
"clippy.toml",
".clippy.toml"
]
},
{
Expand Down Expand Up @@ -113,6 +123,12 @@
"--no-install",
"eslint",
"--version"
],
"linter_config_files": [
".pre-commit-config.yaml",
"eslint.config.js",
".eslintrc.json",
".eslintrc.js"
]
},
{
Expand All @@ -139,7 +155,13 @@
".",
"--fix"
],
"install_hint": "pip install ruff"
"install_hint": "pip install ruff",
"linter_config_files": [
".pre-commit-config.yaml",
"ruff.toml",
".ruff.toml",
"pyproject.toml"
]
},
{
"id": "go",
Expand Down
15 changes: 15 additions & 0 deletions scripts/validate_languages_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ def _check_requires_config(reg: dict) -> list[str]:
return errs


def _check_linter_config_files(reg: dict) -> list[str]:
errs: list[str] = []
for lang in reg.get("languages", []):
if not isinstance(lang, dict):
continue
lid = lang.get("id", "<?>")
cf = lang.get("linter_config_files")
if cf is None:
continue
if not isinstance(cf, list) or not all(isinstance(s, str) for s in cf):
errs.append(f"{lid}:linter_config_files: must be list of strings")
return errs


def _check_since(reg: dict) -> list[str]:
errs: list[str] = []
for lang in reg.get("languages", []):
Expand All @@ -182,6 +196,7 @@ def check(registry: dict) -> list[str]:
errs += _check_lint_format_shape(registry)
errs += _check_status_command_presence(registry)
errs += _check_requires_config(registry)
errs += _check_linter_config_files(registry)
errs += _check_since(registry)
return errs

Expand Down
10 changes: 10 additions & 0 deletions tests/test_validate_languages_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,13 @@ def test_id_must_be_kebab_case(self) -> None:

if __name__ == "__main__":
unittest.main()

class LinterConfigFilesRuleTest(unittest.TestCase):
def test_linter_config_files_must_be_list_of_str(self) -> None:
reg = _mutate(_baseline(), "python", linter_config_files="ruff.toml")
errs = check(reg)
self.assertTrue(any("python:linter_config_files" in e for e in errs), errs)

def test_linter_config_files_can_be_empty(self) -> None:
reg = _mutate(_baseline(), "rust", linter_config_files=[])
self.assertEqual([], check(reg))
Loading