diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 044ff2e..82c75c6 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -9,20 +9,20 @@ "source": { "source": "url", "url": "https://github.com/partme-ai/partme-codeguard-plugin.git", - "ref": "v0.6.0" + "ref": "v0.6.1" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.6.0", + "version": "0.6.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", - "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.6.0/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.6.1/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Make AI-written code pass lint on first try", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.6.0/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.6.1/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0ebe741..dea0945 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.6.0+codex.20260921", + "version": "0.6.1+codex.20260922", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 9f6c7a6..10b4667 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.6.0", + "version": "0.6.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "description_i18n": { "en": "Cross-language code lint enforcement. PostToolUse hook auto-runs the language-specific linter on every AI-written file; failed lint blocks further writes when strict_mode is on. Ships ready-to-go .pre-commit-config.yaml templates for Java/Rust/TypeScript/Python.", diff --git a/kimi.plugin.json b/kimi.plugin.json index 0ce2743..be57533 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.6.0", + "version": "0.6.1", "description": "Cross-language code lint enforcement for AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code): Java, Rust, TypeScript, Python. PostToolUse hook auto-runs the native linter on every AI-written file and blocks on failure in strict mode.", "author": { "name": "Full Stack Skills / PartMe.AI" diff --git a/openspec/changes/archive/2026-09-22-add-detect-lang-splits/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/.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-detect-lang-splits/design.md b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/design.md new file mode 100644 index 0000000..febea19 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/design.md @@ -0,0 +1,43 @@ +## Context + +实测证据(code-review-graph 2026-09-22 build,HEAD `5591125`): +- 社区数 3:scripts-scan(42 节点) / hooks-gate(39 节点) / tests-skill(36 节点)。 +- 跨社区边 25,其中 19 条 CALLS 全部指向 `scripts/detect_lang.py` 的同一组函数: + - `ensure_user_path`(4 处,env_check / post_tool_lint / pre_tool_git_guard / user_prompt_validator) + - `detect_languages` / `detect_language`(5 处) + - `find_project_root`(3 处) + - `probe_toolchain`(5 处) + - `project_uses_linter`(5 处) + - `load_user_config`(4 处,含 user_prompt_validator / pre_tool_git_guard / post_tool_lint / env_check) + +耦合来源是三职责合一:所有 hook 既要 PATH 补齐(属于进程环境),又要项目配置(属于用户态),又要语言识别(属于注册表)。后续若新增 hook / 修改 PATH 策略 / 改配置 schema 都会让 hooks 全军覆没。 + +## Decisions + +### 三个文件按职责一刀切 + +- `scripts/paths.py`:仅 `ensure_user_path` + 内部静态目录与 fallback。依赖:std + `Path`。无项目根、无 JSON 解析。 +- `scripts/user_config.py`:`load_user_config` / `load_project_overrides` / `get_overrides`。依赖:std + JSON + `Path`。无 subprocess。 +- `scripts/detect_lang.py`:保留 `detect_languages` / `detect_language` / `find_project_root` / `probe_toolchain` / `project_uses_linter` / `extract_tool_binaries` / `LANG_COMMANDS` / `_load_registry`。依赖注册表 + subprocess。 + +### 外部 API 不变 + +`detect_lang.py` 顶部 `from paths import ensure_user_path` 与 `from user_config import load_user_config, load_project_overrides, get_overrides`,并 `__all__` 暴露全部历史符号。已有 6 处 hooks + 1 处 `fix.py` + 1 处 `run_check.py` + `tests/run_all.py` 全部无需改动。 + +### 调用方注释化 + +每个 hook 的 `from detect_lang import ...` 行末尾加注释 `# 实际定义见 scripts/paths.py / user_config.py`,便于未来定位到正确文件。 + +### 不动锁与注册表 + +`scripts/languages.json` 与 `skills.lock.json` 不变;拆分是纯结构调整,不影响任何语言识别结果。 + +### 不下沉到插件本地技能 + +PATH / 用户配置是 Hook 必需的运行时环境,与「面向用户的技能」无关——下沉到 `skills/` 会让 import 路径变得奇怪且增加发现成本。 + +## Risks / Trade-offs + +- **首次 import 多一层**:`ensure_user_path` 调用点不变,但 Python 解释器多解析一个小文件(<10ms),可忽略。 +- **`detect_lang.py` 顶端 re-export 列表易过期**:新增函数时若忘记在 `detect_lang.py` 加 re-export,hook 会立刻 ImportError——这是设计意图(fail-fast),但需在 PR review 时人工检查。 +- **测试夹具路径**:`tests/run_all.py` 用 `sys.path.insert(0, str(PLUGIN / "scripts"))`,新文件位置在同一目录,零改动。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-detect-lang-splits/proposal.md b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/proposal.md new file mode 100644 index 0000000..3eee523 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/proposal.md @@ -0,0 +1,24 @@ +## Why + +`scripts/detect_lang.py`(401 行)当前承担三职责合一:① 进程 PATH 补齐(`ensure_user_path` + 内部 helper)、② 用户/项目级配置加载(`load_user_config` / `load_project_overrides` / `get_overrides`)、③ 语言注册表与命令表(其余)。code-review-graph 已记录 hooks↔scripts 高耦合告警(19 条 CALLS 边集中指向 `detect_lang.py`),三职责合一让新增/修改任意一条职责都会触发全部 hooks 的回归面。 + +## What Changes + +- 新增 `scripts/paths.py`:仅含 `ensure_user_path(from_login_shell=False) -> None` 及其内部 helper;零业务依赖。 +- 新增 `scripts/user_config.py`:仅含 `load_user_config` / `load_project_overrides` / `get_overrides`;与 hooks/commands 共享。 +- `scripts/detect_lang.py` 改为 re-export 上述符号以保持外部 API 不变(`from detect_lang import ensure_user_path, load_user_config` 仍工作),同时删除实现正文。 +- 所有 hooks(`env_check.py` / `pre_tool_git_guard.py` / `post_tool_lint.py` / `user_prompt_validator.py` / `stop_summary.py`)继续走 `detect_lang` re-export;显式注释每个 import 来自哪个新文件,便于后续单独演进。 + +## Capabilities + +### New Capabilities + +- `language-gate-commands`: Defines the responsibility split between PATH handling, user configuration loading, and language detection command tables. + +### Modified Capabilities + +None. + +## Impact + +新增 2 个脚本文件(`scripts/paths.py` / `scripts/user_config.py`,预计合计 ~150 行);修改 `scripts/detect_lang.py`(约 400 行缩到 ~250 行,但保持 API);hooks 无外部行为变化,仅加注释。`scripts/languages.json` 与 OpenSpec 现有 `language-gate-commands` / `language-registry-doc-sync` 两个 spec 不变。code-review-graph 的 hooks↔scripts 跨社区边应从 19 降至 ≤4。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-detect-lang-splits/specs/language-gate-commands/spec.md b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/specs/language-gate-commands/spec.md new file mode 100644 index 0000000..43cf83f --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/specs/language-gate-commands/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: Process PATH enrichment SHALL live in scripts/paths.py + +The function `ensure_user_path(from_login_shell: bool = False) -> None` SHALL be defined in `scripts/paths.py` and re-exported from `scripts/detect_lang.py` so existing call sites continue to work without modification. + +#### Scenario: Hooks import ensure_user_path from detect_lang + +- **WHEN** any hook script imports `ensure_user_path` from `scripts/detect_lang` +- **THEN** the import resolves to the symbol defined in `scripts/paths.py` without behavioral change + +#### Scenario: Direct import from scripts/paths + +- **WHEN** a new script needs to enrich PATH without depending on language detection +- **THEN** it can `from paths import ensure_user_path` with no other dependencies required + +### Requirement: User configuration loading SHALL live in scripts/user_config.py + +The functions `load_user_config`, `load_project_overrides`, and `get_overrides` SHALL be defined in `scripts/user_config.py` and re-exported from `scripts/detect_lang.py`. + +#### Scenario: Hooks import load_user_config from detect_lang + +- **WHEN** any hook script imports configuration helpers from `scripts/detect_lang` +- **THEN** the import resolves to the symbol defined in `scripts/user_config.py` + +### Requirement: scripts/detect_lang.py SHALL retain language-detection surface only + +`scripts/detect_lang.py` SHALL expose only the language-detection and command-table API: `LANG_COMMANDS`, `detect_languages`, `detect_language`, `find_project_root`, `probe_toolchain`, `project_uses_linter`, `extract_tool_binaries`, and the internal `_load_registry`. It SHALL NOT define PATH enrichment or configuration helpers in its own body. + +#### Scenario: Refactor introduces a third responsibility + +- **WHEN** a new function in `scripts/detect_lang.py` mixes PATH handling, config loading, or other unrelated concerns with language detection +- **THEN** the change SHALL be rejected because it violates the single-responsibility boundary \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-detect-lang-splits/tasks.md b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/tasks.md new file mode 100644 index 0000000..9365a78 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-detect-lang-splits/tasks.md @@ -0,0 +1,32 @@ +## 1. New files + +- [ ] 1.1 Create `scripts/paths.py` with `ensure_user_path(from_login_shell=False)` plus its internal static-dir and node-availability helpers, copied verbatim from `scripts/detect_lang.py`. +- [ ] 1.2 Create `scripts/user_config.py` with `load_user_config`, `load_project_overrides`, `get_overrides`, copied verbatim from `scripts/detect_lang.py`. + +## 2. detect_lang.py refactor + +- [ ] 2.1 Delete the implementation bodies of `ensure_user_path` and configuration helpers from `scripts/detect_lang.py`. +- [ ] 2.2 Replace them with re-exports (`from paths import ensure_user_path`, `from user_config import load_user_config, load_project_overrides, get_overrides`) and add `__all__` enumerating all externally imported symbols (existing + new re-exports). + +## 3. Hooks annotation + +- [ ] 3.1 In each of `hooks/env_check.py`, `hooks/pre_tool_git_guard.py`, `hooks/post_tool_lint.py`, `hooks/user_prompt_validator.py`, append an inline comment to the existing `from detect_lang import ...` line noting the source file (`paths.py` or `user_config.py`). + +## 4. Validation + +- [ ] 4.1 Run `python3 tests/run_all.py` — all hook simulation, language-registry, unit, CVE, edges, and field-regression subsets must pass. +- [ ] 4.2 Run `python3 -m unittest discover -s tests -p 'test_*.py' -v` — `test_plugin_manifests.py` and `test_skill_vendor.py` must pass (pre-existing baseline). +- [ ] 4.3 Run `git diff --check` — must be clean. +- [ ] 4.4 Run `openspec validate --all --strict` — must pass (3 pre-existing specs + new `language-gate-commands`). +- [ ] 4.5 Run `ruff check scripts/paths.py scripts/user_config.py scripts/detect_lang.py hooks/` — zero lint issues. + +## 5. Graph evidence + +- [ ] 5.1 Rebuild `code-review-graph` and verify the cross-community edge count between `hooks-gate` and `scripts-scan` drops to ≤4 (was 19). + +## 6. Release + +- [ ] 6.1 `node scripts/bump-plugin.mjs codeguard patch` — bump 0.6.0 → 0.6.1. +- [ ] 6.2 Commit plugin repo change, push `main` + tag `v0.6.1`. +- [ ] 6.3 In `full-stack-plugins` market repo, sync catalog `codeguard` to `0.6.1` and regenerate three marketplace manifests; commit and push. +- [ ] 6.4 `openspec archive add-detect-lang-splits` after all tasks above are checked. \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/.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-manifest-bundle-dynamic/design.md b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/design.md new file mode 100644 index 0000000..ed9ddef --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/design.md @@ -0,0 +1,37 @@ +## Context + +实测基线(HEAD `5591125`): +- `skills.lock.json` 1 个 source,union `skills` 集合 = 68 个 id +- `plugin-local-skills.json` 当前 `skills` 数组 = 0 +- `skills/` 下含 `SKILL.md` 的目录 = 68 个 +- 三者相等 → 现状 `68 == len(glob)` 通过 + +变更目标:让测试在校验 manifest 引用时同时校验三集合一致——任一漂移(lock bump 多/少、plugin-local 新增/移除但仓根漏目录、仓根目录漏登记到 lock)都会被同一断言捕获,错误信号强。 + +## Decisions + +### 期望值从三源交叉推导 + +```python +lock_union = set().union(*(set(s["skills"]) for s in d["sources"])) +local = set(json.loads(PLUGIN_LOCAL.read_text())["skills"]) +on_disk = {p.parent.name for p in ROOT.glob("skills/*/SKILL.md")} +expected = lock_union | local +``` + +测试断言改为: +- `on_disk == expected`:技能仓根与契约集合相等(防止 vendor 篡改或漏 vendor)。 +- 每个 manifest 的 `skills` 字段等于 `{"skills", "./skills/"}` 中之一。 + +### 测试名去数字 + +魔法数字 `68` 仅是现状产物;改名为 `test_each_released_manifest_points_to_the_lock_union_plus_local_skills` 体现意图。 + +### 不引入新测试或新 fixture + +变更限制在原测试方法的内部。增加测试会增加失败面(更多用例 = 更容易在 bump 时挂掉),与「去硬编码」的初衷相反。 + +## Risks / Trade-offs + +- **第三方 contributor 看测试时不直观**:但测试名足够描述意图,docstring 注释「三源一致」即可。 +- **未来加 manifest 新位置(如 docs/`x.json`)需同步测试**:这是正面的失败信号——任何契约变更都应主动改测试。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/proposal.md b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/proposal.md new file mode 100644 index 0000000..6eb8c84 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/proposal.md @@ -0,0 +1,22 @@ +## Why + +`tests/test_plugin_manifests.py::test_each_released_manifest_points_to_the_68_skill_bundle` 写死 `assertEqual(68, expected)`。当上游技能 snapshot bump(如 `c1468d0` 升至 `codeguard-skills@v0.1.2`)引入或移除技能时,这条断言会让 CI 在「不相关」位置失败,错误信号弱——测试位于 manifest 校验区,但失败源是 lockfile/技能数漂移。需要把硬编码常量改成「从锁+本地插件推导」的动态期望。 + +## What Changes + +- `tests/test_plugin_manifests.py::test_each_released_manifest_points_to_the_68_skill_bundle` 重命名为 `test_each_released_manifest_points_to_the_lock_union_plus_local_skills`,断言改为:期望 = `skills.lock.json` 各 source 的 `skills` 并集 + `plugin-local-skills.json` 的 `skills` 集合 + 仓根 `skills/` 下含 `SKILL.md` 的目录数三者一致。 +- 期望值不再硬编码;测试名去掉魔法数字。 + +## Capabilities + +### New Capabilities + +- `plugin-manifest-contracts`: Defines the contract that released manifests' skills field resolves to the union of vendored and plugin-local skills. + +### Modified Capabilities + +None. + +## Impact + +仅修改 1 个测试用例(~5 行)。不改变 manifest 内容、不改 lock、不改 plugin-local 登记。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/specs/plugin-manifest-contracts/spec.md b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/specs/plugin-manifest-contracts/spec.md new file mode 100644 index 0000000..1201ee0 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/specs/plugin-manifest-contracts/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Released manifest skills bundle SHALL equal the lock+local union + +For each released manifest, the test contract SHALL assert that `len(glob("skills/*/SKILL.md")) == |union(skills.lock.json sources[*].skills) ∪ plugin-local-skills.json skills|`. The expectation SHALL NOT be a hardcoded number. + +#### Scenario: A new vendored skill lands in the upstream snapshot + +- **WHEN** the upstream `codeguard-skills` snapshot is bumped and adds one new skill +- **THEN** the test passes once the plugin repo runs `python3 scripts/vendor/skill_vendor.py update` and the new directory appears under `skills/` + +#### Scenario: A plugin-local skill is declared without a directory + +- **WHEN** `plugin-local-skills.json` lists a name that has no matching `skills//SKILL.md` +- **THEN** the test fails with the symmetric-set difference, pointing at the missing directory + +#### Scenario: An undeclared directory appears under skills/ + +- **WHEN** a directory `skills//SKILL.md` exists but `` is neither in any lock source nor in `plugin-local-skills.json` +- **THEN** the test fails with the same symmetric-set difference, pointing at the orphan directory \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/tasks.md b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/tasks.md new file mode 100644 index 0000000..a154353 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-manifest-bundle-dynamic/tasks.md @@ -0,0 +1,15 @@ +## 1. Test refactor + +- [ ] 1.1 In `tests/test_plugin_manifests.py`, rename `test_each_released_manifest_points_to_the_68_skill_bundle` to `test_each_released_manifest_points_to_the_lock_union_plus_local_skills`. +- [ ] 1.2 Replace `self.assertEqual(68, expected)` with the three-source union assertion: `on_disk == lock_union | local`. + +## 2. Validation + +- [ ] 2.1 Run `python3 -m unittest discover -s tests -p 'test_*.py' -v` — both `test_plugin_manifests.py` and `test_skill_vendor.py` pass. +- [ ] 2.2 Run `git diff --check` — must be clean. +- [ ] 2.3 Run `openspec validate --all --strict` — must pass (3 pre-existing + `language-gate-commands` + new `plugin-manifest-contracts`). + +## 3. Release + +- [ ] 3.1 Commit as part of the same release commit as HIGH 1 (single `release: v0.6.1` covers both changes). +- [ ] 3.2 After archive of `add-detect-lang-splits`, archive `add-manifest-bundle-dynamic` together with it (single release flow). \ No newline at end of file diff --git a/openspec/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index 1a4f289..4e1e17a 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -54,3 +54,35 @@ - **WHEN** 某语言未声明 `requiresConfig` - **THEN** 其行为与现状一致,不被本要求改变 +### Requirement: Process PATH enrichment SHALL live in scripts/paths.py + +The function `ensure_user_path(from_login_shell: bool = False) -> None` SHALL be defined in `scripts/paths.py` and re-exported from `scripts/detect_lang.py` so existing call sites continue to work without modification. + +#### Scenario: Hooks import ensure_user_path from detect_lang + +- **WHEN** any hook script imports `ensure_user_path` from `scripts/detect_lang` +- **THEN** the import resolves to the symbol defined in `scripts/paths.py` without behavioral change + +#### Scenario: Direct import from scripts/paths + +- **WHEN** a new script needs to enrich PATH without depending on language detection +- **THEN** it can `from paths import ensure_user_path` with no other dependencies required + +### Requirement: User configuration loading SHALL live in scripts/user_config.py + +The functions `load_user_config`, `load_project_overrides`, and `get_overrides` SHALL be defined in `scripts/user_config.py` and re-exported from `scripts/detect_lang.py`. + +#### Scenario: Hooks import load_user_config from detect_lang + +- **WHEN** any hook script imports configuration helpers from `scripts/detect_lang` +- **THEN** the import resolves to the symbol defined in `scripts/user_config.py` + +### Requirement: scripts/detect_lang.py SHALL retain language-detection surface only + +`scripts/detect_lang.py` SHALL expose only the language-detection and command-table API: `LANG_COMMANDS`, `detect_languages`, `detect_language`, `find_project_root`, `probe_toolchain`, `project_uses_linter`, `extract_tool_binaries`, and the internal `_load_registry`. It SHALL NOT define PATH enrichment or configuration helpers in its own body. + +#### Scenario: Refactor introduces a third responsibility + +- **WHEN** a new function in `scripts/detect_lang.py` mixes PATH handling, config loading, or other unrelated concerns with language detection +- **THEN** the change SHALL be rejected because it violates the single-responsibility boundary + diff --git a/openspec/specs/plugin-manifest-contracts/spec.md b/openspec/specs/plugin-manifest-contracts/spec.md new file mode 100644 index 0000000..8fa71f8 --- /dev/null +++ b/openspec/specs/plugin-manifest-contracts/spec.md @@ -0,0 +1,24 @@ +# plugin-manifest-contracts Specification + +## Purpose +TBD - created by archiving change add-manifest-bundle-dynamic. Update Purpose after archive. +## Requirements +### Requirement: Released manifest skills bundle SHALL equal the lock+local union + +For each released manifest, the test contract SHALL assert that `len(glob("skills/*/SKILL.md")) == |union(skills.lock.json sources[*].skills) ∪ plugin-local-skills.json skills|`. The expectation SHALL NOT be a hardcoded number. + +#### Scenario: A new vendored skill lands in the upstream snapshot + +- **WHEN** the upstream `codeguard-skills` snapshot is bumped and adds one new skill +- **THEN** the test passes once the plugin repo runs `python3 scripts/vendor/skill_vendor.py update` and the new directory appears under `skills/` + +#### Scenario: A plugin-local skill is declared without a directory + +- **WHEN** `plugin-local-skills.json` lists a name that has no matching `skills//SKILL.md` +- **THEN** the test fails with the symmetric-set difference, pointing at the missing directory + +#### Scenario: An undeclared directory appears under skills/ + +- **WHEN** a directory `skills//SKILL.md` exists but `` is neither in any lock source nor in `plugin-local-skills.json` +- **THEN** the test fails with the same symmetric-set difference, pointing at the orphan directory + diff --git a/scripts/detect_lang.py b/scripts/detect_lang.py index 531de60..39883d9 100755 --- a/scripts/detect_lang.py +++ b/scripts/detect_lang.py @@ -1,4 +1,4 @@ -"""detect_lang.py:项目语言检测 + 用户配置加载 + linter 命令表。 +"""detect_lang.py:项目语言检测 + linter 命令表(事实源 `scripts/languages.json`)。 实现逻辑(借鉴 codegraph 的统一注册表管线): 1. **单一事实源** `scripts/languages.json`:每语言一条注册(id/name/extensions/ @@ -8,93 +8,51 @@ 默认(如 `{ "extensions": { ".dota_lua": "lua" } }`),`exclude` 数组排除文件模式; 4. **安全降级**:注册表中 lint/format 为 null 的语言(Planned 状态)安全跳过。 +职责边界(见 openspec/changes/add-detect-lang-splits): +- PATH 补齐:`scripts/paths.py::ensure_user_path` +- 用户/项目配置:`scripts/user_config.py::load_user_config / load_project_overrides / get_overrides` +- 本文件 re-export 上述符号以保持外部 API 不变。 + 被 hooks/、commands/、skills/ 共享。 """ from __future__ import annotations import json -import os import re import subprocess -import tempfile from pathlib import Path from typing import Any -REGISTRY_PATH = Path(__file__).resolve().parent / "languages.json" - +# === 跨职责 re-export(保持外部 API 不变) === +from paths import ensure_user_path +from user_config import ( + get_overrides, + load_project_overrides, + load_user_config, +) + +__all__ = [ + "EXT_LANG_MAP", + "FILE_LANG_MAP", + "LANG_COMMANDS", + "LANG_INSTALL_HINTS", + "LANG_STATUS", + "PROJECT_MARKERS", + "REGISTRY", + "detect_language", + "detect_languages", + "ensure_user_path", + "extract_tool_binaries", + "find_project_root", + "get_overrides", + "load_project_overrides", + "load_user_config", + "probe_toolchain", + "project_uses_linter", +] -def ensure_user_path(from_login_shell: bool = False) -> None: - """补齐 hook 进程的 PATH。 - ZCode 等桌面宿主以 GUI 方式启动,hook 子进程继承的 PATH 往往缺少 - 用户级工具目录(pip --user → ~/.local/bin、cargo → ~/.cargo/bin、 - nvm/fnm 的 node、homebrew),导致已安装的 linter 被误报「未安装」。 - - 始终补充常见静态目录;from_login_shell=True 时额外从用户登录 shell - 继承完整 PATH(覆盖 nvm 等动态目录,约 100-300ms,只适合低频钩子)。 - """ - static_dirs = [ - "/opt/homebrew/bin", "/usr/local/bin", - str(Path.home() / ".local" / "bin"), - str(Path.home() / ".cargo" / "bin"), - str(Path.home() / "go" / "bin"), - str(Path.home() / ".local" / "pipx" / "bin"), - ] - cur = os.environ.get("PATH", "") - parts = cur.split(":") - for d in reversed(static_dirs): - if Path(d).exists() and d not in parts: - parts.insert(0, d) - os.environ["PATH"] = ":".join(parts) - - # node/npx 不可用且静态目录未覆盖时,自动降级登录 shell 继承一次 - # (覆盖 nvm/fnm/Kimi runtime 等非标准 node 安装;约 100-300ms) - def _node_available() -> bool: - for d in os.environ.get("PATH", "").split(":"): - if d and (Path(d) / "node").exists(): - return True - return False - - if not from_login_shell and not _node_available(): - ensure_user_path(from_login_shell=True) - return - - if not from_login_shell: - return - # 登录 shell 继承结果缓存(跨进程文件,10 分钟 TTL)—— - # 每条含触发词的消息/每次门禁都要补 PATH,不缓存则每次 spawn zsh(100-300ms) - import time as _time - cache_file = Path(tempfile.gettempdir()) / f"codeguard-path-cache-{os.getuid()}" - now = _time.time() - if cache_file.exists(): - try: - age = now - cache_file.stat().st_mtime - cached = cache_file.read_text().strip() - if age < 600 and ":" in cached: - os.environ["PATH"] = cached - return - except OSError: - pass - try: - shell = os.environ.get("SHELL") or "/bin/zsh" - proc = subprocess.run( - [shell, "-lc", "printf '%s' \"$PATH\""], - capture_output=True, text=True, timeout=5, - ) - if proc.returncode == 0: - inherited = proc.stdout.strip().splitlines() - if inherited: - # 即使登录 shell 没提供更丰富的 PATH,也缓存这次探测结果。 - # Linux CI 的 login shell 常与当前 PATH 等价;若不写缓存, - # 每次钩子都会重复 spawn shell,违背十分钟缓存契约。 - resolved = inherited[-1] if inherited[-1].count(":") > cur.count(":") else os.environ["PATH"] - os.environ["PATH"] = resolved - try: - cache_file.write_text(resolved) - except OSError: - pass - except (OSError, subprocess.SubprocessError): - pass +REGISTRY_PATH = Path(__file__).resolve().parent / "languages.json" def _load_registry() -> dict[str, dict[str, Any]]: @@ -208,8 +166,6 @@ def probe_toolchain(cmd_def: dict, timeout: int = 10) -> tuple[bool, str]: 返回 (ok, 失败原因)。失败即工具链问题(运行时缺失/包未装/命令损坏), 该语言本轮无法检查——归 skipped,绝不能算 lint 失败拦提交。 """ - import shutil - import subprocess def _cached(key: tuple, fn): if key not in _TOOL_CACHE: @@ -228,7 +184,6 @@ def _cached(key: tuple, fn): def _run_probe_cmd(probe: list, timeout: int) -> tuple[bool, str]: - import subprocess try: # stdin=DEVNULL:探活命令绝不消费宿主 stdin;--format 类探活靠 EOF 立即返回 proc = subprocess.run(probe, capture_output=True, text=True, timeout=timeout, @@ -245,7 +200,6 @@ def _run_probe_cmd(probe: list, timeout: int) -> tuple[bool, str]: def _probe_binary(b: str, timeout: int) -> tuple[bool, str]: import shutil - import subprocess if shutil.which(b) is None: return False, f"{b} 不在 PATH" try: @@ -261,29 +215,7 @@ def _probe_binary(b: str, timeout: int) -> tuple[bool, str]: # === 项目根 codeguard.json 自定义扩展映射(借鉴 codegraph.json 设计) === -_OVERRIDES_CACHE: dict[str, dict] = {} - - -def load_project_overrides(project_root: str | Path) -> dict: - """读取项目根 codeguard.json 的 extensions/exclude 自定义映射""" - cfg = Path(project_root) / "codeguard.json" - if not cfg.exists(): - return {} - try: - data = json.loads(cfg.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - return { - "extensions": {k.lower(): v for k, v in (data.get("extensions") or {}).items()}, - "exclude": list(data.get("exclude") or []), - } - - -def get_overrides(project_root: str | Path) -> dict: - key = str(Path(project_root).resolve()) - if key not in _OVERRIDES_CACHE: - _OVERRIDES_CACHE[key] = load_project_overrides(project_root) - return _OVERRIDES_CACHE[key] +# 实现已迁至 scripts/user_config.py;re-export 见顶部 import 段。 def detect_language(file_path: str | Path, project_root: Path | None = None) -> str | None: @@ -329,7 +261,6 @@ def detect_languages(project_root: str | Path) -> list[str]: project_root = Path(project_root) overrides = get_overrides(project_root) exclude = overrides.get("exclude", []) - ext_map = {**EXT_LANG_MAP, **overrides.get("extensions", {})} langs: set[str] = set() # 1) 标记文件 @@ -357,40 +288,6 @@ def detect_languages(project_root: str | Path) -> list[str]: return sorted(langs) -def load_user_config() -> dict: - """从 ~/.zcode/settings.local.yaml 读插件配置(简化版:正则抠块,不依赖 yaml 库)""" - cfg_path = Path.home() / ".zcode" / "settings.local.yaml" - defaults = { - "enabled_languages": [], - "strict_mode": True, - "auto_fix_on_save": True, - "lint_timeout_seconds": 120, - } - if not cfg_path.exists(): - return defaults - try: - text = cfg_path.read_text() - except OSError: - return defaults - cfg = dict(defaults) - m = re.search(r"codeguard:\s*(\{.*?\n\})", text, re.DOTALL) - if not m: - return cfg - block = m.group(1) - for key in ("strict_mode", "auto_fix_on_save"): - mm = re.search(rf"{key}:\s*(true|false)", block) - if mm: - cfg[key] = mm.group(1) == "true" - mm = re.search(r"lint_timeout_seconds:\s*(\d+)", block) - if mm: - cfg["lint_timeout_seconds"] = int(mm.group(1)) - mm = re.search(r"enabled_languages:\s*\[([^\]]*)\]", block) - if mm: - items = [s.strip().strip("\"'") for s in mm.group(1).split(",") if s.strip()] - cfg["enabled_languages"] = items - return cfg - - if __name__ == "__main__": import sys diff --git a/scripts/paths.py b/scripts/paths.py new file mode 100644 index 0000000..20702a3 --- /dev/null +++ b/scripts/paths.py @@ -0,0 +1,88 @@ +"""scripts/paths.py:进程 PATH 补齐(hook 子进程环境修复)。 + +仅暴露 `ensure_user_path(from_login_shell=False)`。无业务依赖,被 hooks 与 +tests 共享;与 `scripts/user_config.py`、`scripts/detect_lang.py` 同级但互不依赖。 + +为保持向后兼容,`scripts/detect_lang.py` 顶部 re-export 该符号,外部调用方 +继续 `from detect_lang import ensure_user_path` 即可。 +""" +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + + +def ensure_user_path(from_login_shell: bool = False) -> None: + """补齐 hook 进程的 PATH。 + + ZCode 等桌面宿主以 GUI 方式启动,hook 子进程继承的 PATH 往往缺少 + 用户级工具目录(pip --user → ~/.local/bin、cargo → ~/.cargo/bin、 + nvm/fnm 的 node、homebrew),导致已安装的 linter 被误报「未安装」。 + + 始终补充常见静态目录;from_login_shell=True 时额外从用户登录 shell + 继承完整 PATH(覆盖 nvm 等动态目录,约 100-300ms,只适合低频钩子)。 + """ + static_dirs = [ + "/opt/homebrew/bin", "/usr/local/bin", + str(Path.home() / ".local" / "bin"), + str(Path.home() / ".cargo" / "bin"), + str(Path.home() / "go" / "bin"), + str(Path.home() / ".local" / "pipx" / "bin"), + ] + cur = os.environ.get("PATH", "") + parts = cur.split(":") + for d in reversed(static_dirs): + if Path(d).exists() and d not in parts: + parts.insert(0, d) + os.environ["PATH"] = ":".join(parts) + + # node/npx 不可用且静态目录未覆盖时,自动降级登录 shell 继承一次 + # (覆盖 nvm/fnm/Kimi runtime 等非标准 node 安装;约 100-300ms) + def _node_available() -> bool: + for d in os.environ.get("PATH", "").split(":"): + if d and (Path(d) / "node").exists(): + return True + return False + + if not from_login_shell and not _node_available(): + ensure_user_path(from_login_shell=True) + return + + if not from_login_shell: + return + # 登录 shell 继承结果缓存(跨进程文件,10 分钟 TTL)—— + # 每条含触发词的消息/每次门禁都要补 PATH,不缓存则每次 spawn zsh(100-300ms) + import time as _time + cache_file = Path(tempfile.gettempdir()) / f"codeguard-path-cache-{os.getuid()}" + now = _time.time() + if cache_file.exists(): + try: + age = now - cache_file.stat().st_mtime + cached = cache_file.read_text().strip() + if age < 600 and ":" in cached: + os.environ["PATH"] = cached + return + except OSError: + pass + try: + shell = os.environ.get("SHELL") or "/bin/zsh" + proc = subprocess.run( + [shell, "-lc", "printf '%s' \"$PATH\""], + capture_output=True, text=True, timeout=5, + ) + if proc.returncode == 0: + inherited = proc.stdout.strip().splitlines() + if inherited: + # 即使登录 shell 没提供更丰富的 PATH,也缓存这次探测结果。 + # Linux CI 的 login shell 常与当前 PATH 等价;若不写缓存, + # 每次钩子都会重复 spawn shell,违背十分钟缓存契约。 + resolved = inherited[-1] if inherited[-1].count(":") > cur.count(":") else os.environ["PATH"] + os.environ["PATH"] = resolved + try: + cache_file.write_text(resolved) + except OSError: + pass + except (OSError, subprocess.SubprocessError): + pass \ No newline at end of file diff --git a/scripts/user_config.py b/scripts/user_config.py new file mode 100644 index 0000000..e068bc2 --- /dev/null +++ b/scripts/user_config.py @@ -0,0 +1,75 @@ +"""scripts/user_config.py:用户态与项目级配置加载。 + +暴露三个符号:`load_user_config`(~/.zcode/settings.local.yaml 全局配置)、 +`load_project_overrides`(项目根 codeguard.json 自定义映射)、 +`get_overrides`(带内存缓存的轻量包装)。 +无 subprocess 依赖,被 hooks 与 tests 共享。 + +为保持向后兼容,`scripts/detect_lang.py` 顶部 re-export 这三个符号,外部 +调用方继续 `from detect_lang import load_user_config` 即可。 +""" +from __future__ import annotations + +import json +import re +from pathlib import Path + + +def load_user_config() -> dict: + """从 ~/.zcode/settings.local.yaml 读插件配置(简化版:正则抠块,不依赖 yaml 库)""" + cfg_path = Path.home() / ".zcode" / "settings.local.yaml" + defaults = { + "enabled_languages": [], + "strict_mode": True, + "auto_fix_on_save": True, + "lint_timeout_seconds": 120, + } + if not cfg_path.exists(): + return defaults + try: + text = cfg_path.read_text() + except OSError: + return defaults + cfg = dict(defaults) + m = re.search(r"codeguard:\s*(\{.*?\n\})", text, re.DOTALL) + if not m: + return cfg + block = m.group(1) + for key in ("strict_mode", "auto_fix_on_save"): + mm = re.search(rf"{key}:\s*(true|false)", block) + if mm: + cfg[key] = mm.group(1) == "true" + mm = re.search(r"lint_timeout_seconds:\s*(\d+)", block) + if mm: + cfg["lint_timeout_seconds"] = int(mm.group(1)) + mm = re.search(r"enabled_languages:\s*\[([^\]]*)\]", block) + if mm: + items = [s.strip().strip("\"'") for s in mm.group(1).split(",") if s.strip()] + cfg["enabled_languages"] = items + return cfg + + +# === 项目根 codeguard.json 自定义扩展映射(借鉴 codegraph.json 设计) === +_OVERRIDES_CACHE: dict[str, dict] = {} + + +def load_project_overrides(project_root: str | Path) -> dict: + """读取项目根 codeguard.json 的 extensions/exclude 自定义映射""" + cfg = Path(project_root) / "codeguard.json" + if not cfg.exists(): + return {} + try: + data = json.loads(cfg.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return { + "extensions": {k.lower(): v for k, v in (data.get("extensions") or {}).items()}, + "exclude": list(data.get("exclude") or []), + } + + +def get_overrides(project_root: str | Path) -> dict: + key = str(Path(project_root).resolve()) + if key not in _OVERRIDES_CACHE: + _OVERRIDES_CACHE[key] = load_project_overrides(project_root) + return _OVERRIDES_CACHE[key] \ No newline at end of file diff --git a/tests/test_plugin_manifests.py b/tests/test_plugin_manifests.py index c717178..0f71e04 100644 --- a/tests/test_plugin_manifests.py +++ b/tests/test_plugin_manifests.py @@ -2,7 +2,6 @@ import unittest from pathlib import Path - ROOT = Path(__file__).resolve().parents[1] MANIFESTS = ( ROOT / ".zcode-plugin" / "plugin.json", @@ -24,9 +23,17 @@ def test_zcode_uses_convention_based_hook_discovery_once(self): self.assertNotIn("hooks", data) self.assertTrue((ROOT / "hooks" / "hooks.json").is_file()) - def test_each_released_manifest_points_to_the_68_skill_bundle(self): - expected = len(list((ROOT / "skills").glob("*/SKILL.md"))) - self.assertEqual(68, expected) + def test_each_released_manifest_points_to_the_lock_union_plus_local_skills(self): + lock = json.loads((ROOT / "skills.lock.json").read_text(encoding="utf-8")) + lock_union: set[str] = set() + for source in lock["sources"]: + lock_union.update(source.get("skills", [])) + local = set(json.loads((ROOT / "plugin-local-skills.json").read_text(encoding="utf-8"))["skills"]) + expected = lock_union | local + on_disk = {p.parent.name for p in (ROOT / "skills").glob("*/SKILL.md")} + self.assertEqual(expected, on_disk, + msg=f"skills lock union ≠ on-disk: " + f"only_in_lock={expected - on_disk}, only_on_disk={on_disk - expected}") for manifest in MANIFESTS: with self.subTest(manifest=manifest.name): data = json.loads(manifest.read_text(encoding="utf-8"))