diff --git a/hooks/env_check.py b/hooks/env_check.py index 08d4643..a15f1cf 100755 --- a/hooks/env_check.py +++ b/hooks/env_check.py @@ -17,6 +17,7 @@ from detect_lang import ( LANG_COMMANDS, + REGISTRY, detect_languages, ensure_user_path, find_project_root, @@ -24,13 +25,6 @@ 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 静默)""" @@ -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 diff --git a/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/.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-linter-config-from-registry/design.md b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/design.md new file mode 100644 index 0000000..6e75f8e --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/design.md @@ -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` 现状已如此)。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/proposal.md b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/proposal.md new file mode 100644 index 0000000..e210830 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/proposal.md @@ -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) 合入。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/specs/registry-driven-config/spec.md b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/specs/registry-driven-config/spec.md new file mode 100644 index 0000000..6c68dcf --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/specs/registry-driven-config/spec.md @@ -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 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/tasks.md b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/tasks.md new file mode 100644 index 0000000..39c42e7 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-linter-config-from-registry/tasks.md @@ -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`. \ No newline at end of file diff --git a/openspec/specs/registry-driven-config/spec.md b/openspec/specs/registry-driven-config/spec.md new file mode 100644 index 0000000..3bdb213 --- /dev/null +++ b/openspec/specs/registry-driven-config/spec.md @@ -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 + diff --git a/scripts/languages.json b/scripts/languages.json index 94a8452..327d451 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -36,6 +36,11 @@ "build.gradle", "build.gradle.kts", "settings.gradle" + ], + "linter_config_files": [ + ".pre-commit-config.yaml", + "checkstyle.xml", + "pmd.xml" ] }, { @@ -64,6 +69,11 @@ "install_hint": null, "requiresConfig": [ "Cargo.toml" + ], + "linter_config_files": [ + ".pre-commit-config.yaml", + "clippy.toml", + ".clippy.toml" ] }, { @@ -113,6 +123,12 @@ "--no-install", "eslint", "--version" + ], + "linter_config_files": [ + ".pre-commit-config.yaml", + "eslint.config.js", + ".eslintrc.json", + ".eslintrc.js" ] }, { @@ -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", diff --git a/scripts/validate_languages_json.py b/scripts/validate_languages_json.py index 7233def..4c18ca5 100644 --- a/scripts/validate_languages_json.py +++ b/scripts/validate_languages_json.py @@ -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", []): @@ -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 diff --git a/tests/test_validate_languages_json.py b/tests/test_validate_languages_json.py index 684c0df..4cb1848 100644 --- a/tests/test_validate_languages_json.py +++ b/tests/test_validate_languages_json.py @@ -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))