From 62673cc1d88090813a38c94e922f09a1b79b926b Mon Sep 17 00:00:00 2001 From: loong10k <20489781+loong10k@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:40:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=200.12.0=EF=BC=8C?= =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=88=A4=E5=AE=9A=E5=8F=AF=E4=BF=A1=E5=BA=A6?= =?UTF-8?q?=E5=B9=B6=E5=A2=9E=E5=8A=A0=20Java=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E5=BD=B1=E5=93=8D=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/plugins/marketplace.json | 12 +- .codex-plugin/plugin.json | 10 +- .github/workflows/skills-check.yml | 4 + .zcode-plugin/plugin.json | 10 +- PRIVACY.md | 10 +- README.md | 395 ++++-------------- README.zh-CN.md | 374 ++++------------- bin/codeguard | 5 +- docs/LANGUAGES.md | 2 +- ...tme-codeguard-plugin-Architecture.zh_CN.md | 2 + docs/technical-roadmap.zh_CN.md | 2 + docs/verdict-java-architecture.md | 83 ++++ docs/verification-verdict-java.md | 59 +++ hooks/__protocol__.md | 25 +- hooks/gate_lib.py | 99 +++-- hooks/post_tool_lint.py | 33 +- hooks/pre_tool_git_guard.py | 30 +- hooks/user_prompt_validator.py | 7 +- kimi.plugin.json | 8 +- .../.openspec.yaml | 2 + .../design.md | 31 ++ .../proposal.md | 31 ++ .../specs/cve-dependency-scan/spec.md | 33 ++ .../specs/hook-protocol/spec.md | 27 ++ .../specs/java-project-impact/spec.md | 33 ++ .../specs/language-gate-commands/spec.md | 55 +++ .../specs/mcp-tool-server/spec.md | 41 ++ .../specs/verdict-integrity/spec.md | 37 ++ .../tasks.md | 19 + openspec/specs/cve-dependency-scan/spec.md | 34 +- openspec/specs/hook-protocol/spec.md | 26 +- openspec/specs/java-project-impact/spec.md | 34 ++ openspec/specs/language-gate-commands/spec.md | 52 ++- openspec/specs/mcp-tool-server/spec.md | 43 +- openspec/specs/verdict-integrity/spec.md | 38 ++ scripts/bump-plugin.mjs | 49 ++- scripts/cve_check.py | 160 +++++-- scripts/detect_lang.py | 1 + scripts/git_snapshot.py | 131 ++++++ scripts/java_project.py | 216 ++++++++++ scripts/languages.json | 15 +- scripts/run_check.py | 63 ++- scripts/run_per_language.py | 131 +++--- scripts/verdict.py | 45 ++ tests/run_all.py | 8 +- tests/test_artifact_awareness.py | 9 +- tests/test_full_scan_excludes.py | 8 +- tests/test_hardening_fixes.py | 21 +- tests/test_java_project_impact.py | 157 +++++++ tests/test_mcp_server.py | 22 +- tests/test_session_fixes_20260922.py | 19 +- tests/test_verdict_integrity.py | 341 +++++++++++++++ 52 files changed, 2196 insertions(+), 906 deletions(-) create mode 100644 docs/verdict-java-architecture.md create mode 100644 docs/verification-verdict-java.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/design.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/proposal.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/cve-dependency-scan/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/hook-protocol/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/java-project-impact/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/language-gate-commands/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/mcp-tool-server/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/verdict-integrity/spec.md create mode 100644 openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/tasks.md create mode 100644 openspec/specs/java-project-impact/spec.md create mode 100644 openspec/specs/verdict-integrity/spec.md create mode 100644 scripts/git_snapshot.py create mode 100644 scripts/java_project.py create mode 100644 scripts/verdict.py create mode 100644 tests/test_java_project_impact.py create mode 100644 tests/test_verdict_integrity.py diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 0ebdd4d..a44fc4c 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.11.1" + "ref": "v0.12.0" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.11.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.11.1/assets/official-logo.png", + "version": "0.12.0", + "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.12.0/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.11.1/assets/official-logo.png" + "shortDescription": "Trustworthy code checks and Java impact analysis", + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.12.0/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 787dd51..cc4ae75 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeguard", - "version": "0.11.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.", + "version": "0.12.0+codex.20260922", + "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI", "url": "https://github.com/partme-ai" @@ -24,8 +24,8 @@ "skills": "./skills/", "interface": { "displayName": "代码规范守卫", - "shortDescription": "Make AI-written code pass lint on first try", - "longDescription": "Detects project languages, runs the appropriate linter (mvn javadoc+checkstyle, cargo clippy+fmt, npx eslint, ruff check) on every file the AI writes or edits, and blocks further writes when lints fail. Ships ready-to-use .pre-commit-config.yaml templates and AGENTS.md snippets for one-line project bootstrap. Supports ZCode, Claude Code, Codex CLI, and Kimi Code through their respective plugin manifests.", + "shortDescription": "Trustworthy code checks and Java impact analysis", + "longDescription": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit. Java planning is read-only; executing project checks runs trusted build/test commands, not a security sandbox.", "developerName": "Full Stack Skills / PartMe.AI", "category": "Developer Tools", "capabilities": [ @@ -38,7 +38,7 @@ "defaultPrompt": [ "Run /check on the current project and fix what fails", "Initialize codeguard on this repository with /init", - "Run Java javadoc + checkstyle on the current project" + "Analyze Java module impact, then run the project's verification plan" ], "brandColor": "#2563EB", "composerIcon": "./assets/composer-icon.png", diff --git a/.github/workflows/skills-check.yml b/.github/workflows/skills-check.yml index 99c6303..263b159 100644 --- a/.github/workflows/skills-check.yml +++ b/.github/workflows/skills-check.yml @@ -17,6 +17,10 @@ jobs: python-version: "3.12" - name: Install requirements (mcp SDK so MCP server tests run, not skip) run: python3 -m pip install --disable-pip-version-check -r requirements.txt + - name: Install test checker (deterministic real-process fixtures) + run: python3 -m pip install --disable-pip-version-check ruff==0.16.8 + - name: Check executable code quality + run: ruff check hooks scripts tests - name: Verify vendored skills match the lockfile digests run: python3 scripts/vendor/skill_vendor.py check --offline - name: Verify lockfile pins still match upstream refs and content diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index 480a10c..63bb61e 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,11 +5,11 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.11.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.", + "version": "0.12.0", + "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "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.", - "zh-CN": "跨语言代码规范强制门禁插件,专为 ZCode / Claude Code / Codex CLI / Kimi Code 等 AI 编程助手设计。PostToolUse 钩子在 AI 写完文件后自动跑对应语言 linter;严格模式下失败会阻塞 AI 继续。内含可直接接入的 .pre-commit-config.yaml 模板。" + "en": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", + "zh-CN": "基于证据的代码检查与 Git 内容门禁,含 Java 模块影响分析。保存钩子只反馈,未验证项明确标注。" }, "author": { "name": "Full Stack Skills / PartMe.AI", @@ -42,7 +42,7 @@ "strict_mode": { "type": "boolean", "title": "严格模式", - "description": "启用后 PostToolUse 钩子会在 linter 失败时返回非 0 退出码,AI 必须修复后才能继续", + "description": "保留兼容字段,当前 PostToolUse 恒 exit 0;确定违规仅由 Git PreToolUse 门禁拦截。", "default": true }, "auto_fix_on_save": { diff --git a/PRIVACY.md b/PRIVACY.md index 67e8267..0b65d04 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -8,17 +8,19 @@ - Does not phone home - Does not send telemetry, analytics, or crash reports -- Does not read or transmit your source code +- Reads local source files and Git blobs for checking; does not include a built-in source-upload or LLM telemetry service - Does not require authentication All operations happen locally: -- Linter execution (`mvn javadoc:jar`, `cargo clippy`, `npx eslint`, `ruff check`) +- Local process execution (Maven verify / Gradle check, cargo clippy, ESLint, ruff) - File reads (the file the AI just wrote) -- State files (`.session_state.json` for Stop hook summary) +- State files under CODEGUARD_HOME (default ~/.codeguard), diagnostic logs and temporary Git-content snapshots ## Network requests -The plugin **does not make network requests**. All linters it invokes are local binaries installed on your machine. +The Java planner only reads local build descriptions. Executed build tools, wrappers, project plugins and CVE scanners may access package registries, wrapper distributions and vulnerability databases. Online vendor verification contacts the skill source repository. A local subprocess is not a network sandbox. + +Project-defined code and tools run with the invoking user's permissions and may have their own data practices. Review untrusted project commands before executing checks. Logs may contain file paths and tool diagnostics; review them before sharing. If you opt into `pip install pre-commit`, pre-commit itself may download hook repositories from GitHub — but that's pre-commit's network policy, not ours. diff --git a/README.md b/README.md index da434b7..47d41db 100644 --- a/README.md +++ b/README.md @@ -1,365 +1,150 @@ -# partme-codeguard-plugin Plugin +# CodeGuard plugin -> Parity note: `README.md` and `README.zh-CN.md` must stay structurally aligned (heading levels, local links, version strings) — enforced by `tests/test_readme_parity.py`; mirror any structural edit into both files in the same commit. +> Parity: README.md and README.zh-CN.md must keep the same heading structure, local links and version strings; enforced by tests/test_readme_parity.py. -

- partme-codeguard-plugin — Make AI-written code pass lint on first try. Supports ZCode, Claude Code, Codex CLI, and Kimi Code. -

+[English](README.md) · [简体中文](README.zh-CN.md) -

- Lint on every AI-written file. Block on failure. Strict by default.
- Cross-language code style enforcement for AI coding assistants: Java / Rust / TypeScript / Python. -

- -

- English · - 简体中文 · - Architecture · - Technical roadmap -

- ---- +![CodeGuard](assets/banner.svg) ## Positioning -`partme-codeguard-plugin` makes AI coding assistants (ZCode, Claude Code, Codex CLI, Kimi Code) produce code that **passes linters on the first attempt**. Instead of finding out at commit-time that your AI forgot a Javadoc tag or used `unwrap()`, this plugin runs the right linter the moment the AI writes a file — and blocks the AI from continuing until the lint passes. - -It is a **constraint-type plugin** for AI assistants, not a productivity-type plugin: it produces no code itself, but enforces rules on the code the AI produces. +CodeGuard provides native check evidence and guards supported Git commit/push calls from AI coding assistants. PostToolUse gives feedback, not blocking. Verified violations block the Git call; unavailable checks remain explicitly UNVERIFIED. Passing a configured check is not proof of complete code correctness. -### Who it is for +Current development version: **0.12.0**. No new skills were added for this release: priority is verdict integrity and Java project awareness. -- Backend engineers whose AI assistant writes Java but skips Javadoc tags. -- Rust teams where `cargo clippy` is non-negotiable, but AI reaches for `unwrap()` out of habit. -- TypeScript / frontend teams tired of `any` types and unused imports from AI. -- Python teams who want `ruff` discipline on AI-generated code. -- Engineering leads who want **CI-like lint feedback inside the AI's "thinking mode"** instead of minutes later in PR review. +### Runtime boundaries -### What problem it solves - -| Problem | What this plugin provides | Verifiable entry point | +| Surface | What it checks | Result | |---|---|---| -| AI skips Javadoc tags, javadoc errors only surface on `mvn install` | PostToolUse hook auto-runs `mvn javadoc:jar` after each AI-written `.java` | `hooks/post_tool_lint.py`, [Architecture §3.2](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | -| AI uses `unwrap()` in Rust business code | `cargo clippy -- -D warnings` runs on each `.rs` file | [Architecture §2.1](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | -| AI introduces `any` and unused vars in TypeScript | `eslint --max-warnings 0` blocks the AI | `linters/eslint/recommended.cjs` | -| "did it pass lint?" is asked manually after every AI session | Stop hook summarizes lint pass/fail counts | `hooks/stop_summary.py` | -| pre-commit and CI catch issues 30s+5min late, by then the AI has moved on | Three-layer defense: hook (<2s) → pre-commit (30s) → CI (5min) | [Technical roadmap §1](docs/technical-roadmap.zh_CN.md) | - -## At a glance - -```text -AI writes file - │ - ▼ -┌──────────────────────────────────────────────────────────┐ -│ partme-codeguard-plugin │ -│ ① detect project language (java / rust / ts / python) │ -│ ② lint run native linter on the file │ -│ ③ auto-fix spotless / cargo fmt / eslint / ruff │ -│ ④ block exit 2 if lint still fails (strict mode) │ -│ ⑤ summary session-end lint pass/fail counts │ -└──────────────────────────────────────────────────────────┘ - │ - ▼ -AI code that passes lint on first try -``` +| PostToolUse | Edited file, for file-scoped tools | Feedback, exit 0; project-level checks deferred | +| UserPromptSubmit | Working-tree changes relevant to commit intent | Advisory, never blocks the user message | +| PreToolUse Git gate | Proposed index snapshot or HEAD snapshot for push | Verified violations exit 2; uncertain checks report UNVERIFIED and fail open | +| CLI check / MCP check_code_style | Project checks, including Java build verification | Explicit status, reason, raw exit code and output log | +| pre-commit / CI | Independently configured checks | Separate acceptance; not replaced by hook success | -| Property | Value | -|---|---| -| Plugin ID | `partme-codeguard-plugin` | -| Hosts | ZCode, Claude Code, Codex CLI, Kimi Code | -| Current version | `0.11.1` | -| ZCode manifest | `.zcode-plugin/plugin.json` | -| Codex manifest | `.codex-plugin/plugin.json` | -| MCP server | Published: stdio server via the official SDK (`check_code_style` / `auto_fix` / `list_languages`); see Quick start | -| Primary language | Python 3.10+ (hooks), YAML/JSON (config) | -| License | Apache-2.0 | - -## Supported languages - -**53 languages Stable (auto-enforced) + 4 Planned with platform tooling** — the widest coverage of any code-governance plugin. Every registered language has a SKILL; Planned languages are the ones without an independent CLI linter (platform IDE diagnostics only). Full per-language table: [docs/LANGUAGES.md](docs/LANGUAGES.md). - -| Status | Languages | -|---|---| -| **Stable** (53, auto-enforced) | Java, Rust, TypeScript/JavaScript, Python, Go, C#, Kotlin, Swift, PHP, Ruby, Scala, Shell, Dockerfile, YAML, Elixir, CSS/SCSS, Markdown, SQL, TOML, HTML, Protobuf, Terraform/OpenTofu, Nix, Dart, Solidity, Ansible-playbooks, Perl, Groovy, Clojure, PowerShell, Zig, Nim, Crystal, Julia (format-only), Pascal (format-only), Elm, Lua, Luau, C++ (clang-tidy), Objective-C, CUDA, GraphQL, Protobuf digest, VB.NET, Erlang, R, CFML — and more; see LANGUAGES.md | +Hooks do not run in every host command surface automatically. Historical V0.5.4 installation evidence is not acceptance of this version in Codex, ZCode or Kimi. -> **Markdown / YAML opt-in semantics**: both declare `requiresConfig` — without a root linter config -> (e.g. `.markdownlint-cli2.jsonc` / `.yamllint`) the project counts as not opted in: safely skipped, -> never blocked, never swept by tool default rules. The markdown gate is advisory (reported in skipped, non-blocking). -> Its lint command previously lacked a glob and always exited with a usage error; it now returns real results. -> `codeguard init` copies the lenient config template. -| **Planned** (4, no independent CLI linter) | Metal, ArkTS (HarmonyOS), COBOL, Liquid (Shopify theme-check 已列为工具,待接通) | +### Verdict contract -## Governance skills (Git & Security) - -Beyond linting, codeguard ships standalone governance skills sourced from the team's engineering-standards wiki: - -| Skill | Covers | -|---|---| -| `codeguard-git-branch` | 7 mainstream models — Gitflow, Gitflow+ (team), GitLab branch rules, GitHub Flow, GitLab Flow, Trunk-Based Development, OneFlow, Release Flow — with model detection, branch naming gates (`feature/{version}_{function}_{author}_{datetime}`), merge-direction gates, merge strategy (merge/squash/rebase) | -| `codeguard-git-commit` | Conventional Commits (Angular regex gate `linters/git/commit-msg`), Gitmoji prefixes, Udacity long-form, commitlint tooling | -| `codeguard-security-code` | Source & config leakage prevention, CVE dependency scanning (dependency-check / trivy / npm audit / MurphySec) | -| `codeguard-security-api` | Privilege-escalation guards (Shiro / Spring Security annotations), data-permission checks, 3-layer file-upload control, apikey+timestamp+signature | -| `codeguard-security-data` | Encrypted-at-rest fields (SM2/SM3/SM4 国密), response masking, single-device login, MLPS (等保) & commercial-crypto evaluation (密评) notes | -| `codeguard-dockerfile` | Dockerfile security risks — root user, latest tag, ADD abuse, sudo, secrets in layers, missing HEALTHCHECK (hadolint + trivy config) | - -The commit gate is pre-wired in the pre-commit template (`stages: [commit-msg]`); branch and security skills guide the AI during branch creation, interface development, and pre-merge review. - -### External skill source - -The 68 portable skills are authored in [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills), not independently inside this plugin. This repository vendors the complete `v0.1.2` snapshot so installed plugins work offline: +| Status | Meaning | passed | +|---|---|---| +| PASS | An actual check completed successfully | true | +| FAIL | The checker reported a violation | false | +| UNVERIFIED | Missing tool, timeout, invalid configuration, unavailable evidence | false | +| SKIPPED | No applicable changed files | false | +| PLANNED | A plan exists or no executable adapter is configured | false | -- `skills.lock.json` pins the upstream repository, immutable tag, resolved commit, managed skill names, and per-skill SHA-256 digests. -- `python3 scripts/vendor/skill_vendor.py update` refreshes only the skill names listed in the lock. -- `python3 scripts/vendor/skill_vendor.py check --offline` verifies the packaged snapshot; omit `--offline` to verify the upstream ref and content too. -- Do not directly edit a locked skill directory. Change and release `codeguard-skills`, update the lock ref, then run the vendor update. -- Plugin-specific skills may remain under `skills/` only when they are intentionally absent from `skills.lock.json` and explicitly listed in `plugin-local-skills.json`; the vendor preserves declared directories and rejects undeclared exceptions. +CLI exit priority: FAIL → 2; otherwise UNVERIFIED/PLANNED → 1; verified success or no applicable changes → 0. Never interpret “not exit 2” as “passed”. Tools have different exit-code contracts: pylint 2 is not ESLint 2. -Hooks, linters, commands, MCP wiring, and executable scripts remain plugin-owned. The authoring standard is documented in [docs/CODEGUARD_SKILLS_SPEC.md](docs/CODEGUARD_SKILLS_SPEC.md). +## Java project awareness -## Capabilities and boundaries +### Read-only planning -### Supported +```bash +codeguard java-plan /path/to/project --json +codeguard java-plan /path/to/project --json --changed api/src/main/java/Api.java +codeguard check --lang java /path/to/project +``` -| Capability | Input | Output | Limit | Status | -|---|---|---|---|---| -| Per-file language detection | file path from hook payload | language string (`java`/`rust`/`typescript`/`python`) | — | Stable | -| Language-specific lint | `mvn javadoc:jar` / `cargo clippy` / `npx eslint` / `ruff check` | exit code + stderr | timeout configurable (default 120s) | Stable | -| Auto-fix on failure | `mvn spotless:apply` / `cargo fmt` / `eslint --fix` / `ruff --fix` | retry lint with fixed files | best-effort, no business-logic changes | Stable | -| Commit/push gate | "commit" / "push" / "deploy" keyword in user prompt | run all linters, exit 2 if any fails | — | Stable | -| One-line project bootstrap | `/init` slash command | copy linter configs + `.pre-commit-config.yaml` + AGENTS.md snippet | — | Stable | -| Session-end summary | Stop hook | table of lint pass/fail/auto-fix counts | — | Stable | +The planner reads Maven POM / Gradle Groovy or Kotlin DSL, prefers project wrappers, maps files to modules and computes reverse transitive dependencies. Changing api can require checking service and app even if their files did not change. Deletions, resources and build descriptors are included. -### Three-layer defense +Maven plans use verify, with -pl and -am for a safe subset. Gradle plans use root check or affected :module:check tasks. Profiles, unresolved properties, inherited dependencies or recognized dynamic/composite Gradle builds expand the plan conservatively. Planning never executes a build, downloads dependencies, installs tools or initializes CodeGraph. -The plugin does not replace pre-commit or CI — it adds a **faster** layer in front of them. +### Explicit project commands -| Layer | Latency | Force | Purpose | -|---|---|---|---| -| **PostToolUse hook (this plugin)** | <2s | Block AI from continuing | Catch errors while the AI is still in "fix it now" mode | -| pre-commit | 30s | Block git commit | Catch errors when the user is ready to commit | -| CI | minutes | Block PR merge | Last-resort gate | +A root codeguard.json can declare authoritative argv lists: -PostToolUse is the **highest-ROI** layer because it gives the AI feedback **while it still cares**. +```json +{ + "java": { + "commands": [ + ["./mvnw", "verify", "-Pquality"] + ] + } +} +``` -### Not responsible for +Commands run in order, stopping on failure. They are trusted project configuration, not shell strings. Running check or the Git gate may execute project plugins/tests and access package registries; this is **not a sandbox**. -- Running your code. This plugin lints; it does not execute. -- Generating code. This plugin enforces rules on what AI generates. -- Replacing peer review. Linters catch mechanical errors; humans catch design errors. -- Cloud / SaaS linter services. This plugin is **strictly client-side** (see [PRIVACY.md](./PRIVACY.md)). -- Languages without an active linter yet (Planned tier above — their files are detected but safely skipped by the hook). +Coverage is module-level, not a symbol call graph or business-semantic proof. A successful verify/check does not establish that Checkstyle, PMD, SpotBugs or tests are configured comprehensively. Inspect the plan's gaps and reasons. -## Quick start +## Git content integrity -### CLI (codeguard) +A plain commit checks the index, not an unstaged repair. Supported preceding git add operations overlay predicted worktree paths; pure push checks HEAD and upstream differences. Without a resolvable upstream, the HEAD tree is checked. Sensitive-file rules use the same proposed scope; removing a sensitive file is not treated as introducing it. -`bin/codeguard` is a bash dispatcher: each subcommand (`check` / `fix` / `cve` / `dockerfile` / `detect`) routes to the matching `scripts/*.py` implementation. +Checks materialize temporary Git blobs without stash, checkout or modifying the real index. The exact-content gate does not reuse the soft working-tree cache. Missing ignored dependencies remain UNVERIFIED rather than silently falling back to different source content. -```bash -# Optional one-time setup: put the CLI on PATH -ln -s $PWD/bin/codeguard /usr/local/bin/codeguard - -codeguard check # multi-language lint gate -codeguard fix # auto-fix lint issues -codeguard cve # CVE dependency scan (Maven/npm/Python/Rust + universal trivy fallback) -codeguard cve --fix # scan + auto-fix (npm audit fix) -codeguard cve --severity MEDIUM # threshold-and-above: MEDIUM+HIGH+CRITICAL fail -codeguard cve --ecosystem java # alias for maven; unknown values exit 3 before any scan -codeguard detect # detect project languages -``` +Limits: 20,000 tracked files / 256 MiB Git content / 32 MiB per overlay file. Symlinks, submodules, conflicts and unsupported content need separate validation. Complex shell rewrites, arbitrary Git refspecs, dynamic aliases and concurrent edits are not a fully modeled transaction. A hook is not a replacement for protected-branch CI. -CVE exit codes: `0` pass, `1` unverifiable (tool missing / nothing scannable), `2` findings, `3` usage error. -Ecosystems without a native scanner fall back to `trivy fs --scanners vuln` when detected; native tools are never replaced by the fallback. Severity means threshold-and-above on every scanner (maven maps to CVSS band floors: HIGH⇒7). +There is no “historical debt” exemption based only on an unchanged diagnostic filename; a modified API can break an unchanged caller. -Maven CVE scanning uses OWASP dependency-check (pom snippet in -`linters/maven/dependency-check-pom-snippet.xml`; build fails at `CVSS>=7`). -**A finding must be fixed, not filed away**: every report ships with the fix command -per ecosystem (upgrade paths / suppression filing); npm supports `audit fix` auto-repair. +## CLI and MCP -### As a user +### CLI ```bash -# Step 1: install (one of these, per your host) -ln -s $PWD ~/.zcode/plugins/partme-codeguard-plugin -ln -s $PWD ~/.codex/plugins/partme-codeguard-plugin -ln -s $PWD ~/.kimi/plugins/partme-codeguard-plugin - -# Step 2: in any project, ask the AI: -/init # copy linter configs + .pre-commit + AGENTS.md -/check # run full lint suite with report -/fix # auto-fix what can be fixed +# Run directly from this checkout; no global installation required. +./bin/codeguard detect /path/to/project +./bin/codeguard check /path/to/project +./bin/codeguard fix /path/to/project --dry-run +./bin/codeguard fix /path/to/project +./bin/codeguard fix /path/to/project --all +./bin/codeguard cve /path/to/project --json +./bin/codeguard cve /path/to/project --ecosystem universal --severity HIGH ``` -### As an AI +fix defaults to Git-changed files; project-wide formatters require explicit --all. A non-Git CLI directory retains the legacy full-scope behavior. --fix may modify files; it is not a preview. -When this plugin is active, you do **not** need to do anything manually: - -- Every file you write is auto-linted immediately. -- If lint fails and cannot be auto-fixed, you will see the error and **must fix before continuing**. -- When the user says "commit", you will receive a final all-linters-must-pass gate check. -- At session end, you will see a summary of which lints passed/failed. +CVE exits: 0 pass, 1 unverified, 2 findings, 3 invalid ecosystem. Maven/npm/pip-audit/cargo-audit/Trivy results require structured report evidence. Network failures are not vulnerabilities. npm moderate maps to MEDIUM; after npm audit fix the new scan controls the verdict. Native Python/Rust findings without comparable severity remain UNVERIFIED above LOW, with findings preserved; explicitly select Trivy to assess severity. Python audits project requirements/pyproject, not the host environment. ### MCP server -`run_check.py --mcp` starts a stdio MCP server (official `mcp` SDK; install -dependencies with `pip install -r requirements.txt`) exposing three tools: - -| Tool | Purpose | -|---|---| -| `check_code_style` | Run lint and return a per-language envelope with `stderr_path` and `log_path` | -| `auto_fix` | Run the formatter chain, then re-run lint and embed the check envelope | -| `list_languages` | List supported language ids and display names (no internal commands) | - ```bash -python3 scripts/run_check.py --mcp . +# Requires the dependencies declared in requirements.txt. +python3 scripts/run_check.py --mcp /path/to/project ``` -Failed runs write the full combined output to `/out/.codeguard-last.log` -(the same path appears in the tool envelope and the CLI summary line; suppress -with `--quiet`). +| Tool | Contract | +|---|---| +| check_code_style | Per-language status/reason/passed/raw exit code and full output log path | +| auto_fix | Format Git-changed files and recheck the same scope; refuse unbounded project formatters; fixed means actual modifications | +| list_languages | Registry ids and display names | +| analyze_java_impact | Read-only plan; accepts path and optional changed array | -## Configuration +Output logs default to /out/.codeguard-last.log; CLI --quiet disables log writing. MCP auto_fix does not write when a Git scope cannot be established. -User config in `~/.zcode/settings.local.yaml` (ZCode) or equivalent for other hosts: +## Configuration and coverage -```yaml -codeguard: - enabled_languages: auto # or [java, rust, typescript, python] - strict_mode: true # PostToolUse exit 2 on lint fail (BLOCKS AI) - auto_fix_on_save: true # try spotless/cargo fmt/eslint --fix/ruff --fix first - lint_timeout_seconds: 120 -``` +Root codeguard.json may set gate_scope to delta or repo and customize extension/exclusion detection. User settings retain enabled_languages, auto_fix_on_save and lint_timeout_seconds. strict_mode is reserved and does not make PostToolUse block. See the [hook protocol](hooks/__protocol__.md). -| Key | Default | Effect | -|---|---|---| -| `enabled_languages` | `auto` (detect) | Restrict which linters run | -| `strict_mode` | `true` | Reserved, currently unwired: PostToolUse never blocks (always exit 0, see `hooks/__protocol__.md`) | -| `auto_fix_on_save` | `true` | Whether to attempt auto-fix before reporting failure | -| `lint_timeout_seconds` | `120` | Per-linter timeout | - -### Gate scope, escape audit, and unverified verdicts - -Gate scanning is scoped to **what you are about to change**, decided by git state: - -- **Commit face** (`staged + unstaged + untracked`): checked before `git commit`. - Pre-existing issues in HEAD do not block an unrelated new commit. -- **Push face** (commit face ∪ unpushed commits, `up...HEAD`): checked before - `git push`, so a bad commit made outside the gate is still caught on the way out. -- Project-level `codeguard.json` (repo root) overrides the default: - `{"gate_scope": "repo"}` restores full-repository scanning; `extensions` and - `exclude` customize language detection. Full scans always skip vendor/build - snapshots (immutable supply-chain content) and build-output directories - (`target`, `dist`, `build`, … — also injected into `find -print0` style gates, - so generated artifacts like maven-javadoc's `javadoc.sh` no longer trip the - shell gate). The >50-file delta fallback to a full command applies the same - exclusions. Java gate failures caused by unresolvable dependencies now carry - an actionable hint (run `mvn install` first) instead of a bare maven stack. - -Verdict honesty: a linter crashing with exit 2 (usage/dependency failure) is -reported as **unverified**, never as a lint failure — "cannot verify" is not -"verified bad", and a tool crash never triggers auto-fix. Exit 127 (command -missing) is split by context on purpose: interactive hooks skip it (never block -a person for missing tooling), while `check`/CI treats it as **failure** — a -health surface must go red when tooling is absent. - -Escape hatch: `git config codeguard.skipGate true` bypasses both the soft and -hard gate for one repository; every bypass is counted and surfaced by the Stop -summary, which also warns when the flag is left enabled. The audit keeps the -last 20 events (timestamp + repository + kind). Shared hook state lives -under `CODEGUARD_HOME` (default `~/.codeguard`): session lint statistics, -double-install dedup keys, and the skip audit. - -## Repository layout +The registry contains **54 Stable adapters and 3 Planned entries**. “Stable” does not certify every toolchain or project. Markdown/YAML require project configuration; missing configuration is UNVERIFIED. Markdown findings are advisory. Generated and dependency directories are excluded from ordinary lint scope, not automatically accepted for commit. Full command inventory: [languages](docs/LANGUAGES.md). -``` -partme-codeguard-plugin/ -├── .zcode-plugin/plugin.json # ZCode manifest (primary) -├── .codex-plugin/plugin.json # Codex CLI manifest -├── hooks/ -│ ├── hooks.json # 4 hook definitions -│ ├── env_check.py # SessionStart: detect language + inject rules -│ ├── post_tool_lint.py # PostToolUse: core enforcement hook -│ ├── user_prompt_validator.py # UserPromptSubmit: commit gate -│ └── stop_summary.py # Stop: session summary -├── scripts/ -│ ├── detect_lang.py # language detection + linter command table (shared) -│ ├── run_check.py # main CLI: detect + run all linters + report -│ ├── fix.py # auto-fix CLI -│ └── vendor/skill_vendor.py # lock-driven external skill vendor/check -├── skills.lock.json # upstream tag/commit + managed skills + SHA-256 digests -├── plugin-local-skills.json # explicit plugin-only skill exceptions (currently empty) -├── skills/ # 68 vendored skills from codeguard-skills v0.1.2 -│ ├── codeguard/ # main entry -│ ├── codeguard-init/ # one-line bootstrap -│ ├── codeguard-{java,rust,typescript,python}/ -│ ├── codeguard-{go,csharp,kotlin,swift,php,ruby,scala}/ # V0.2 languages -│ ├── codeguard-git-{branch,commit}/ # branch & commit governance -│ └── codeguard-security-{code,api,data}/ # security governance -├── commands/ # 3 slash commands (/check /fix /init) -│ ├── check.json -│ ├── fix.json -│ └── init.json -├── bin/codeguard # CLI entry (check / fix / cve / detect / init) -├── linters/ # copy-paste templates per language -│ ├── checkstyle/ # Alibaba P3C + javadoc enforced -│ ├── clippy/ # deny warnings + unwrap/expect/panic -│ ├── eslint/ # recommended + TS rules -│ ├── ruff/ # [tool.ruff] block -│ ├── maven/ # OWASP dependency-check pom snippet -│ └── git/ # commit-msg gate script -│ └── pre-commit/ # .pre-commit-config.template.yaml -├── docs/ -│ ├── partme-codeguard-plugin-Architecture.zh_CN.md -│ └── technical-roadmap.zh_CN.md -├── README.md # this file -├── README.zh-CN.md # Chinese -├── LICENSE # Apache-2.0 -├── PRIVACY.md # zero data collection -└── TERMS.md -``` - -## Compatibility +The explicit escape hatch git config codeguard.skipGate true bypasses the hook gate and is recorded in session summaries. Shared hook state lives under CODEGUARD_HOME (default ~/.codeguard). -| Host | Plugin manifest | Install path | Status | -|---|---|---|---| -| **ZCode** | `.zcode-plugin/plugin.json` | `~/.zcode/cli/plugins/cache//codeguard//` | ✅ V0.5.4 verified | -| **Codex CLI** | `.codex-plugin/plugin.json` | `~/.codex/plugins/cache//codeguard//` | ✅ V0.5.4 verified | -| **Claude Code** | (uses Codex manifest via marketplace) | `~/.claude/plugins/partme-codeguard-plugin/` | 🔧 V0.2 | -| **Kimi Code** | `kimi.plugin.json` | `~/.kimi-code/plugins/managed/codeguard/` | ✅ V0.5.4 verified | +## External skills -The hooks, scripts, linters, and skills are **shared across all hosts** — only the manifest differs. +The **68** portable skills are authored in [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills). This plugin packages immutable **v0.1.2** through skills.lock.json, pinning tag, commit and per-skill digests. -## Verification - -After install, smoke-test in any project: +Do not edit locked skill directories. Update/release the source skills, update the lock and run the vendor tool. Only declared entries in plugin-local-skills.json may be plugin-owned; currently none are declared. See [authoring rules](docs/CODEGUARD_SKILLS_SPEC.md). ```bash -# Should output ["java"] (or similar) and exit 0 -python3 scripts/detect_lang.py /path/to/java-project - -# Should print table of pass/fail per language -python3 scripts/run_check.py --timeout 60 - -# Should auto-fix what can be fixed and re-run lint -python3 scripts/fix.py --dry-run # see what would change -python3 scripts/fix.py # actually change +python3 scripts/vendor/skill_vendor.py check --offline +python3 scripts/vendor/skill_vendor.py check ``` -In any AI session, after writing a `.java` file, you should see in the AI log: +## Verification and remaining work +```bash +python3 -m unittest discover -s tests -q +python3 tests/run_all.py +python3 scripts/validate_languages_json.py +ruff check hooks scripts tests ``` -[codeguard] lint java: src/main/java/Foo.java -[codeguard] ❌ java lint failed for src/main/java/Foo.java -[codeguard] fix with: mvn -q spotless:apply -``` - -Exit code 2 if strict mode is on (the AI must fix); exit code 0 with warnings if strict mode is off. -## Related skills +Tests include real temporary Git repositories, native subprocess fixtures and official-SDK stdio MCP calls. Fixture wrapper success is not a real Maven/Gradle integration build. Live Codex/ZCode/Kimi loading, real project builds, online CVE scanner runs and precision/recall benchmarks require separate acceptance. -- **[full-stack-doc](https://github.com/partme-ai/skills/tree/main/full-stack-doc)** — Documentation standard this plugin's `docs/` follows. -- **partme-blender-plugin** — Sibling plugin using the same hook/manifest/skills pattern. +Current implementation and evidence: [architecture](docs/verdict-java-architecture.md), [verification report](docs/verification-verdict-java.md). Earlier design documents remain historical context: [original architecture](docs/partme-codeguard-plugin-Architecture.zh_CN.md), [roadmap](docs/technical-roadmap.zh_CN.md). -## License +## License and privacy -Apache-2.0 — see [LICENSE](./LICENSE). +Apache-2.0 — [LICENSE](./LICENSE). Native build/scanning tools may access dependency registries and vulnerability databases; review [PRIVACY.md](./PRIVACY.md) and [TERMS.md](./TERMS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 1a2bfc7..e580de5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,344 +1,150 @@ -# partme-codeguard-plugin 插件 +# CodeGuard 插件 -> 结构对齐:`README.md` 与 `README.zh-CN.md` 必须保持结构一致(标题层级、本地链接、版本串)——由 `tests/test_readme_parity.py` 门禁守护;结构性修改须同 commit 镜像到两份文件。 +> 结构对齐:README.md 与 README.zh-CN.md 的标题层级、本地链接和版本串必须一致,由 tests/test_readme_parity.py 检查。 -

- partme-codeguard-plugin — 让 AI 写的代码一次过 lint。支持 ZCode、Claude Code、Codex CLI、Kimi Code。 -

+[English](README.md) · [简体中文](README.zh-CN.md) -

- AI 写完每个文件自动 lint。失败即阻塞。严格模式默认开启。
- 面向 AI 编程助手的跨语言代码规范强制门禁:Java / Rust / TypeScript / Python。 -

- -

- English · - 简体中文 · - 架构文档 · - 技术方案 -

- ---- +![CodeGuard](assets/banner.svg) ## 定位 -`partme-codeguard-plugin` 让 AI 编程助手(ZCode、Claude Code、Codex CLI、Kimi Code)**第一次写出的代码就能通过 linter**。传统模式下你只能在 commit 时才发现 AI 漏写了 Javadoc 标签或者用了 `unwrap()`;本插件在 AI 写完文件的那一刻就跑对应 linter,**不通过则阻塞 AI 继续**。 - -它不是生产力插件,是**约束型插件**——它自己不产代码,而是对 AI 产出的代码执行规范。 - -### 适合谁 +CodeGuard 为 AI 编程助手提供原生检查证据,守护已支持的 Git 提交/推送调用。PostToolUse 只反馈、不阻断;确定违规会拦截 Git 调用;检查无法完成时明确 UNVERIFIED。某项检查通过,不等于代码完全正确。 -- **Java 后端工程师**:AI 写 Java 但漏 Javadoc 标签。 -- **Rust 团队**:`cargo clippy` 不容商量,但 AI 习惯用 `unwrap()`。 -- **TS / 前端团队**:受够了 AI 写 `any` 类型和未使用的 import。 -- **Python 团队**:希望 AI 写出的代码也守 `ruff` 规范。 -- **Tech Lead**:希望在 AI「思考模式」内就给反馈,而不是 PR review 时才发现。 +当前开发版本:**0.12.0**。本版没有增加技能数量,重点是判定可信度与 Java 项目感知。 -### 解决什么问题 +### 运行边界 -| 问题 | 本插件提供 | 可验证入口 | +| 入口 | 检查对象 | 结果 | |---|---|---| -| AI 漏 Javadoc,`mvn install` 时才报 | PostToolUse 钩子在每个 `.java` 写完自动跑 `mvn javadoc:jar` | `hooks/post_tool_lint.py`,[架构文档 §3.2](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | -| AI 在 Rust 业务代码用 `unwrap()` | `cargo clippy -- -D warnings` 跑每个 `.rs` 文件 | [架构文档 §2.1](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | -| AI 写 `any` 和未用变量 | `eslint --max-warnings 0` 阻塞 AI | `linters/eslint/recommended.cjs` | -| 每次会话都要手工问「过 lint 了吗?」 | Stop 钩子自动总结本会话 lint 通过/失败次数 | `hooks/stop_summary.py` | -| pre-commit / CI 发现时 AI 已切走,修复率 <30% | **三层防御**:钩子(<2s)→ pre-commit(30s)→ CI(5min) | [技术方案 §1](docs/technical-roadmap.zh_CN.md) | - -## 一览 - -```text -AI 写文件 - │ - ▼ -┌──────────────────────────────────────────────────────────┐ -│ partme-codeguard-plugin │ -│ ① detect 检测项目语言(java / rust / ts / python) │ -│ ② lint 对文件跑对应原生 linter │ -│ ③ auto-fix spotless / cargo fmt / eslint --fix / ruff │ -│ ④ block 仍失败则退出码 2(严格模式默认开启) │ -│ ⑤ summary 会话结束总结本轮 lint 通过/失败次数 │ -└──────────────────────────────────────────────────────────┘ - │ - ▼ -AI 一次写出就过 lint 的代码 -``` - -| 属性 | 值 | -|---|---| -| 插件 ID | `partme-codeguard-plugin` | -| 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.11.1` | -| ZCode manifest | `.zcode-plugin/plugin.json` | -| Codex manifest | `.codex-plugin/plugin.json` | -| MCP 服务 | 已发布:官方 SDK stdio 服务(`check_code_style` / `auto_fix` / `list_languages`);见快速开始 | -| 主要语言 | Python 3.10+(钩子)、YAML/JSON(配置) | -| 协议 | Apache-2.0 | +| PostToolUse | 文件型工具检查本次编辑文件 | 反馈、exit 0;项目级检查延后 | +| UserPromptSubmit | 与提交意图相关的工作树变更 | 建议,不阻断用户消息 | +| PreToolUse Git 门禁 | 拟提交 index 快照或推送 HEAD 快照 | 确定违规 exit 2;不确定项明确 UNVERIFIED 并 fail-open | +| CLI check / MCP check_code_style | 项目检查,含 Java 构建验证 | 明确状态、原因、原始退出码和日志 | +| pre-commit / CI | 独立配置的检查 | 单独验收,不能由钩子成功代替 | -## 支持的语言 +钩子不会自动覆盖宿主的每个命令入口。历史 V0.5.4 安装证据不能代表当前版本已在 Codex、ZCode、Kimi 验收。 -**53 种语言 Stable(默认强制)+ 4 种 Planned(依赖平台内置诊断)**——同类代码治理插件中最广的覆盖面。每种注册语言都有独立 SKILL;完整逐语言表格见 [docs/LANGUAGES.md](docs/LANGUAGES.md)。 +### 判定契约 -| 状态 | 语言 | -|---|---| -| **Stable**(53 种,默认强制) | Java、Rust、TypeScript/JavaScript、Python、Go、C#、Kotlin、Swift、PHP、Ruby、Scala、Shell、Dockerfile、YAML、Elixir、CSS/SCSS、Markdown、SQL、TOML、HTML、Protobuf、Terraform/OpenTofu、Nix、Dart、Solidity、Ansible、Perl、Groovy、Clojure、PowerShell、Zig、Nim、Crystal、Julia(仅格式化)、Pascal(仅格式化)、Elm、Lua、Luau、C++(clang-tidy)、Objective-C、CUDA、GraphQL、VB.NET、Erlang、R、CFML 等,详见 LANGUAGES.md | +| 状态 | 含义 | passed | +|---|---|---| +| PASS | 检查实际执行成功 | true | +| FAIL | 检查器发现违规 | false | +| UNVERIFIED | 缺工具、超时、配置错误、证据不可用 | false | +| SKIPPED | 没有适用的改动文件 | false | +| PLANNED | 已有计划,或尚未配置可执行适配器 | false | -> **Markdown / YAML 接入语义**:二者声明了 `requiresConfig`——项目根没有对应 linter 配置 -> (如 `.markdownlint-cli2.jsonc` / `.yamllint`)时视为**未接入**,安全跳过、不阻塞提交, -> 不会被工具默认规则全仓报错。Markdown 门禁为 advisory(告警进 skipped,不拦截); -> 此前其 lint 命令缺 glob、恒以用法错误退出,现已返回真实结论。`codeguard init` 会拷入宽松配置模板。 -| **Planned**(4 种,无独立 CLI linter) | Metal、ArkTS(HarmonyOS)、COBOL、Liquid(theme-check 待接通) | +CLI 优先级:FAIL → 2;否则有 UNVERIFIED/PLANNED → 1;验证成功或无须检查 → 0。不能把“非 2”解释为“通过”。工具退出码各有语义:pylint 的 2 不等于 ESLint 的 2。 -## 治理技能(Git 与安全) +## Java 项目感知 -### 外部技能来源 +### 只读规划 -68 个可复用技能统一在 [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills) 编写,插件不再维护一份独立手写副本。为保证插件安装后离线可用,本仓库 vendor 了完整的 `v0.1.2` 快照: +```bash +codeguard java-plan /path/to/project --json +codeguard java-plan /path/to/project --json --changed api/src/main/java/Api.java +codeguard check --lang java /path/to/project +``` -- `skills.lock.json` 固定上游仓库、不可变 tag、解析后的 commit、受管技能清单与逐技能 SHA-256。 -- `python3 scripts/vendor/skill_vendor.py update` 只刷新 lock 中列出的技能。 -- `python3 scripts/vendor/skill_vendor.py check --offline` 校验插件内快照;去掉 `--offline` 还会校验上游 ref 与内容。 -- 不得直接修改 lock 管理的技能目录。应先在 `codeguard-skills` 修改并发布,再更新 lock ref 并执行 vendor update。 -- 只有插件内部定制技能可以直接保留在 `skills/`,且必须明确不列入 `skills.lock.json`、显式登记到 `plugin-local-skills.json`;vendor 会保留已声明目录并拒绝未声明例外。 +规划器读取 Maven POM / Gradle Groovy、Kotlin DSL,优先项目 wrapper,将文件映射到模块,再计算反向传递依赖。修改 api 可能要求复查 service 和 app,即使调用方文件没有变化。删除、资源与构建描述变更均纳入分析。 -Hooks、linters、commands、MCP 接线和可执行脚本仍由插件仓负责。作者编写规范见 [docs/CODEGUARD_SKILLS_SPEC.md](docs/CODEGUARD_SKILLS_SPEC.md)。 +Maven 使用 verify,安全子集增加 -pl、-am;Gradle 使用根 check 或受影响的 :module:check。profiles、未解析属性、父依赖继承或识别到的动态/复合 Gradle 构建会保守扩大范围。规划不会执行构建、下载依赖、安装工具或初始化 CodeGraph。 -## 能力与边界 +### 项目权威命令 -### 已支持 +仓根 codeguard.json 可声明明确的 argv 列表: -| 能力 | 输入 | 输出 | 限制 | 状态 | -|---|---|---|---|---| -| 按文件检测语言 | 钩子 payload 中的 file_path | 语言字符串(`java`/`rust`/`typescript`/`python`) | — | Stable | -| 语言专属 lint | `mvn javadoc:jar` / `cargo clippy` / `npx eslint` / `ruff check` | 退出码 + stderr | 超时可配置(默认 120s) | Stable | -| 自动修复失败 | `mvn spotless:apply` / `cargo fmt` / `eslint --fix` / `ruff --fix` | 重跑 lint | 尽力修复,不改业务代码 | Stable | -| commit/push 门禁 | 用户 prompt 含 "commit" / "push" / "deploy" 关键词 | 全量 lint + 退出码 2(失败时) | — | Stable | -| 一行项目接入 | `/init` slash 命令 | 拷贝 linter 配置 + `.pre-commit-config.yaml` + AGENTS.md 片段 | — | Stable | -| 会话结束总结 | Stop 钩子 | 表格化 lint 通过/失败/自动修复次数 | — | Stable | +```json +{ + "java": { + "commands": [ + ["./mvnw", "verify", "-Pquality"] + ] + } +} +``` -### 三层防御 +命令顺序执行,失败停止。它们是可信项目配置,不是 shell 字符串。执行 check 或 Git 门禁可能运行项目插件、测试并访问依赖仓库;**这不是沙箱**。 -本插件**不**取代 pre-commit 和 CI——它在它们前面加一个**更快**的层。 +本轮覆盖模块级,不是符号调用图或业务语义证明。verify/check 成功不代表 Checkstyle、PMD、SpotBugs 或测试配置完整;应查看计划的 gaps 和 reasons。 -| 层 | 延迟 | 强制力 | 作用 | -|---|---|---|---| -| **PostToolUse 钩子(本插件)** | <2s | 阻塞 AI 继续 | AI 还在「立刻修」的模式时就抓住错误 | -| pre-commit | 30s | 阻塞 git commit | 用户准备提交时再次拦截 | -| CI | 分钟级 | 阻塞 PR merge | 最后兜底 | +## Git 内容一致性 -PostToolUse 是 **最高 ROI** 的层,因为 AI 在它「还在乎这个问题」时收到反馈。 +纯 commit 检查 index,不会拿未暂存的修复冒充提交内容。已支持的前序 git add 操作叠加预测工作树路径;纯 push 检查 HEAD 和上游差异。无可解析上游时检查 HEAD 树。敏感文件规则采用同一预测范围;删除敏感文件不视为新增入库。 -### 不做的事 +检查在临时目录物化 Git blobs,不 stash、不 checkout、不更改真实 index。精确内容门禁不复用软提醒的工作树缓存。快照缺 ignored 依赖时保持 UNVERIFIED,不改查另一份源码。 -- **不执行**你的代码。本插件只 lint,不运行。 -- **不生产**代码。本插件强制 AI 产出的代码遵守规范。 -- **不取代** code review。linter 抓机械错误,code review 抓设计错误。 -- **不接**云端 linter 服务。本插件**严格客户端运行**(见 [PRIVACY.md](./PRIVACY.md))。 -- **尚未启用 linter 的语言**(Planned 层,见上文——文件可被识别,但钩子会安全跳过)。 +限制:20,000 个已跟踪文件 / 256 MiB Git 内容 / 每个覆盖文件 32 MiB。符号链接、子模块、冲突及不支持的内容需要独立验证。复杂 shell 写入、任意 Git refspec、动态别名与并发编辑尚未完整建模;钩子不能替代受保护分支 CI。 -## 快速开始 +不再仅凭“报错文件没修改”豁免历史债:变更 API 也会破坏未修改的调用方。 -### CLI(codeguard) +## CLI 与 MCP -`bin/codeguard` 是 bash 分发器:每个子命令(`check` / `fix` / `cve` / `dockerfile` / `detect`)都路由到对应的 `scripts/*.py` 实现。 +### CLI ```bash -# 安装 CLI(可选):放到 PATH 后任意目录直接用 -ln -s $PWD/bin/codeguard /usr/local/bin/codeguard - -codeguard check # 跑多语言 lint 门禁 -codeguard fix # 自动修复 lint 问题 -codeguard cve # CVE 依赖漏洞扫描(Maven/npm/Python/Rust + universal trivy 兜底) -codeguard cve --fix # 扫描并自动修复(npm audit fix) -codeguard cve --severity MEDIUM # 「该级别及以上」:MEDIUM/HIGH/CRITICAL 都算失败 -codeguard cve --ecosystem java # maven 的别名;未声明生态在任何扫描前退出码 3 拒绝 -codeguard detect # 检测项目语言 +# 直接在此仓运行,不需要全局安装。 +./bin/codeguard detect /path/to/project +./bin/codeguard check /path/to/project +./bin/codeguard fix /path/to/project --dry-run +./bin/codeguard fix /path/to/project +./bin/codeguard fix /path/to/project --all +./bin/codeguard cve /path/to/project --json +./bin/codeguard cve /path/to/project --ecosystem universal --severity HIGH ``` -CVE 退出码:`0` 通过 / `1` 无法验证(工具缺失或无可扫描生态) / `2` 存在漏洞 / `3` 参数错误。 -未被原生扫描器覆盖的语言自动落 `trivy fs --scanners vuln` 通用兜底;原生工具缺失时保持「无法验证」,不用兜底顶替。`--severity` 在所有扫描器上都是「阈值及以上」(maven 按 CVSS 档位下界换算:HIGH⇒7)。 +fix 默认只修 Git 改动文件;整项目 formatter 需要显式 --all。非 Git 的 CLI 目录保留旧的全量行为。--fix 可能修改文件,不是预览。 -Maven 项目 CVE 扫描使用 OWASP dependency-check(pom 配置模板见 -`linters/maven/dependency-check-pom-snippet.xml`;`CVSS>=7 构建失败`)。 -**检查出来了得修**:报告会附带每个生态的修复命令(升级依赖 / 登记误报),npm 支持 `audit fix` 自动修复。 +CVE 退出码:0 通过、1 未验证、2 发现漏洞、3 非法生态。Maven/npm/pip-audit/cargo-audit/Trivy 必须有结构化报告证据;网络错误不是漏洞。npm moderate 映射 MEDIUM;npm audit fix 后以新扫描为准。Python/Rust 原生发现没有可比较严重度时,高于 LOW 的阈值返回 UNVERIFIED 并保留发现,可显式选择 Trivy 复核。Python 检查项目 requirements/pyproject,不扫宿主环境。 -### 作为用户 +### MCP 服务 ```bash -# 第一步:安装(按你用的平台选其一) -ln -s $PWD ~/.zcode/plugins/partme-codeguard-plugin -ln -s $PWD ~/.codex/plugins/partme-codeguard-plugin -ln -s $PWD ~/.kimi/plugins/partme-codeguard-plugin - -# 第二步:在任何项目里对 AI 说: -/init # 拷贝 linter 配置 + .pre-commit + AGENTS.md -/check # 跑全量 lint + 报告 -/fix # 自动修复 +# 需要 requirements.txt 已声明的依赖。 +python3 scripts/run_check.py --mcp /path/to/project ``` -### 作为 AI - -插件激活后,**你不需要**任何手动操作: - -- 你写的每个文件会被立刻 lint -- lint 失败且无法自动修复时,你会看到错误,**必须修复才能继续** -- 用户说「commit」时,你会受到「所有 linter 必须通过」的最后门禁 -- 会话结束时会看到本会话 lint 通过/失败的总结 - -### MCP 服务器 - -`run_check.py --mcp` 启动 stdio MCP 服务(官方 `mcp` SDK;先用 -`pip install -r requirements.txt` 安装依赖),暴露三个工具: - -| 工具 | 用途 | +| 工具 | 契约 | |---|---| -| `check_code_style` | 跑 lint,返回逐语言信封与 `stderr_path`、`log_path` | -| `auto_fix` | 先跑 formatter 链再复检 lint(嵌入 check 信封) | -| `list_languages` | 列出语言 id 与显示名(不含内部命令) | - -```bash -python3 scripts/run_check.py --mcp . -``` +| check_code_style | 逐语言状态、原因、passed、原始退出码与完整日志路径 | +| auto_fix | 只修 Git 改动文件、同范围复检,拒绝无界项目 formatter;fixed 表示实际修改 | +| list_languages | 注册表语言标识和名称 | +| analyze_java_impact | 只读计划,接受 path 和可选 changed 数组 | -失败时完整输出落盘到 `<项目根>/out/.codeguard-last.log`(与工具信封及 CLI -摘要行里的路径一致;`--quiet` 可关闭)。 - -## 配置 - -`~/.zcode/settings.local.yaml`(ZCode)或各平台对应文件: - -```yaml -codeguard: - enabled_languages: auto # 或 [java, rust, typescript, python] - strict_mode: true # PostToolUse 失败时退出码 2(阻塞 AI) - auto_fix_on_save: true # 先试 spotless/cargo fmt/eslint --fix/ruff --fix - lint_timeout_seconds: 120 -``` +日志默认在 /out/.codeguard-last.log;CLI --quiet 关闭写日志。MCP auto_fix 无法确定 Git 范围时不会写入。 -| 键 | 默认 | 作用 | -|---|---|---| -| `enabled_languages` | `auto`(自动检测) | 限定只跑哪些语言的 linter | -| `strict_mode` | `true` | 保留字段,当前未接线:PostToolUse 从不阻塞(恒 exit 0,见 `hooks/__protocol__.md`) | -| `auto_fix_on_save` | `true` | 是否在报错前先尝试自动修复 | -| `lint_timeout_seconds` | `120` | 每次 linter 调用的超时秒数 | - -### 门禁作用域、绕过审计与未验证判定 - -门禁只扫描**你即将改动的东西**,由 git 状态决定: - -- **提交面**(staged + 未暂存 + 未跟踪):`git commit` 前检查。HEAD 里的存量 - 问题不会拦截无关的新提交。 -- **推送面**(提交面 ∪ 未推送提交,`up...HEAD`):`git push` 前检查——绕过门禁 - 提交进历史的坏改动,出门时仍会被拦下。 -- 项目根的 `codeguard.json` 可覆盖缺省:`{"gate_scope": "repo"}` 恢复全仓扫描; - `extensions` 与 `exclude` 自定义语言识别。全仓扫描始终剔除 vendor/build - 快照(供应链不可变内容)与构建产物目录(`target`、`dist`、`build` 等, - 含 `find -print0` 型 gate——生成物如 maven-javadoc 的 `javadoc.sh` 不再让 - shell 门禁误红)。delta 超 50 文件回退全量命令时同样剔除。 - Java 门禁报依赖解析失败时附行动指引(`mvn install` 前置说明),而非只留 - maven 堆栈尾部。 - -判定诚实性:linter 以 exit 2(用法/依赖崩溃)退出时按**未验证**上报,绝不计 -为 lint 失败——"无法验证"不等于"验证失败",工具崩溃也不会触发自动修复。 -exit 127(命令不存在)**按场景有意分流**:交互钩子跳过它(不因工具缺失挡人); -`check`/CI 面按**失败**处理——健康面在工具缺失时就该变红。 - -逃生门:`git config codeguard.skipGate true` 对单仓同时豁免软门与硬门;每次 -绕过都会计数并由 Stop 汇总展示,标志遗留未清时也会提醒,审计还保留最近 20 -条明细(时间 + 仓库 + 类型)。钩子共享状态位于 -`CODEGUARD_HOME`(缺省 `~/.codeguard`):会话 lint 统计、双副本去重键与绕过 -审计。 - -## 仓库结构 +## 配置与覆盖 -``` -partme-codeguard-plugin/ -├── .zcode-plugin/plugin.json # ZCode manifest(主) -├── .codex-plugin/plugin.json # Codex CLI manifest -├── hooks/ -│ ├── hooks.json # 4 类钩子定义 -│ ├── env_check.py # SessionStart:检测语言 + 注入规则 -│ ├── post_tool_lint.py # PostToolUse:核心强制钩子 -│ ├── user_prompt_validator.py # UserPromptSubmit:commit 门禁 -│ └── stop_summary.py # Stop:会话总结 -├── scripts/ -│ ├── detect_lang.py # 语言检测 + linter 命令表(共享) -│ ├── run_check.py # 主 CLI:检测 + 跑全量 + 报告 -│ ├── fix.py # 自动修复 CLI -│ └── vendor/skill_vendor.py # lock 驱动的外部技能 vendor/check -├── skills.lock.json # 上游 tag/commit + 受管技能 + SHA-256 -├── plugin-local-skills.json # 插件专属技能显式例外清单(当前为空) -├── skills/ # 从 codeguard-skills v0.1.2 vendor 的 68 个技能 -│ ├── codeguard/ # 主入口 -│ ├── codeguard-init/ # 一行接入 -│ ├── codeguard-{java,rust,typescript,python}/ -│ ├── codeguard-{go,csharp,kotlin,swift,php,ruby,scala}/ # V0.2 语言 -│ ├── codeguard-git-{branch,commit}/ # 分支与提交规范 -│ └── codeguard-security-{code,api,data}/ # 安全规范 -├── commands/ # 3 个斜杠命令(/check /fix /init) -│ ├── check.json -│ ├── fix.json -│ └── init.json -├── bin/codeguard # CLI 入口(check / fix / cve / detect / init) -├── linters/ # 各语言配置模板(拷贝即用) -│ ├── checkstyle/ # 阿里 P3C + 强制 javadoc -│ ├── clippy/ # deny warnings + 禁 unwrap/expect/panic -│ ├── eslint/ # recommended + TS 规则 -│ ├── ruff/ # [tool.ruff] 块 -│ ├── maven/ # OWASP dependency-check pom 片段 -│ └── git/ # commit-msg 门禁脚本 -│ └── pre-commit/ # .pre-commit-config.template.yaml -├── docs/ -│ ├── partme-codeguard-plugin-Architecture.zh_CN.md -│ └── technical-roadmap.zh_CN.md -├── README.md # 本文件(英文) -├── README.zh-CN.md # 本文件(中文) -├── LICENSE # Apache-2.0 -├── PRIVACY.md # 零数据收集承诺 -└── TERMS.md -``` +仓根 codeguard.json 的 gate_scope 可选 delta/repo,也可定制扩展名和排除规则。用户设置保留 enabled_languages、auto_fix_on_save、lint_timeout_seconds。strict_mode 是保留字段,不会令 PostToolUse 阻断,详见[钩子协议](hooks/__protocol__.md)。 -## 三端兼容性 +注册表含 **54 个 Stable 适配器和 3 个 Planned 项**。“Stable” 不证明全部工具链或项目已验证。Markdown/YAML 需要项目配置,缺配置为 UNVERIFIED;Markdown 违规只告警。生成物和依赖目录从普通 lint 范围排除,不等于允许入库。完整命令见[语言清单](docs/LANGUAGES.md)。 -| 平台 | 插件 manifest | 安装路径 | 状态 | -|---|---|---|---| -| **ZCode** | `.zcode-plugin/plugin.json` | `~/.zcode/cli/plugins/cache//codeguard//` | ✅ V0.5.4 已验证 | -| **Codex CLI** | `.codex-plugin/plugin.json` | `~/.codex/plugins/cache//codeguard//` | ✅ V0.5.4 已验证 | -| **Claude Code** | (复用 Codex manifest,经 marketplace 安装) | `~/.claude/plugins/partme-codeguard-plugin/` | 🔧 V0.2 | -| **Kimi Code** | `kimi.plugin.json` | `~/.kimi-code/plugins/managed/codeguard/` | ✅ V0.5.4 已验证 | +显式逃生门 git config codeguard.skipGate true 会绕过钩子门禁,并在会话总结中记录。共享状态位于 CODEGUARD_HOME(默认 ~/.codeguard)。 -钩子、脚本、linter、skills 在所有平台**共享**——只有 manifest 不同。 +## 外部技能 -## 验证 +**68** 个可复用技能在 [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills) 编写。本插件通过 skills.lock.json 打包不可变的 **v0.1.2**,固定 tag、commit 与逐技能摘要。 -装好后,在任何项目里跑烟雾测试: +不得直接编辑受管技能。先修改并发布技能源,再更新 lock 并运行 vendor。只有 plugin-local-skills.json 显式登记项归插件自有,目前为空。详见[编写规范](docs/CODEGUARD_SKILLS_SPEC.md)。 ```bash -# 应输出 ["java"](或类似)并退出码 0 -python3 scripts/detect_lang.py /path/to/java-project - -# 应输出表格化的通过/失败报告 -python3 scripts/run_check.py --timeout 60 - -# 应自动修复能修的并重跑 lint -python3 scripts/fix.py --dry-run # 看会改什么 -python3 scripts/fix.py # 实际改 +python3 scripts/vendor/skill_vendor.py check --offline +python3 scripts/vendor/skill_vendor.py check ``` -在任何 AI 会话里,写完一个 `.java` 文件后,AI 日志里应看到: +## 验证与剩余工作 +```bash +python3 -m unittest discover -s tests -q +python3 tests/run_all.py +python3 scripts/validate_languages_json.py +ruff check hooks scripts tests ``` -[codeguard] lint java: src/main/java/Foo.java -[codeguard] ❌ java lint failed for src/main/java/Foo.java -[codeguard] fix with: mvn -q spotless:apply -``` - -严格模式下退出码 2(AI 必须修);非严格模式下退出码 0 仅警告。 -## 相关资源 +测试包含真实临时 Git 仓库、原生子进程 fixture 与官方 SDK stdio MCP 调用。fixture wrapper 成功不是真实 Maven/Gradle 集成构建。Codex/ZCode/Kimi 当前版本加载、真实项目构建、在线漏洞扫描与准确率/召回率基准仍需独立验收。 -- **[full-stack-doc](https://github.com/partme-ai/skills/tree/main/full-stack-doc)** — 本插件 `docs/` 遵循的文档规范 -- **partme-blender-plugin** — 同范式的兄弟插件(hooks/manifest/skills 模式) +当前实现与证据:[架构](docs/verdict-java-architecture.md)、[验证报告](docs/verification-verdict-java.md)。早期设计保留为历史参考:[原架构](docs/partme-codeguard-plugin-Architecture.zh_CN.md)、[路线图](docs/technical-roadmap.zh_CN.md)。 -## 协议 +## 许可与隐私 -Apache-2.0 — 见 [LICENSE](./LICENSE)。 +Apache-2.0 — [LICENSE](./LICENSE)。原生构建器/扫描器可能访问依赖仓库和漏洞数据库,请查看 [PRIVACY.md](./PRIVACY.md) 与 [TERMS.md](./TERMS.md)。 diff --git a/bin/codeguard b/bin/codeguard index 3a14d63..df5c413 100755 --- a/bin/codeguard +++ b/bin/codeguard @@ -21,6 +21,9 @@ cmd="${1:-check}" [ $# -gt 0 ] && shift || true case "$cmd" in + java-plan) + exec python3 "$ROOT/scripts/java_project.py" "$@" + ;; check) exec python3 "$ROOT/scripts/run_check.py" "$@" ;; @@ -49,7 +52,7 @@ EOF ;; *) echo "codeguard: 未知子命令 '$cmd'" >&2 - echo "可用: check | fix | cve | dockerfile | detect | init" >&2 + echo "可用: check | fix | cve | dockerfile | detect | java-plan | init" >&2 exit 1 ;; esac diff --git a/docs/LANGUAGES.md b/docs/LANGUAGES.md index 3221ebd..2c344e9 100644 --- a/docs/LANGUAGES.md +++ b/docs/LANGUAGES.md @@ -9,7 +9,7 @@ | 语言 | 扩展名 | Lint 命令 | Format 命令 | 安装说明 | |---|---|---|---|---| -| Java | `.java` | `mvn -q javadoc:jar -DskipTests` | `mvn -q spotless:apply` | — | +| Java | `.java` | `mvn -B verify` | `mvn -q spotless:apply` | — | | Rust | `.rs` | `cargo clippy --all-targets -- -D warnings` | `cargo fmt` | — | | TypeScript / JavaScript | `.ts` `.tsx` `.js` `.jsx` `.mjs` `.cjs` | `npx --no-install eslint . --max-warnings 0` | `npx eslint . --fix` | 项目需安装 eslint | | Python | `.py` | `ruff check .` | `ruff check . --fix` | pip install ruff | diff --git a/docs/partme-codeguard-plugin-Architecture.zh_CN.md b/docs/partme-codeguard-plugin-Architecture.zh_CN.md index a31cf2a..7342bc6 100644 --- a/docs/partme-codeguard-plugin-Architecture.zh_CN.md +++ b/docs/partme-codeguard-plugin-Architecture.zh_CN.md @@ -1,5 +1,7 @@ # partme-codeguard-plugin 系统架构设计 +> **历史设计,不作为当前行为证明**:下文保存早期架构思路,其中 PostToolUse 阻断、javadoc 默认命令、延迟和“全覆盖”等叙述已不适用。当前 0.12.0 实现以[判定与 Java 架构](verdict-java-architecture.md)、[钩子协议](../hooks/__protocol__.md)及 OpenSpec 为准;历史图表中的指标不是本次实测。 + > **文档说明**:本架构文档描述 partme-codeguard-plugin 插件的内部结构、模块划分、数据流与三端适配设计。 > > **版本**:V1.1 diff --git a/docs/technical-roadmap.zh_CN.md b/docs/technical-roadmap.zh_CN.md index c5b82c7..3ac51fc 100644 --- a/docs/technical-roadmap.zh_CN.md +++ b/docs/technical-roadmap.zh_CN.md @@ -1,5 +1,7 @@ # PartMe CodeGuard 技术方案与路线 +> **历史路线档案,不代表当前验收**:下文的 100% 准确率、<2 秒、零网络等数字未在本轮测量,不能当完成证据。0.12.0 已实现判定可信度与 Java 模块影响规划;当前事实见[架构与下一阶段](verdict-java-architecture.md)和[验证报告](verification-verdict-java.md)。后续优先真实项目验收、符号级影响、基线差分和修复复验,不扩张技能数量。 + > **文档说明**:本文档描述 partme-codeguard-plugin 插件的技术选型、关键决策、ADR(架构决策记录)和版本路线图。 > > **版本**:V1.0 diff --git a/docs/verdict-java-architecture.md b/docs/verdict-java-architecture.md new file mode 100644 index 0000000..d25ecee --- /dev/null +++ b/docs/verdict-java-architecture.md @@ -0,0 +1,83 @@ +# 判定可信度与 Java 项目感知 + +版本:0.12.0(本地开发态)。规格事实源:`openspec/specs/`;实施记录归档为 `openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/`。本轮不增加技能,不改外部受管技能。 + +## 1. 可执行链路 + +```mermaid +flowchart TD + A["Git commit / push 工具调用"] --> B["识别仓库与预测暂存范围"] + B --> C["git_snapshot:index / HEAD 临时内容树"] + B --> S["同范围入库安全规则"] + C --> D{语言} + D -->|Java| E["读取 Maven / Gradle 模块图"] + E --> F["变更所属模块 → 反向依赖闭包"] + F --> G["wrapper + verify / check 计划"] + D -->|其他| H["原生检查器 + 文件作用域"] + G --> I["执行检查,保留原始退出码与日志"] + H --> I + I --> J{结论} + J -->|FAIL| K["Git 调用 exit 2"] + S -->|违规| K + J -->|UNVERIFIED| L["明确未验证,兼容 fail-open"] + J -->|PASS| M["本次已执行检查通过"] +``` + +这不是安全沙箱,也不是完整 Git 事务模拟器。检查器继承当前用户权限;普通临时目录只保证不以原工作树作为检查内容。 + +## 2. 责任分配 + +| 模块 | 责任 | 禁止冒充的结论 | +|---|---|---| +| `scripts/verdict.py` | 状态、原始退出码、原因分离 | UNVERIFIED 不可变成 passed=true | +| `scripts/git_snapshot.py` | index/HEAD blobs、预测覆盖、删除范围、临时树 | 不 stash、不改真实 index、不拿工作树修复替代提交 | +| `scripts/java_project.py` | 只读图、影响闭包、命令规划 | 计划不是检查结果,模块图不是调用图 | +| `scripts/run_per_language.py` | 顺序执行计划、作用域、输出日志 | 第二文件失败不能被第一文件成功覆盖 | +| `scripts/run_check.py` | CLI 聚合、4 个 MCP 工具 | MCP 转换不能丢失不确定性 | +| `hooks/gate_lib.py` | 门禁并行编排、失败反馈 | 未修改诊断位置不能自动当历史债 | +| `scripts/cve_check.py` | 结构化漏洞报告、严重度阈值、复扫 | 网络/配置故障不能当漏洞或通过 | + +保留旧 `passed` 字段兼容消费者;新消费者应使用 `status` 与 `reason`,并区分原始工具退出码与 CLI 聚合退出码。hook 的 exit 0 仅说明未阻断,不等于 PASS。 + +## 3. Java 影响分析 + +```mermaid +flowchart LR + API["api:本次变更"] --> SERVICE["service:依赖 api"] + SERVICE --> APP["app:依赖 service"] + OTHER["other:独立模块"] + API -.-> PLAN["检查 api + service + app"] + SERVICE -.-> PLAN + APP -.-> PLAN +``` + +Maven 静态读取模块、坐标、属性及模块直接依赖,按反向传递闭包选目标,`-am` 补足其构建前置依赖;默认 `verify` 不主动跳过测试。Gradle 读取常见静态 include 与 project 依赖,默认 `check`;识别到动态 include、复合构建、projectDir、buildSrc、allprojects 等则回退根检查。 + +删除源码和资源变化保留所属模块;构建描述或 wrapper 变化扩大范围。无法解析的 XML/路径越界/非可执行 wrapper 返回 UNVERIFIED。`codeguard.json` 可声明权威 argv 列表替代默认命令,调用者对项目命令的信任仍不可省略。 + +边界:不能完整求值 Gradle 程序、Maven effective-POM、所有父 POM/插件注入依赖;也未实现符号级调用图、测试方法选择或数据流分析。动态逻辑可能超出当前识别模式,高风险项目应用 `gate_scope=repo` 或明确权威命令,并使用独立 CI。静态插件存在不证明已绑定生命周期,计划始终保留覆盖缺口说明。 + +## 4. 快照与修复安全 + +- 纯 commit 从 index 取内容;预测 add 从相应工作树覆盖;纯 push 从 HEAD 取内容。重命名按删除+新增保留影响范围。 +- 入库安全仍是路径/目录规则,不是 secret 内容扫描或 SAST。删除敏感文件不会误算为新增泄漏。 +- 快照拒绝符号链接、子模块、冲突和超限;不复制 ignored 的 node_modules 等依赖。缺依赖返回未验证。 +- 单文件保存不触发项目级 formatter。MCP auto_fix 默认只改 Git 改动文件,并报告实际变化;无法确定 Git 范围不写入。 +- 不提供复杂 shell 写入、任意 refspec、动态 alias、并发 index 更新的原子保障。临时内容检查完成到真实 Git 操作之间仍有时间窗口。 + +## 5. CVE 证据 + +扫描器输出必须是有效结构化报告。npm 按统计和阈值判定,`moderate` 对齐 MEDIUM;Maven 使用一次性目录接收 aggregate JSON,避免旧报告或多模块单模块报告覆盖;Trivy 请求 JSON 与独立漏洞退出码。Python 指定项目 requirements/pyproject,Rust 读取 cargo-audit JSON。 + +Python/Rust 报告中的发现若无法比较严重度,在高于 LOW 阈值时返回未验证并保留漏洞列表,不能猜为高危或通过。npm 修复后以复扫决定最终状态。本轮未实际访问 CVE 数据库进行扫描。 + +官方契约核对:[Dependency-Check aggregate 和 odc.outputDirectory](https://dependency-check.github.io/DependencyCheck/dependency-check-maven/aggregate-mojo.html)、[pip-audit 项目输入和 JSON](https://github.com/pypa/pip-audit)、[RustSec Report](https://docs.rs/rustsec/latest/rustsec/report/struct.Report.html)。 + +## 6. 下一阶段,不计入本次完成 + +1. 选真实 Maven reactor 与 Gradle 多模块仓,在受控环境跑集成验收;记录成本、工具版本、执行范围和检出结果。 +2. 建立缺陷种子集,分别统计假通过、误报、未验证率;不能拿结构测试数量代替准确率。 +3. 增加经授权的 effective model / 已有 CodeGraph 证据,推进符号级反向影响与测试选择;不静默初始化索引。 +4. 做同环境基线/变更双跑,再讨论历史债抑制;先证明诊断指纹一致,再豁免。 +5. 修复走建议补丁、变更范围审计、复检、人工采纳;高风险业务语义修复不自动执行。 +6. 另行讨论可配置 fail-closed 和 CI 接管策略;当前 hook 保留兼容 fail-open,不能声称所有未验证变更都会被阻断。 diff --git a/docs/verification-verdict-java.md b/docs/verification-verdict-java.md new file mode 100644 index 0000000..ca317f1 --- /dev/null +++ b/docs/verification-verdict-java.md @@ -0,0 +1,59 @@ +# 判定可信度与 Java 影响分析:本地验收记录 + +日期:2026-09-22。目标版本:0.12.0;Codex manifest:0.12.0+codex.20260922。 +实施基线:插件 main / `601ff64fa0141b1d092bca4632eb7fa61c074f4c`。本轮没有提交、推送、创建 tag/Release 或更新已安装宿主缓存。 + +## 验证结果 + +| 命令 / 证据 | 本地结果 | 能证明什么 | +|---|---|---| +| `PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -q` | 194 tests,OK,无 skip | 单测、真实临时 Git、wrapper 子进程、官方 SDK stdio MCP 契约 | +| `PYTHONDONTWRITEBYTECODE=1 python3 tests/run_all.py` | 141 通过 / 0 失败 / 0 跳过 | 原生工具抽测及 hook stdin/stdout/exit 协议 | +| `ruff check hooks scripts tests` | All checks passed,ruff 0.16.8 | 执行代码静态检查 | +| `python3 scripts/validate_languages_json.py` | 57 条(54 stable / 3 planned),11 schema rules passed | 注册表合法性,不代表 57 语言运行验收 | +| `python3 scripts/vendor/skill_vendor.py check --offline` | 通过 | 68 个受管技能未被篡改 | +| `python3 scripts/vendor/skill_vendor.py check` | 通过,v0.1.2 → `2c0c8071f96de48dc53e11de2499c083c100e44c` | 上游 tag、内容与 lock 对齐 | +| `openspec validate converge-verdicts-java-impact --strict` | 通过 | 增量规格结构一致 | +| `openspec archive converge-verdicts-java-impact --yes --json` | 通过;新增 8 / 修改 9 条 requirements | 已同步主规格并保留归档,9 项 tasks 完成 | +| 对本次六组主规格逐一 `openspec validate --type spec --strict` | 六组全部通过 | 合并后的本次行为契约合法 | +| 市场 `node scripts/sync-marketplaces.mjs --plugin=codeguard` | 通过 | 本地市场与插件 0.12.0 元数据一致,不代表远端 tag 已存在 | +| `node --check scripts/bump-plugin.mjs` / `bash -n bin/codeguard` / `git diff --check` | 通过 | 调度脚本语法和差异检查 | + +环境:macOS,Python 3.13.5。未安装/升级宿主 CLI、未初始化 CodeGraph、未变更技能源或 skills.lock.json。CI 配置增加固定版本 ruff,远端 CI 未执行。 + +归档位置:`openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/`。正式行为规格已合并到 `openspec/specs/`;真实项目与宿主验收仍属于后续工作,不因规格归档而自动通过。 + +## TDD 与规格追溯 + +本轮先看到失败,再实现:首批可信度 14 用例中 13 失败;Java 首批 12 用例失败。后续补充删除影响、资源影响、修复范围、CVE 报告、配置前置条件等,均先观察到对应失败,再修复后执行完整回归。 + +| 行为 | 可重跑测试 | +|---|---| +| 第二文件失败不能被首文件成功覆盖 | `test_second_file_failure_is_not_hidden_by_first_pass`、`test_scoped_check_runs_each_placeholder_file` | +| 工具错误不是 PASS,CLI/MCP 不丢失状态 | `test_tool_config_error_is_not_pass_and_survives_mcp`、`test_missing_tool_cli_is_nonzero_without_all_passed`、`test_cli_honors_linter_configuration_prerequisite` | +| index/HEAD 内容与工作树分离且原状态不变 | `test_staged_bad_worktree_clean_checks_index_and_preserves_both`、`test_push_uses_head_not_unstaged_repair` | +| 预测暂存、安全范围与删除影响 | `test_predicted_add_uses_worktree_and_expands_directory`、`test_safety_sees_predicted_add_and_allows_removal`、`test_deleted_file_is_retained_for_impact_but_not_safety` | +| 修复不扩张范围、不跟随符号链接,复检错误仍未验证 | `test_mcp_auto_fix_never_touches_clean_files`、`test_scoped_fix_does_not_follow_symlink_to_clean_file`、`test_post_tool_recheck_error_is_json_unverified_not_failure` | +| Maven/Gradle 图、反向闭包、wrapper 与保守回退 | `tests/test_java_project_impact.py` 的 15 项用例 | +| MCP 真正暴露并调用 Java 规划 | `test_mcp_boot_lists_four_tools`(真实 stdio JSON-RPC) | +| CVE 错误/阈值/JSON 报告分类 | `tests/test_verdict_integrity.py::CveEvidenceTests` | + +## 完整性、正确性与一致性审查 + +- 对照 verdict-integrity、java-project-impact、language-gate-commands、mcp-tool-server、cve-dependency-scan、hook-protocol 六组增量规格检查了入口与断言。 +- CLI/MCP/hook 共用状态语义;hook 仍保持原 fail-open 约定,不能把 exit 0 当成通过。 +- 删除“未修改文件报错必为历史债”的豁免;没有编造基线结果。 +- 双语 README 结构/链接/版本测试通过;老架构与路线图已标记历史,不再用旧延迟/准确率宣传作为验收。 +- 本地市场原为 0.11.0,源码为 0.11.1,已用生成脚本准备 0.12.0;修正本仓落后发版脚本的 ref/logo 同步。没有手改生成清单。 +- 所有改动保持在当前分支,未创建/切换分支;外部受管技能不变,因没有技能内容变更,本轮不重新跑 TRACE。 + +## 剩余边界,不得计为通过 + +1. 没有执行真实 Maven/Gradle 项目的联网构建。wrapper fixture 只证明参数、路由与进程处理,不证明业务项目构建成功。 +2. Java 是模块级静态图;不能完整解释任意 Gradle 程序、effective-POM、外部父 POM 注入依赖、符号调用和数据流。复杂项目需要根检查/权威命令和 CI。 +3. 临时快照不是执行沙箱;复杂 shell 链、任意 Git refspec、动态 alias、并发写入仍有盲区。符号链接/子模块/冲突/超限快照明确未验证。 +4. hook 未验证项仍放行,这是兼容策略;下一阶段需要单独设计可配置 fail-closed,不能宣传“全部守住”。 +5. 没有真实漏洞数据库扫描、准确率/召回率基准、Codex/ZCode/Kimi 当前版本现场加载、GitHub CI、tag 或 Release 证据。 +6. 额外执行全库 `openspec validate --specs --strict`:7 通过 / 7 失败。失败均为未触及的历史 Purpose 占位:asset-canonicalization、bilingual-docs-consistency、gate-trigger-policy、idiomatic-runner、languages-registry-contract、plugin-manifest-contracts、registry-driven-config。本次相关六组已通过,未扩张修改这些历史规格;不能宣称全库 strict 已清零。 + +建议下一次验收选择一个真实 Maven reactor 和一个 Gradle 多模块仓,植入接口破坏/删除、配置故障和不可修复问题,核对影响面、拒绝/放行结论与成本,再推进符号级影响分析。 diff --git a/hooks/__protocol__.md b/hooks/__protocol__.md index 0335d07..02a832e 100644 --- a/hooks/__protocol__.md +++ b/hooks/__protocol__.md @@ -14,7 +14,7 @@ |---|---|---|---|---|---| | SessionStart | `env_check.py` | 人类可读一行摘要 `codeguard 插件环境:...` | 仅内部错误时 | 0 | 否 | | UserPromptSubmit | `user_prompt_validator.py` | JSON `{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"..."}}` | 仅内部错误时 | 0 | 否 | -| PreToolUse `Bash` | `pre_tool_git_guard.py` | 通过:空 | 通过:空;失败:完整修复指令 | 0(通过)/ 2(拦截) | 通过:否;失败:是 | +| PreToolUse `Bash` | `pre_tool_git_guard.py` | 通过:空;未验证:JSON additionalContext | 通过:空;失败:完整修复指令 | 0(通过/放行)/ 2(拦截) | 放行:否;确定违规:是 | | PostToolUse `Write\|Edit\|MultiEdit` | `post_tool_lint.py` | JSON `{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"..."}, "systemMessage":"..."}` | 仅内部错误时 | 0 | 否 | | Stop | `stop_summary.py` | 人类可读会话摘要 | 仅内部错误时 | 0 | 否 | @@ -92,10 +92,10 @@ if __name__ == "__main__": **整个 Bash 工具调用**,此前未声明这一点,AI 反复把写文件与提交塞进同一调用 并误判"编辑被吞"。 -**禁止**在 lint skipped(工具未装/项目未接入/本次改动未涉及/exit 2 工具链异常/ -**存量归因**——delta 报错提到的文件全部在本次改动集之外)时 exit 2——这是 -「无法验证」而非「验证失败」;存量归因是防"历史债不还就永远提交不了 → 只能 -skipGate → 门禁信誉清零"的最后一道闸。 +**禁止**因工具未装、项目未接入、检查超时等 UNVERIFIED 状态 exit 2;保留 fail-open, +但 MUST 明确未验证,不能输出“全部通过”。没有适用改动是 SKIPPED,不等于工具故障。 +退出码按工具解释,不能全局把 2 当崩溃(例如 pylint 的 2 是违规)。 +**取消无基线存量归因**:报错在未修改文件上也可能是本次 API 变化造成的,不能凭路径放行。 **一致性约束**:`UserPromptSubmit` 软门禁与本硬门禁共用同一条 skipGate 豁免, 且**都不得在非 git 目录回退成"扫描 cwd"**——UPS 对非 git 目录输出一行 @@ -108,14 +108,21 @@ payload 不带这些字段(测试协议/其它宿主)时不去重,保持 纯 `git commit` → 仅暂存区;`git add -A/-a/-u` 或 `commit -a` → 相应扩到 未暂存/未跟踪;`git add ` → 并入这些路径——add 在 PreToolUse 时**尚未执行**, 不并入会漏检"即将暂存"的文件;并行会话留在工作树的未暂存 WIP 不属于本次提交, -曾因此被误拦);push 面 = 并集未推送提交(`up...HEAD`)。lanes/extra 必须进 -缓存键——同一工作树状态下窄面 pass 不得被宽面复用(staged 干净 + 未暂存有病 -时,纯 commit 通过的结果若被 `add -A && commit` 复用 = 绕过)。 +曾因此被误拦);纯 push 在 HEAD 快照中检查未推送差异(`up...HEAD`),无上游时检查 HEAD 树。 +commit+push 使用预测提交快照并合并未推送范围。删除文件进入影响分析,不作为新增敏感文件。 +硬门禁从 Git blobs 物化一次性目录,不借真实 .git,不修改原 index/工作树,也不复用软门禁缓存。 +符号链接/子模块/冲突、超限、依赖不可用都明确 UNVERIFIED。快照是内容隔离,不是执行沙箱; +复杂命令链、非 HEAD refspec、并发修改仍需独立 CI 验证。 UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不传 lanes = 三路宽口径**——软门禁没有待执行命令可预测,按"工作树有待提交改动就提醒"注入 (注入非阻断,多提醒不算错;硬门禁少拦才是底线),软硬两门只在 skipGate 豁免 上严格一致。 +PostToolUse 只运行可限定到单文件的检查和 formatter。append_files=false 的项目命令推迟到 +显式 check/Git 门禁;保存不能触发整项目 formatter。工具异常不能自动修复。 +CLI/MCP 共用 PASS/FAIL/UNVERIFIED/SKIPPED/PLANNED,只有 PASS 的 passed=true。 +CLI 的 0/1/2 与 hook 的 fail-open 退出码是不同协议,不能混用。 + --- ## 5. 新增 hook checklist @@ -150,4 +157,4 @@ UserPromptSubmit 按提示词里的 `push/推送` 选 commit/push 面,但**不 | §3 Fail-open | `hook-protocol::Requirement: The cheat-sheet SHALL document fail-open` | | §5 新增 hook checklist | `hook-protocol::Requirement: tests/run_all.py SHALL reference the cheat-sheet` | -变更通过 `openspec archive add-host-protocol-cheatsheet` 入库;spec 在 `openspec/specs/hook-protocol/spec.md`。 \ No newline at end of file +变更通过 `openspec archive add-host-protocol-cheatsheet` 入库;spec 在 `openspec/specs/hook-protocol/spec.md`。 diff --git a/hooks/gate_lib.py b/hooks/gate_lib.py index b3ea2d9..4dfc101 100644 --- a/hooks/gate_lib.py +++ b/hooks/gate_lib.py @@ -66,24 +66,20 @@ def _git(project_root: Path, *args: str) -> str | None: return proc.stdout if proc.returncode == 0 else None -def check_commit_safety(project_root: Path, mode: str) -> list[tuple[str, str, str]]: +def check_commit_safety(project_root: Path, mode: str, *, lanes=None, extra=None, + pending_commit=False) -> list[tuple[str, str, str]]: """检查即将进入版本库的文件(commit=暂存区;push=未推送提交的 diff)。 返回违规列表 [(路径, 命中规则, 建议操作)];无法判定(非 git 仓/无对比基线) 返回空列表并由调用方按 skipped 处理——安全检查不做静默失败。 """ - if mode == "commit": - out = _git(project_root, "diff", "--cached", "--name-only", "-z") - else: # push:检查所有未推送提交触及的文件 - out = _git(project_root, "diff", "--name-only", "-z", "@{upstream}..HEAD") - if out is None: - out = _git(project_root, "diff", "--name-only", "-z", "origin/main..HEAD") - if not out: - return [] + from git_snapshot import proposed_paths + paths = proposed_paths(project_root, mode, lanes=lanes, extra=extra, + pending_commit=pending_commit) import fnmatch violations: list[tuple[str, str, str]] = [] - for raw in out.split("\0"): + for raw in paths: f = raw.strip() if not f: continue @@ -218,6 +214,8 @@ def run_gate( mode: str = "commit", lanes: tuple[str, ...] | list[str] | None = None, extra: tuple[str, ...] | list[str] | None = None, + exact: bool = False, + pending_commit: bool = False, ) -> tuple[list, list]: """运行 linter 门禁(跨进程结果缓存 + 并行执行)。 @@ -236,6 +234,21 @@ def run_gate( - skipped: [str] 无法验证的说明(工具未装/超时),不阻塞 """ import time as _time + if exact: + from git_snapshot import SnapshotError, validation_tree + from scope import is_build_artifact + try: + with validation_tree(project_root, mode, lanes=lanes, extra=extra, + pending_commit=pending_commit) as (snapshot, paths): + selected = languages if languages is not None else detect_languages(snapshot) + enabled = cfg.get("enabled_languages", []) + if enabled and enabled != ["auto"]: + selected = [lang for lang in selected if lang in enabled] + requested_scope = (get_overrides(snapshot) or {}).get("gate_scope") or "delta" + return _run_gate_uncached(snapshot, cfg, selected, scope=requested_scope, + changed=[p for p in paths if not is_build_artifact(p)]) + except (SnapshotError, OSError, ValueError) as exc: + return [], [f"git UNVERIFIED:无法验证准确内容快照:{exc}"] if languages is None: languages = detect_languages(project_root) if not languages: @@ -327,31 +340,9 @@ def _mentioned_files(full: str, project_root: Path) -> set[str]: def _stale_attribution(full: str, project_root: Path, lang: str, lang_files: list[str]) -> str | None: - """delta 面存量归因:报错提到的文件全部在本次改动集之外 → 存量,不拦。 - - 永久红场景(实测):项目级命令(mvn javadoc:jar)因工具链/历史债在**与本次 - 改动无关**的文件上失败,若一律按 failure 就变成"不还历史债就永远提交不了" - → 用户只能 skipGate,门禁信誉清零。返回 skipped 说明(含完整日志路径)或 - None(无法归因/有交集 → 维持 failure,宁可多拦不漏拦)。 - """ - mentioned = _mentioned_files(full, project_root) - if not mentioned: - return None - changed_set = set(lang_files) - if mentioned & changed_set: - return None - shown = sorted(mentioned) - head = ", ".join(shown[:3]) + ("…" if len(shown) > 3 else "") - try: - path = _log_path(project_root, lang) - path.write_text(full, encoding="utf-8") - tail = f";完整输出: {path}" - except OSError: - tail = "" - return ( - f"{lang} 报错均位于本次改动之外的存量文件({head},共 {len(shown)} 个)" - f"——不拦本次提交{tail}。如需清偿存量问题请单独修复或建 baseline。" - ) + """保留旧调用契约;没有实际基线复跑,禁止凭文件位置豁免失败。""" + # 没有同命令/同工具版本的基线证据,未修改调用方也可能被本次 API 变更破坏。 + return None def _run_gate_uncached( @@ -374,12 +365,25 @@ def _run_gate_uncached( def check(lang: str): cmd_def = LANG_COMMANDS.get(lang) if not cmd_def: - return None + return (lang, None, f"{lang} PLANNED:没有可执行检查命令") + if lang == "java": + from run_per_language import run_check + outcome = run_check([lang], project_root, timeout=cfg.get("lint_timeout_seconds", 120), + files=changed if scope == "delta" else None, + log_dir=_log_path(project_root, lang).with_suffix(""))[0] + if outcome["status"] == "PASS": + return None + if outcome["status"] != "FAIL": + return (lang, None, f"java {outcome['status']}: {outcome['reason']}") + detail = _truncate_detail(outcome.get("stdout_tail", "") + outcome.get("stderr_tail", ""), project_root, lang) + if outcome.get("log_path"): + detail += f"\n完整输出: {outcome['log_path']}" + return (lang, (lang, detail, "按 Java 影响计划修复并复跑 verify/check", "优先项目 wrapper"), None) lang_files: list[str] = [] if scope == "delta": lang_files = [ f for f in (changed or []) - if detect_language(f, project_root) == lang + if detect_language(f, project_root) == lang and (project_root / f).is_file() ] if not lang_files: return (lang, None, f"{lang} 本次改动未涉及,跳过") @@ -431,23 +435,19 @@ def check(lang: str): cmd = scope_cmd(base_cmd, project_root, full_excludes=True) outputs.append(_run_one(cmd, timeout, lang, hint)) - rc, out, err = outputs[0] - if rc == 124: - return (lang, None, f"{lang} 检查超时(>{timeout}s),本次未验证") + # 任一后续文件失败都不能被首个文件的成功覆盖。 + rc, out, err = next((entry for entry in outputs if entry[0] != 0), outputs[0]) if rc == 0: return None + from verdict import UNVERIFIED, lint_verdict + status, reason = lint_verdict(rc, base_cmd, out + err) + if status == UNVERIFIED: + return (lang, None, f"{lang} 工具链异常未验证:{reason} (exit {rc})") if lang == "markdown": return (lang, None, "markdown 风格告警(不阻塞提交)") - if rc == 127: - return (lang, None, f"{lang} 工具链异常未验证:命令不存在(exit 127)") - if rc == 2: - # exit 2 = 工具用法/依赖/配置崩溃,与仓库内容无关(与 markdownlint - # 用法错误同族)。按"无法验证≠验证失败"归 skipped,绝不拦提交。 - head = next((ln.strip() for ln in f"{out}\n{err}".splitlines() if ln.strip()), "") - return (lang, None, f"{lang} 工具链异常未验证:exit 2(非 lint 结论){('|' + head[:80]) if head else ''}") full = "\n".join(seg for seg in ((out or "").rstrip(), (err or "").rstrip()) if seg) if scope == "delta" and lang_files: - # 存量归因:项目级命令在与本次改动无关的文件上失败 → skipped 不拦 + # 无基线时不做“历史债”推断;保留接口供未来双跑基线扩展。 stale = _stale_attribution(full, project_root, lang, lang_files) if stale is not None: return (lang, None, stale) @@ -642,8 +642,7 @@ def format_failure_report(failures: list) -> str: summarize_failures(failures), "─" * 60, ] - for block in _failure_detail_blocks(failures): - lines.append(block) + lines.extend(_failure_detail_blocks(failures)) lines.append(f"一键尝试自动修复: python3 {PLUGIN_ROOT}/scripts/fix.py") return "\n".join(lines) diff --git a/hooks/post_tool_lint.py b/hooks/post_tool_lint.py index 0661ac2..224dd31 100644 --- a/hooks/post_tool_lint.py +++ b/hooks/post_tool_lint.py @@ -27,7 +27,10 @@ project_uses_linter, ) from gate_lib import codeguard_home, session_state_path -from scope import is_build_artifact, scope_cmd # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) +from scope import ( # 状态目录 ~/.codeguard(可 CODEGUARD_HOME 覆盖) + is_build_artifact, + scope_cmd, +) # 双副本去重:同一插件可能以多个 marketplace 副本安装(partme-ai/ 与 # full-stack-plugins/ 各一份,钩子双份触发——实测),用户级固定路径跨副本共享 @@ -228,6 +231,14 @@ def main() -> int: if not cmd_def: return 0 + if not cmd_def.get("append_files", True): + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"codeguard: {lang} 是项目级检查;本次保存未验证," + "请使用 codeguard check 或提交门禁。不会自动格式化整个项目。", + }}, ensure_ascii=False)) + return 0 + # 项目未接入该 linter(无配置文件)时,生态型 linter(eslint)必然报错 # 退出——那是「未接入」不是「代码违规」(qumall-mall-ui 无 .eslintrc 实测) if not project_uses_linter(cmd_def, project_root): @@ -253,6 +264,16 @@ def materialize(cmd: list[str]) -> list[str]: rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) + from verdict import UNVERIFIED, lint_verdict + verdict, reason = lint_verdict(rc, lint_cmd, stdout + stderr) + if verdict == UNVERIFIED: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"codeguard: UNVERIFIED [{lang}] {reason} (exit {rc});" + "没有代码结论,不自动修复。", + }}, ensure_ascii=False)) + return 0 + if rc == 0: bump_state(lang, passed=True) # 注入 AI 上下文:告诉 AI 该文件通过了门禁 @@ -271,7 +292,6 @@ def materialize(cmd: list[str]) -> list[str]: if cfg.get("auto_fix_on_save", True): fmt_cmd = cmd_def.get("format") if fmt_cmd: - print(f"[codeguard] ⚠️ {lang} lint failed, attempting auto-fix...") def _porcelain() -> set[str]: try: @@ -297,9 +317,16 @@ def _porcelain() -> set[str]: name for name in (after - before) if not name.rstrip("/").endswith(Path(file_path).name) ) - print("[codeguard] ✅ auto-fix succeeded, re-running lint...") rc, stdout, stderr = run(materialize(lint_cmd), cwd=project_root, timeout=timeout) + verdict, reason = lint_verdict(rc, lint_cmd, stdout + stderr) + if verdict == UNVERIFIED: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": f"codeguard: UNVERIFIED [{lang}] 修复后复检未完成:{reason};" + "formatter 可能已修改文件,请重新读取。没有通过/失败结论。", + }}, ensure_ascii=False)) + return 0 passed = rc == 0 bump_state(lang, passed=passed, auto_fixed=auto_fixed) diff --git a/hooks/pre_tool_git_guard.py b/hooks/pre_tool_git_guard.py index 88a4604..403e8e4 100755 --- a/hooks/pre_tool_git_guard.py +++ b/hooks/pre_tool_git_guard.py @@ -348,15 +348,12 @@ def staging_intent(command: str) -> tuple[tuple[str, ...], list[str]]: if sub == "add": flags = [t for t in rest if t.startswith("-")] paths = [t for t in rest if not t.startswith("-")] - if not rest or any(f in ("-A", "-a", "-u", "--all", "--ignore-removal") for f in flags) \ - or any(p.strip("\"'") in (".", "./", "*", ":/") for p in paths): - add_all = True - elif paths: + if paths and not any(p.strip("\"'") in (".", "./", "*", ":/") for p in paths): add_paths.extend(p.strip("\"'") for p in paths) - else: - add_all = True # 无法预测形态时按宽口径(宁可多拦) - if any(f in ("-u", "--update") for f in flags): + elif any(f in ("-u", "--update") for f in flags): add_tracked_only = True + else: + add_all = True elif sub == "commit" and any(t in ("-a", "-am", "--all") for t in rest): lanes.update(("staged", "unstaged")) if add_all: @@ -366,7 +363,7 @@ def staging_intent(command: str) -> tuple[tuple[str, ...], list[str]]: for p in add_paths: if Path(p).is_absolute(): continue - cand_root = last_cd if last_cd is not None else Path(".") + cand_root = last_cd if last_cd is not None else Path.cwd() # git add 的 pathspec 相对 shell 语境(last_cd 或 cwd)解析成绝对路径, # 再换算成**仓库根**相对路径——changed_files 的 paths 全部相对仓根; # cd 到仓库子目录(scripts/…)时相对 last_cd 会算错一截。 @@ -472,20 +469,29 @@ def main() -> int: # 按命令链预测实际提交面(纯 commit → 仅 staged;add -A/-a → 三路), # 并入 git add 显式路径(add 尚未执行、暂存区还是旧的)。 lanes, extra = staging_intent(command) + pending_commit = "commit" in _collect_subs(command) # 每个被操作的仓库独立跑:linter 门禁 + 提交内容安全检查 # (commit 查暂存区;push 查未推送提交的 diff,防已提交未发现的坏文件) reports = [] for project_root in roots: - # extra 路径按各仓存在性过滤:多仓链里 add 的路径只属于其中一个仓, - # 幻影路径喂给 {file} 型 linter 会得到"文件不存在"的假失败。 - root_extra = [p for p in extra if (project_root / p).exists()] + # 保留删除路径用于影响分析;单文件 linter 自行过滤不存在的文件。 + root_extra = list(extra) failures, _skipped = run_gate( project_root, cfg, mode=mode, lanes=lanes, extra=root_extra, + exact=True, pending_commit=pending_commit, ) if failures: reports.append(gate_directive(failures)) - violations = check_commit_safety(project_root, mode) + unknown = [s for s in _skipped if "本次改动未涉及" not in s and " SKIPPED:" not in s + and "markdown 风格告警" not in s] + if unknown: + print(json.dumps({"hookSpecificOutput": { + "hookEventName": "PreToolUse", "additionalContext": + "codeguard: 存在未验证项,不能宣称全部通过:" + ";".join(unknown), + }}, ensure_ascii=False)) + violations = check_commit_safety(project_root, mode, lanes=lanes, extra=root_extra, + pending_commit=pending_commit) if violations: reports.append(format_safety_report(violations) + ( "\n\n**给 AI 的强制指令**:**密钥/凭据类**(.env、*.pem、id_* 等)" diff --git a/hooks/user_prompt_validator.py b/hooks/user_prompt_validator.py index 2f8a24f..c60ce05 100755 --- a/hooks/user_prompt_validator.py +++ b/hooks/user_prompt_validator.py @@ -221,12 +221,15 @@ def main() -> int: skipped_langs = {s.split()[0] for s in skipped} checked = sorted(set(detect_languages(project_root)) - skipped_langs) skipped_note = f";跳过 {len(skipped)} 项({';'.join(skipped)})" if skipped else "" + unknown = [s for s in skipped if "本次改动未涉及" not in s and " SKIPPED:" not in s + and "markdown 风格告警" not in s] print(json.dumps({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": ( - f"codeguard ✅ 提交门禁通过:已检查 {len(checked)} 个语言生态 + 暂存区安全" - f"({', '.join(checked) or '无'}),可以提交{skipped_note}。" + ("codeguard ⚠️ 检查范围存在未验证项,不能宣称全部通过" if unknown + else f"codeguard ✅ 提交门禁通过:已检查 {len(checked)} 个语言生态 + 暂存区安全") + + f"({', '.join(checked) or '无'}){skipped_note}。" ) } }, ensure_ascii=False)) diff --git a/kimi.plugin.json b/kimi.plugin.json index fdf9c4a..ee3d065 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,7 +1,7 @@ { "name": "codeguard", - "version": "0.11.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.", + "version": "0.12.0", + "description": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit.", "author": { "name": "Full Stack Skills / PartMe.AI" }, @@ -21,8 +21,8 @@ ], "interface": { "displayName": "代码规范守卫", - "shortDescription": "Make AI-written code pass lint on first try", - "longDescription": "Detects project languages, runs the appropriate linter (mvn javadoc+checkstyle, cargo clippy+fmt, npx eslint, ruff check) on every file the AI writes or edits, and blocks further writes when lints fail. Ships ready-to-use .pre-commit-config.yaml templates and AGENTS.md snippets for one-line project bootstrap.", + "shortDescription": "Trustworthy code checks and Java impact analysis", + "longDescription": "Evidence-backed code checks and Git content gates for AI assistants, with Maven/Gradle module impact analysis. Save hooks provide feedback; unverified checks are explicit. Java planning is read-only; executing project checks runs trusted build/test commands, not a security sandbox.", "developerName": "Full Stack Skills / PartMe.AI", "websiteURL": "https://github.com/partme-ai/partme-codeguard-plugin" }, diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/.openspec.yaml b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/.openspec.yaml new file mode 100644 index 0000000..1b9acb7 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/design.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/design.md new file mode 100644 index 0000000..d5f21f0 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/design.md @@ -0,0 +1,31 @@ +# 实施设计 + +## Context + +动机见 proposal.md。保留 Python stdlib、现有三端 hook 契约和外部技能所有权,不引入新框架。现有 main 干净,在当前分支实施,不自动创建分支。 + +## Goals / Non-Goals + +- 目标:结论可信、检查对象准确、Java 模块级影响分析可解释。 +- 非目标:完整符号级调用图、业务语义自动修复、自动安装 scanner、所有语言沙箱、宿主配置迁移。 + +## Decisions + +1. 增加共享 verdict 模块:状态、原始退出码、原因分离。兼容 passed 字段但只允许 PASS=true;每工具独立处理特殊退出码,未知异常保守 UNVERIFIED。原有 tuples gate API 保留,未验证通过 skipped 通道明确输出。 +2. Git 硬门禁在临时目录物化 index/HEAD,并按 staging intent 覆盖将被暂存的路径。用户工作树/index 不写入;不借用真实 .git。无法物化、外部符号链接/子模块或缺少快照运行依赖时明确未验证。软提醒仍可检查工作树。精确快照路径不复用工作树缓存,避免缓存污染。 +3. 安全检查使用未排除构建产物的预测路径集合,并排除已删除路径。检查目录名不是秘密内容扫描,文档必须明确边界。 +4. 删除无基线的“未修改文件就是历史问题”降级。新增 Java 模块图将反向依赖方纳入计划。代价是部分历史项目会暴露真实存量失败;不能为减少噪声制造通过。 +5. Java 使用 POM XML 和保守的 Gradle 声明读取,绝不执行构建脚本来获得图。Maven 解析模块、父坐标、properties 与内部依赖;无法解析/profile/动态结构回退全仓。Gradle 不能确认静态图时根 check。优先 wrapper;Maven 使用 verify,Gradle 使用 check,不默认跳过测试。 +6. java-plan 独立 CLI + MCP;门禁和仓库检查复用同一计划。计划包含 changes/affected/modules/dependencies/commands/reasons/gaps,声明模块级而非符号级覆盖。项目级检查在保存时只提醒,不自动跑整仓 formatter。 +7. CVE 以 JSON 或工具明确证据归类,网络错误不得当漏洞;不进行真实联网扫描测试。保留旧字段方便消费者迁移。 + +## Risks / Trade-offs + +- 快照不包含 ignored 依赖 → 缺少依赖时 UNVERIFIED,不能退回扫描错误内容。 +- 动态构建不能静态完全解析 → 全量保守计划和明确 gaps,而非虚构精确影响面。 +- Maven verify/Gradle check 可能执行项目已有插件或联网 → 规划只读,执行延续显式检查/提交门禁授权,文档标明不是沙箱。 +- 不同宿主 payload 仍需独立真实端验收;本轮以子进程协议和 stdio MCP 测试为证据。 + +## Migration Plan + +先发布本地可验证的 minor 变更;CLI 未验证退出 1,消费者不得只按非 2 判 PASS。MCP 增加工具和状态字段。文档同步,不移动既有 tag,不直接改受管 skills;远端发布与宿主升级另需确认。 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/proposal.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/proposal.md new file mode 100644 index 0000000..cea20f9 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/proposal.md @@ -0,0 +1,31 @@ +# 判定可信度收敛与 Java 项目感知 + +## Why + +v0.11.1 的代码审计复现了逐文件漏检、UNVERIFIED 假通过、暂存区与工作树错配、项目级命令约束丢失和 CVE 环境故障误报。扩大覆盖面之前,必须使检查结论与被检查内容一致,再让 Java 检查理解构建系统和模块依赖。 + +## What Changes + +- 统一显式结果状态;**BREAKING**:CLI 无法验证退出 1,不再打印 all passed;MCP 保留兼容字段并增加 status/reason。 +- 逐文件结果完整聚合;检查命令透传作用域约束;工具异常不触发自动修复。 +- Git 硬门禁检查暂存/推送内容快照,预测 git add 的安全检查范围;不因报错落在未修改文件而擅自认定为历史问题。 +- CVE 扫描只在有漏洞证据时报告 FAIL,保留执行失败和无法验证状态。 +- 新增 Java 只读检查计划:Maven/Gradle、wrapper、多模块依赖、反向影响闭包;接入 CLI/MCP 与 Java 门禁。 +- 同步宿主协议、架构与 README;保持外部 skills 快照不变。 + +## Capabilities + +### New Capabilities + +- `verdict-integrity`: 代码快照、逐文件聚合和无法验证状态的一致性。 +- `java-project-impact`: Java 构建感知、模块级影响分析及检查计划。 + +### Modified Capabilities + +- `language-gate-commands`: Git 内容范围与错误归因、项目级命令的作用域。 +- `mcp-tool-server`: 显式结果状态和 Java 分析工具。 +- `cve-dependency-scan`: 基于扫描证据区分漏洞与执行故障。 + +## Impact + +修改插件 hooks/scripts/tests/docs 与版本元数据;不增加依赖、不安装工具、不初始化用户项目 CodeGraph,不修改受管技能或已有 OpenSpec change。不把模块图当作完整符号/业务语义分析,不自动提交、推送或改变用户宿主配置。 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/cve-dependency-scan/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/cve-dependency-scan/spec.md new file mode 100644 index 0000000..5a284a2 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/cve-dependency-scan/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Severity threshold means threshold-and-above + +严重度 MUST 解释为阈值及以上;Maven CVSS、npm moderate/MEDIUM 和 Trivy 严重度必须正确归一。若原生报告已发现漏洞但没有可比较严重度,且阈值高于 LOW,MUST 保留发现并返回 UNVERIFIED,不能猜为通过或高危;LOW 对全部已知漏洞失败。用户可显式选 universal 复核。 + +#### Scenario: 降低阈值 +- **WHEN** 选择 LOW 或 MEDIUM +- **THEN** 支持严重度的适配器包含全部更高级别 + +#### Scenario: 默认与最高阈值无回归 +- **WHEN** 选择 HIGH 或 CRITICAL +- **THEN** 按可比较严重度判定;缺少严重度时报告未验证 + +### Requirement: Result classification + +扫描结果 MUST 基于工具的有效报告落入 PASS、FAIL、UNVERIFIED。网络/配置/解析故障和超时 MUST 为 UNVERIFIED;只有阈值内的漏洞证据才为 FAIL。JSON 与文本保持一致,修复后结论采用复扫结果。 + +#### Scenario: 三类结果混合 +- **WHEN** 同时存在通过、漏洞和未验证生态 +- **THEN** 分别列出,退出码 FAIL=2 优先于 UNVERIFIED=1 + +#### Scenario: 存在无法验证的生态 +- **WHEN** 无已确认漏洞但扫描超时或输出不可解析 +- **THEN** 返回 1,不报告有漏洞或通过 + +#### Scenario: 全部通过 +- **WHEN** 所有扫描提供有效且阈值内无发现的结果 +- **THEN** 返回 0 + +#### Scenario: npm findings below configured severity +- **WHEN** npm 返回非零但报告只有阈值以下的漏洞 +- **THEN** 当前阈值判定 PASS,并保留原始工具退出码 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/hook-protocol/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/hook-protocol/spec.md new file mode 100644 index 0000000..974a1bd --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/hook-protocol/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: The commit face SHALL match the actual staging surface + +PreToolUse MUST 按可识别命令预测提交内容:纯 commit 使用 index,git add -A 使用工作树覆盖,git add -u / commit -a 不得纳入未跟踪文件,带路径 add 不得扩张到全仓。精确快照不得复用软门禁缓存。无独立基线时,项目命令在未修改文件上的失败 MUST 保留,不得按路径猜测历史债。无法完整建模的命令必须声明能力边界,不能作为全面验收证据。 + +#### Scenario: Plain commit with unrelated unstaged WIP in the worktree +- **WHEN** index 可提交且工作树存在无关 WIP +- **THEN** 在 index 快照检查,不检查 WIP 内容 + +#### Scenario: Chained add widens the face before commit runs +- **WHEN** git add 后接 commit +- **THEN** 按 add 的范围叠加工作树文件并保留删除影响 + +#### Scenario: Project-level linter fails only on files outside the changeset +- **WHEN** 项目级检查在未修改文件发现违规且没有独立基线证据 +- **THEN** 保留失败,不归类为已知存量 + +## ADDED Requirements + +### Requirement: Fail-open uncertainty SHALL be visible + +PreToolUse 放行工具故障或无法物化快照时 MUST 使用 JSON additionalContext 说明未验证;UserPromptSubmit MUST NOT 对含未验证项的检查输出全部通过。PostToolUse 只提供反馈,strict_mode 不得被文档描述为已生效的阻断开关。 + +#### Scenario: A tool cannot run at the Git gate +- **WHEN** 工具缺失或配置导致无法获得检查结论 +- **THEN** 保留 exit 0 的兼容放行,并明确未验证而不是 PASS diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/java-project-impact/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/java-project-impact/spec.md new file mode 100644 index 0000000..8206b21 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/java-project-impact/spec.md @@ -0,0 +1,33 @@ +## Purpose + +让 Java 守卫依据真实构建描述和模块依赖制定可解释的检查计划,识别 wrapper 与多模块影响范围,并在动态构建配置无法静态确定时保守扩大范围而不是伪造完整语义分析。 + +## ADDED Requirements + +### Requirement: Java planning SHALL be read only and project aware + +系统 MUST 提供 CLI java-plan 和 MCP analyze_java_impact,识别 Maven/Gradle 与 wrapper,返回构建系统、模块、依赖边、受影响模块、检查命令、依据和未验证边界。规划 MUST NOT 执行构建、安装工具、下载依赖或初始化 CodeGraph。 + +#### Scenario: Maven wrapper multi-module project +- **WHEN** 仓库存在 mvnw、父 pom 和 api/service 模块,service 依赖 api +- **THEN** 修改 api 的计划包含 api 和 service,并优先使用 ./mvnw + +### Requirement: Impact SHALL include reverse dependency closure + +受影响范围 MUST 包含修改模块及其直接/间接依赖方;构建配置变化、无法解析的依赖表达式或 Gradle 动态配置 MUST 保守退回全模块,并给出原因。删除文件、无改动、仓外路径和缺失构建描述 MUST 被明确处理。 + +#### Scenario: A shared module changes +- **WHEN** app 依赖 service、service 依赖 api,仅 api 源码变化 +- **THEN** 计划覆盖 api、service、app,且不把未修改调用方错误归为历史问题 + +#### Scenario: Dynamic Gradle project configuration +- **WHEN** settings 或项目依赖使用无法可靠解析的动态表达式 +- **THEN** 计划使用根项目 check 并标明全量保守回退 + +### Requirement: Java execution SHALL follow the plan without claiming unrun checks + +Java 仓库级检查 MUST 使用计划的 Maven verify 或 Gradle check(显式项目命令可覆盖),分别记录命令与覆盖边界。PostToolUse MUST NOT 静默进行项目级格式化;无静态规则配置时 MUST NOT 宣称已执行 Checkstyle/PMD 等规则。 + +#### Scenario: Gradle-only repository +- **WHEN** Java 仓库使用 Gradle 而无 pom.xml +- **THEN** 检查调用 Gradle check,不调用 Maven diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/language-gate-commands/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/language-gate-commands/spec.md new file mode 100644 index 0000000..f29f99c --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/language-gate-commands/spec.md @@ -0,0 +1,55 @@ +## MODIFIED Requirements + +### Requirement: Gates in git repositories SHALL default to changed-file scope + +Git 门禁 MUST 默认 delta,项目可用 gate_scope=repo 选择全量;构建产物/依赖目录继续使用 scope.FULL_SCAN_EXCLUDES 单源排除。纯 commit 取 index 改动,预测 add 叠加工作树,纯 push 取 HEAD 中的未推送差异,无可解析上游时检查 HEAD 树。提交+推送检查两者并集。精确快照不复用软门禁缓存。只提醒的 UserPromptSubmit 可用工作树宽口径。项目级检查失败不得仅凭未修改文件位置豁免。 + +#### Scenario: A committed legacy issue is untouched by a clean change +- **WHEN** 改动只有文档且项目检查不适用 +- **THEN** 无须执行的检查跳过;不伪称完成项目验证 + +#### Scenario: A new staged file introduces a problem +- **WHEN** staged 内容违规但工作树已修好 +- **THEN** index 快照检出违规 + +#### Scenario: A bad commit made outside the gate must not slip through the push +- **WHEN** HEAD 含未推送违规内容 +- **THEN** HEAD 快照检出违规,不读取工作树修复冒充通过 + +#### Scenario: Push with no resolvable upstream does not crash +- **WHEN** 没有可解析上游 +- **THEN** 精确硬门禁检查 HEAD 树,不因空基线跳过全部文件 + +#### Scenario: A chained commit-and-push takes the wider face +- **WHEN** 可识别链中同时提交和推送 +- **THEN** 预测快照包含拟提交内容与未推送差异 + +### Requirement: A toolchain crash SHALL be recorded as unverified, not as a lint failure + +检查器退出码 MUST 结合工具契约判定。配置/依赖/用法错误、缺失工具和超时 MUST 记为 UNVERIFIED,禁止自动修复;不得把所有工具的 exit 2 一概视为配置错误。无法验证不计为 PASS,CLI 返回 1,MCP 保留原因,hook 放行时必须明确提示未验证。 + +#### Scenario: An npx tool crashes with exit 2 +- **WHEN** ESLint 因配置问题以 exit 2 退出 +- **THEN** 结果为 UNVERIFIED,不作为 lint 违规也不作为通过 + +### Requirement: Single-file hook commands MUST NOT silently expand to whole-repository scans + +PostToolUse MUST 将文件型命令限制到编辑文件;项目级命令 MUST 保留 append_files=false,不追加源码文件参数,并推迟到显式仓库检查或 Git 门禁。不得因单文件事件自动执行全项目 formatter。所有 CLI/MCP/hook 命令构造 MUST 保留注册表作用域约束。 + +#### Scenario: Editing one Python file triggers format +- **WHEN** AI 保存单个 Python 文件且该文件存在可修复 lint 违规 +- **THEN** formatter 仅处理该文件并复检 + +#### Scenario: Editing a Java file +- **WHEN** Java 检查/修复是项目级命令 +- **THEN** 保存事件提示项目级检查入口,不生成 mvn 加源码文件的错误命令,不自动格式化全仓 + +## ADDED Requirements + +### Requirement: Diagnostics outside the diff SHALL NOT be assumed historical + +没有独立基线证据时,系统 MUST NOT 仅因诊断落在未修改文件就跳过失败。 + +#### Scenario: Changed API breaks an unchanged caller +- **WHEN** 新修改接口导致未修改调用方报告错误 +- **THEN** 保留失败结论,不自动标记存量债务 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/mcp-tool-server/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/mcp-tool-server/spec.md new file mode 100644 index 0000000..a4fdbd6 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/mcp-tool-server/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: `auto_fix` SHALL run formatters and re-verify + +auto_fix MUST 默认仅处理 Git 改动文件并按相同范围复检;无法确定范围或项目 formatter 无法限制范围时不得默默扩展写入。fixed MUST 表示实际文件修改,而非只表示进程退出 0。结果包含 check 信封与逐语言修复结果。 + +#### Scenario: Formatter succeeds and lint passes +- **WHEN** formatter 修改变更文件且复检成功 +- **THEN** fixed=true,check 中结果 PASS,干净文件不变 + +#### Scenario: Formatter succeeds but lint still fails +- **WHEN** 已修改文件仍有不可修复问题 +- **THEN** fixed=true 且复检 FAIL,不宣称完成 + +#### Scenario: Git scope is unavailable +- **WHEN** 不能确定 Git 改动范围 +- **THEN** fixed=false、UNVERIFIED,提示显式 CLI 全量修复,不执行 formatter + +### Requirement: The MCP server SHALL expose three tools over stdio JSON-RPC + +服务 MUST 使用官方 SDK stdio 暴露原有 check_code_style、auto_fix、list_languages,并新增只读 analyze_java_impact。既有工具名保持兼容。 + +#### Scenario: Listing tools at startup +- **WHEN** 客户端请求 tools/list +- **THEN** 返回四个工具及其输入 schema + +### Requirement: `check_code_style` SHALL return a structured envelope with full stderr on disk + +结果 MUST 保留 language、passed、exit_code、stderr_path、log_path,增加 status 和 reason;UNVERIFIED/PLANNED 不得变成 passed=true。非 PASS 的执行输出 MUST 可追溯。 + +#### Scenario: One language fails +- **WHEN** Python 检查发现违规 +- **THEN** 返回 status=FAIL、passed=false 与实际退出码和完整日志路径 + +#### Scenario: All languages pass +- **WHEN** 每个检查真实退出 0 +- **THEN** 返回 status=PASS、passed=true + +#### Scenario: A tool cannot run +- **WHEN** 缺少工具或检查超时 +- **THEN** 返回 status=UNVERIFIED、passed=false 和原因,不丢弃未验证信息 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/verdict-integrity/spec.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/verdict-integrity/spec.md new file mode 100644 index 0000000..bf97945 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/specs/verdict-integrity/spec.md @@ -0,0 +1,37 @@ +## Purpose + +保证检查结论绑定明确的代码内容和执行证据,防止逐文件结果、工具错误、暂存区差异或报告转换制造假通过,并保持用户工作树、暂存区和外部技能内容不变。 + +## ADDED Requirements + +### Requirement: Verdicts SHALL preserve uncertainty across interfaces + +CLI、MCP 和 hooks MUST 区分 PASS、FAIL、UNVERIFIED、SKIPPED、PLANNED。仅真实执行且成功的检查允许 passed=true;无法验证 MUST NOT 触发自动修复或 all passed。CLI 聚合退出码 MUST 为 FAIL=2、无 FAIL 但存在未验证/计划项=1、全部已验证或无须检查=0。 + +#### Scenario: Configuration error survives MCP serialization +- **WHEN** 检查器因配置错误退出且没有有效违规结论 +- **THEN** CLI 退出 1,MCP 返回 passed=false、status=UNVERIFIED 和原因 + +### Requirement: Every executed file SHALL contribute to the verdict + +逐文件检查 MUST 纳入全部已执行文件结果,不得只保留第一个结果。 + +#### Scenario: The second file fails +- **WHEN** 同语言第一个文件通过、第二个文件违规 +- **THEN** 聚合包含第二个文件失败并拒绝 Git 操作 + +### Requirement: Git gates SHALL verify the proposed content without modifying it + +Git 硬门禁 MUST 在隔离内容快照上检查:纯 commit 取 index,预测暂存包含相应工作树文件,纯 push 取 HEAD。安全检查 MUST 采用同一预测范围。快照失败、无法可靠预测或无法运行工具 MUST 明示 UNVERIFIED,不得宣称通过;不得更改真实 index/工作树。已删除文件不得作为新入库敏感文件拦截。 + +#### Scenario: A dirty index is hidden by a clean worktree +- **WHEN** index 内容有违规而未暂存工作树内容已修好 +- **THEN** 检查 index 快照检出违规,工作树和 index 保持原样 + +#### Scenario: A sensitive file is about to be staged +- **WHEN** 命令是 git add .env 后 commit,文件尚未暂存 +- **THEN** 安全门禁仍检出 .env + +#### Scenario: A push contains a bad committed file +- **WHEN** HEAD 的文件违规但工作树已修好 +- **THEN** push 检查 HEAD 内容而非修好的工作树 diff --git a/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/tasks.md b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/tasks.md new file mode 100644 index 0000000..1a2920d --- /dev/null +++ b/openspec/changes/archive/2026-09-22-converge-verdicts-java-impact/tasks.md @@ -0,0 +1,19 @@ +# Tasks + +## 1. 判定可信度 + +- [x] 1.1 为多文件漏检、UNVERIFIED 序列化、命令作用域和 CVE 错误建立失败测试,再修复并验证 test_verdict_integrity。 +- [x] 1.2 为 index/HEAD 与工作树错配、预测暂存安全范围、删除文件建立真实临时 Git 测试;实现隔离快照并确认原工作树/index 不变。 +- [x] 1.3 统一 CLI/MCP/hooks 状态,取消无证据历史归因,工具故障不自动修复;通过相关回归。 + +## 2. Java 项目感知 + +- [x] 2.1 先建立 Maven/Gradle、wrapper、多模块反向依赖及保守回退测试,再实现只读 java-plan。 +- [x] 2.2 接入 Java gate/check 与 CLI/MCP,测试真实子进程参数、范围及未验证输出,不执行真实联网构建。 + +## 3. 收敛与交付 + +- [x] 3.1 同步协议、双语 README、架构/技术路线和 OpenSpec;验证 docs parity、OpenSpec validate。 +- [x] 3.2 执行全量 unittest、run_all、ruff、vendor 离线/在线检查、注册表校验并记录结果。 +- [x] 3.3 检查市场仓状态,按仓库脚本准备 minor 升版,验证元数据一致;远端发布未经确认不执行。 +- [x] 3.4 对照规格完成完整性/正确性/一致性审查,保存验证报告和未验证边界。 diff --git a/openspec/specs/cve-dependency-scan/spec.md b/openspec/specs/cve-dependency-scan/spec.md index 6f134ee..58d2f29 100644 --- a/openspec/specs/cve-dependency-scan/spec.md +++ b/openspec/specs/cve-dependency-scan/spec.md @@ -2,7 +2,9 @@ ## Purpose 定义 `codeguard cve` 的生态覆盖边界:哪些生态必须给出可验证结论、无原生扫描器时如何兜底、生态标识如何被接受与报告、严重级别阈值如何统一作用于每个扫描器,以及「通过 / 存在漏洞 / 无法验证」三类结果如何判定与退出。 + ## Requirements + ### Requirement: Universal fallback for ecosystems without a native scanner 当项目被识别到的语言不属于任何已有原生扫描器的生态时,系统 SHALL 使用通用扫描器给出结论,而不是报告「无可扫描生态」。 @@ -62,34 +64,32 @@ ### Requirement: Severity threshold means threshold-and-above -`--severity` 指定的阈值 SHALL 被解释为「该级别及以上」,且 SHALL 以等价形式传达给每个扫描器。 +严重度 MUST 解释为阈值及以上;Maven CVSS、npm moderate/MEDIUM 和 Trivy 严重度必须正确归一。若原生报告已发现漏洞但没有可比较严重度,且阈值高于 LOW,MUST 保留发现并返回 UNVERIFIED,不能猜为通过或高危;LOW 对全部已知漏洞失败。用户可显式选 universal 复核。 #### Scenario: 降低阈值 - -- **WHEN** 用户传入 `--severity LOW` 或 `--severity MEDIUM` -- **THEN** 通用扫描器收到包含全部更高级别的完整严重级别集合,而不是只含该级别与最高级别的子集 +- **WHEN** 选择 LOW 或 MEDIUM +- **THEN** 支持严重度的适配器包含全部更高级别 #### Scenario: 默认与最高阈值无回归 - -- **WHEN** 阈值为默认的 `HIGH`,或为 `CRITICAL` -- **THEN** 实际下发给扫描器的级别集合与阈值语义一致 +- **WHEN** 选择 HIGH 或 CRITICAL +- **THEN** 按可比较严重度判定;缺少严重度时报告未验证 ### Requirement: Result classification -每个被扫描的生态 SHALL 恰好落入「通过」「存在漏洞」「无法验证」三类之一,三者在文本与 JSON 输出中均可区分,「无法验证」SHALL NOT 被计入「存在漏洞」。 +扫描结果 MUST 基于工具的有效报告落入 PASS、FAIL、UNVERIFIED。网络/配置/解析故障和超时 MUST 为 UNVERIFIED;只有阈值内的漏洞证据才为 FAIL。JSON 与文本保持一致,修复后结论采用复扫结果。 #### Scenario: 三类结果混合 - -- **WHEN** 一次运行中同时存在通过、存在漏洞与无法验证的生态 -- **THEN** 摘要分别列出三类,退出状态以「存在漏洞」优先于「无法验证」 +- **WHEN** 同时存在通过、漏洞和未验证生态 +- **THEN** 分别列出,退出码 FAIL=2 优先于 UNVERIFIED=1 #### Scenario: 存在无法验证的生态 - -- **WHEN** 无生态存在漏洞,但至少一个生态无法验证 -- **THEN** 退出状态为「无法验证」,且输出明确声明该结果不能视为通过 +- **WHEN** 无已确认漏洞但扫描超时或输出不可解析 +- **THEN** 返回 1,不报告有漏洞或通过 #### Scenario: 全部通过 +- **WHEN** 所有扫描提供有效且阈值内无发现的结果 +- **THEN** 返回 0 -- **WHEN** 所有被扫描生态均通过且无无法验证的生态 -- **THEN** 退出状态为通过 - +#### Scenario: npm findings below configured severity +- **WHEN** npm 返回非零但报告只有阈值以下的漏洞 +- **THEN** 当前阈值判定 PASS,并保留原始工具退出码 diff --git a/openspec/specs/hook-protocol/spec.md b/openspec/specs/hook-protocol/spec.md index 422fdf3..f8a2809 100644 --- a/openspec/specs/hook-protocol/spec.md +++ b/openspec/specs/hook-protocol/spec.md @@ -1,8 +1,10 @@ # hook-protocol Specification ## Purpose -TBD - created by archiving change add-host-protocol-cheatsheet. Update Purpose after archive. +定义 CodeGuard 与宿主的事件、输出和退出码契约:区分观察性反馈与 Git 硬门禁,保留 fail-open 的同时明确未验证状态,并将检查绑定到拟提交或推送的内容。 + ## Requirements + ### Requirement: Hook-host contract SHALL live in hooks/__protocol__.md The canonical description of how codeguard-plugin hooks communicate with Codex CLI, ZCode, and Kimi Code (exit codes, JSON output formats, fail-open convention, three-host compatibility) SHALL live in `hooks/__protocol__.md`. No hook script or docstring SHALL contradict it; any change to exit codes or JSON schema SHALL update the document in the same commit. @@ -84,20 +86,24 @@ UserPromptSubmit 与 PreToolUse MUST 共用同一条仓库级豁免(`git confi ### Requirement: The commit face SHALL match the actual staging surface -PreToolUse 的 commit 面 MUST 按命令链预测**实际会提交**的文件集(`staging_intent`):纯 `git commit` 只取暂存区;`git add -A/-a/-u`、`commit -a` 相应扩展到未暂存/未跟踪;`git add ` MUST 并入这些路径(触发拦截时 add 尚未执行、暂存区仍是旧的)。工作树里**未被该命令链触及**的改动(如并行会话的未暂存 WIP)MUST NOT 触发硬拦。预测面(lanes/extra)MUST 进入门禁缓存键。delta 作用域下,项目级 linter 报错所提及的文件 MUST 全部位于本次改动集之外时归为 skipped(存量归因)而非 failure;无法归因或存在交集时维持 failure(宁可多拦不漏拦)。 +PreToolUse MUST 按可识别命令预测提交内容:纯 commit 使用 index,git add -A 使用工作树覆盖,git add -u / commit -a 不得纳入未跟踪文件,带路径 add 不得扩张到全仓。精确快照不得复用软门禁缓存。无独立基线时,项目命令在未修改文件上的失败 MUST 保留,不得按路径猜测历史债。无法完整建模的命令必须声明能力边界,不能作为全面验收证据。 #### Scenario: Plain commit with unrelated unstaged WIP in the worktree - -- **WHEN** 暂存区干净可提交,工作树存在与本命令无关的未暂存/未跟踪改动,执行 `git commit -m t` -- **THEN** 门禁只检查暂存区文件,无关 WIP 不产生 failure,提交放行 +- **WHEN** index 可提交且工作树存在无关 WIP +- **THEN** 在 index 快照检查,不检查 WIP 内容 #### Scenario: Chained add widens the face before commit runs - -- **WHEN** 执行 `git add -A && git commit -m t` 或 `git add path/to/file && git commit -m t` -- **THEN** 门禁按执行后的暂存区预测检查(含未暂存/未跟踪或显式路径),"即将暂存"的文件不漏检 +- **WHEN** git add 后接 commit +- **THEN** 按 add 的范围叠加工作树文件并保留删除影响 #### Scenario: Project-level linter fails only on files outside the changeset +- **WHEN** 项目级检查在未修改文件发现违规且没有独立基线证据 +- **THEN** 保留失败,不归类为已知存量 + +### Requirement: Fail-open uncertainty SHALL be visible -- **WHEN** delta 面内 mvn/cargo 等项目级命令失败,但输出提及的文件全部不在本次改动集 -- **THEN** 归为 skipped(存量归因 + 完整日志路径),不拦截本次提交 +PreToolUse 放行工具故障或无法物化快照时 MUST 使用 JSON additionalContext 说明未验证;UserPromptSubmit MUST NOT 对含未验证项的检查输出全部通过。PostToolUse 只提供反馈,strict_mode 不得被文档描述为已生效的阻断开关。 +#### Scenario: A tool cannot run at the Git gate +- **WHEN** 工具缺失或配置导致无法获得检查结论 +- **THEN** 保留 exit 0 的兼容放行,并明确未验证而不是 PASS diff --git a/openspec/specs/java-project-impact/spec.md b/openspec/specs/java-project-impact/spec.md new file mode 100644 index 0000000..b1dfc19 --- /dev/null +++ b/openspec/specs/java-project-impact/spec.md @@ -0,0 +1,34 @@ +# java-project-impact Specification + +## Purpose +让 Java 守卫依据真实构建描述和模块依赖制定可解释的检查计划,识别 wrapper 与多模块影响范围,并在动态构建配置无法静态确定时保守扩大范围而不是伪造完整语义分析。 + +## Requirements + +### Requirement: Java planning SHALL be read only and project aware + +系统 MUST 提供 CLI java-plan 和 MCP analyze_java_impact,识别 Maven/Gradle 与 wrapper,返回构建系统、模块、依赖边、受影响模块、检查命令、依据和未验证边界。规划 MUST NOT 执行构建、安装工具、下载依赖或初始化 CodeGraph。 + +#### Scenario: Maven wrapper multi-module project +- **WHEN** 仓库存在 mvnw、父 pom 和 api/service 模块,service 依赖 api +- **THEN** 修改 api 的计划包含 api 和 service,并优先使用 ./mvnw + +### Requirement: Impact SHALL include reverse dependency closure + +受影响范围 MUST 包含修改模块及其直接/间接依赖方;构建配置变化、无法解析的依赖表达式或 Gradle 动态配置 MUST 保守退回全模块,并给出原因。删除文件、无改动、仓外路径和缺失构建描述 MUST 被明确处理。 + +#### Scenario: A shared module changes +- **WHEN** app 依赖 service、service 依赖 api,仅 api 源码变化 +- **THEN** 计划覆盖 api、service、app,且不把未修改调用方错误归为历史问题 + +#### Scenario: Dynamic Gradle project configuration +- **WHEN** settings 或项目依赖使用无法可靠解析的动态表达式 +- **THEN** 计划使用根项目 check 并标明全量保守回退 + +### Requirement: Java execution SHALL follow the plan without claiming unrun checks + +Java 仓库级检查 MUST 使用计划的 Maven verify 或 Gradle check(显式项目命令可覆盖),分别记录命令与覆盖边界。PostToolUse MUST NOT 静默进行项目级格式化;无静态规则配置时 MUST NOT 宣称已执行 Checkstyle/PMD 等规则。 + +#### Scenario: Gradle-only repository +- **WHEN** Java 仓库使用 Gradle 而无 pom.xml +- **THEN** 检查调用 Gradle check,不调用 Maven diff --git a/openspec/specs/language-gate-commands/spec.md b/openspec/specs/language-gate-commands/spec.md index e4e9701..c9227d5 100644 --- a/openspec/specs/language-gate-commands/spec.md +++ b/openspec/specs/language-gate-commands/spec.md @@ -2,7 +2,9 @@ ## Purpose 定义语言注册表中每个条目所声明命令的可执行性要求:`lint` / `format` / `probe` 必须能以声明的方式真正运行,需要路径或文件参数时必须以显式占位符或 glob 表达;并定义语言声明配置前置条件的能力,使「项目未接入」与「检查失败」不再混为一谈。 + ## Requirements + ### Requirement: Declared commands must be runnable as written 注册表声明的 `lint` / `format` / `probe` 命令 SHALL 能按声明形式直接执行;需要文件或路径参数的 SHALL 以 `{file}` 占位符或显式 glob 表达,不得依赖调用方补全。 @@ -88,48 +90,52 @@ The functions `load_user_config`, `load_project_overrides`, and `get_overrides` ### Requirement: Gates in git repositories SHALL default to changed-file scope -git 仓库内的门禁 MUST 缺省只检查**本次操作面**涉及的文件,并按语言归属过滤;存量问题 MUST NOT 阻塞无关的新提交。操作面 MUST 由操作类型决定:**commit 面**(`git commit` 前)= staged + 未暂存 + 未跟踪;**push 面**(`git push` 前)= 提交面并集**未推送提交**(`up...HEAD` 三点差;无 upstream 时按 `origin/<当前分支>`→`origin/main`→`origin/master` 逐个尝试,均不可解析则不猜测、不崩溃)。面的判定 MUST 单源:命中判定与选面共用同一套扫描,直接命令与一层解释器间接不得分叉;同时命中 commit 与 push 时 MUST 取 push 面。提示词触发的软门禁 MUST 以同一套面语义选择(推送意图选 push 面)。项目可用 `codeguard.json` 的 `gate_scope`(`delta`/`repo`)显式覆盖;非 git 目录缺省为全量。构建产物与依赖快照 MUST 有**单一事实源清单**(`scope.FULL_SCAN_EXCLUDES`),覆盖全量与 delta 两条路径的**所有门禁族**:ruff `--exclude`、find 型 gate 的 `-not -path` 注入(`-print0`/`-exec`/无 NUL 锚三形态全覆盖)、PostToolUse 对产物路径静默跳过、`changed_files` 过滤产物路径(force-add 的 target 文件不进 delta 面);「入库面」清单 MUST 由该单一事实源派生(+IDE 目录),两侧不得各自手抄。html 门禁 MUST 以 NUL 管道传递文件清单——`-exec … {} + | xargs -0` 的换行分隔会在产物数百个时把整串路径塞进单参数(`xargs: insufficient space` 实测)。门禁结果缓存 MUST 按面隔离(mode 进缓存键),同一 HEAD 下两面不得互相污染。 +Git 门禁 MUST 默认 delta,项目可用 gate_scope=repo 选择全量;构建产物/依赖目录继续使用 scope.FULL_SCAN_EXCLUDES 单源排除。纯 commit 取 index 改动,预测 add 叠加工作树,纯 push 取 HEAD 中的未推送差异,无可解析上游时检查 HEAD 树。提交+推送检查两者并集。精确快照不复用软门禁缓存。只提醒的 UserPromptSubmit 可用工作树宽口径。项目级检查失败不得仅凭未修改文件位置豁免。 #### Scenario: A committed legacy issue is untouched by a clean change - -- **WHEN** 仓内 HEAD 已含存量 lint 问题,本次提交只涉及无问题的文档文件 -- **THEN** commit 面判定通过,存量问题不拦截本次提交 +- **WHEN** 改动只有文档且项目检查不适用 +- **THEN** 无须执行的检查跳过;不伪称完成项目验证 #### Scenario: A new staged file introduces a problem - -- **WHEN** 新增或修改的文件被暂存且含 lint 问题 -- **THEN** commit 面按改动集检出该问题并正常拦截 +- **WHEN** staged 内容违规但工作树已修好 +- **THEN** index 快照检出违规 #### Scenario: A bad commit made outside the gate must not slip through the push - -- **WHEN** 工作树干净、提交已完成(绕过或早于门禁的坏提交),上游可解析 -- **THEN** push 面检出未推送提交中的问题文件并拦截 `git push` +- **WHEN** HEAD 含未推送违规内容 +- **THEN** HEAD 快照检出违规,不读取工作树修复冒充通过 #### Scenario: Push with no resolvable upstream does not crash - -- **WHEN** 仓库无 upstream 且 `origin/main`/`origin/master` 均不存在 -- **THEN** 提交面照常计算、push 面保持空集,门禁不报错、按提交面结论决定 +- **WHEN** 没有可解析上游 +- **THEN** 精确硬门禁检查 HEAD 树,不因空基线跳过全部文件 #### Scenario: A chained commit-and-push takes the wider face - -- **WHEN** 单条命令链同时包含 `git commit` 与 `git push`(或间接脚本体内两者并存) -- **THEN** 生效面为 push 面(提交面的超集),两面的文件并集被检查 +- **WHEN** 可识别链中同时提交和推送 +- **THEN** 预测快照包含拟提交内容与未推送差异 ### Requirement: A toolchain crash SHALL be recorded as unverified, not as a lint failure -linter 以 exit 2(用法/依赖/配置崩溃)退出时,门禁 MUST 记为未验证(hook 路径归 skipped;CLI/MCP 标记 unverified 且不计为失败),MUST NOT 作为 lint 失败上报或触发自动修复。 +检查器退出码 MUST 结合工具契约判定。配置/依赖/用法错误、缺失工具和超时 MUST 记为 UNVERIFIED,禁止自动修复;不得把所有工具的 exit 2 一概视为配置错误。无法验证不计为 PASS,CLI 返回 1,MCP 保留原因,hook 放行时必须明确提示未验证。 #### Scenario: An npx tool crashes with exit 2 - -- **WHEN** 某语言的检查命令因包/运行时问题以 exit 2 退出且无 lint 结论 -- **THEN** 结果为"工具链异常未验证",不阻塞提交、不计入失败 +- **WHEN** ESLint 因配置问题以 exit 2 退出 +- **THEN** 结果为 UNVERIFIED,不作为 lint 违规也不作为通过 ### Requirement: Single-file hook commands MUST NOT silently expand to whole-repository scans -PostToolUse 的 lint 与 format 命令 MUST 物化到被编辑文件的单文件作用域:存在 `{file}` 或全仓扫描 token(`.`、`**/*.md`)时 MUST 收敛为该文件,裸命令 MUST 追加该文件路径。自动修复成功后,若改动波及被编辑文件之外的文件,MUST 把文件清单注入返回上下文。 +PostToolUse MUST 将文件型命令限制到编辑文件;项目级命令 MUST 保留 append_files=false,不追加源码文件参数,并推迟到显式仓库检查或 Git 门禁。不得因单文件事件自动执行全项目 formatter。所有 CLI/MCP/hook 命令构造 MUST 保留注册表作用域约束。 #### Scenario: Editing one Python file triggers format +- **WHEN** AI 保存单个 Python 文件且该文件存在可修复 lint 违规 +- **THEN** formatter 仅处理该文件并复检 + +#### Scenario: Editing a Java file +- **WHEN** Java 检查/修复是项目级命令 +- **THEN** 保存事件提示项目级检查入口,不生成 mvn 加源码文件的错误命令,不自动格式化全仓 + +### Requirement: Diagnostics outside the diff SHALL NOT be assumed historical -- **WHEN** AI 保存单个 `.py` 文件且该文件 lint 失败触发自动修复 -- **THEN** format 命令只作用于该文件;若仍有其它文件被改动,返回上下文列出被改动文件并要求重新读取 +没有独立基线证据时,系统 MUST NOT 仅因诊断落在未修改文件就跳过失败。 +#### Scenario: Changed API breaks an unchanged caller +- **WHEN** 新修改接口导致未修改调用方报告错误 +- **THEN** 保留失败结论,不自动标记存量债务 diff --git a/openspec/specs/mcp-tool-server/spec.md b/openspec/specs/mcp-tool-server/spec.md index a341fce..50e4f8b 100644 --- a/openspec/specs/mcp-tool-server/spec.md +++ b/openspec/specs/mcp-tool-server/spec.md @@ -1,41 +1,49 @@ # mcp-tool-server Specification ## Purpose -TBD - created by archiving change 2026-09-22-fix-gate-trigger-and-mcp. Update Purpose after archive. +定义官方 SDK stdio MCP 的四个工具、带状态和原因的检查结果、受限修复作用域,以及只读 Java 模块影响规划,避免协议转换丢失不确定性。 + ## Requirements + ### Requirement: The MCP server SHALL expose three tools over stdio JSON-RPC -`scripts/run_check.py --mcp` MUST start an stdio JSON-RPC server using the official `mcp` Python SDK and register exactly three tools: `check_code_style`, `auto_fix`, and `list_languages`. The server MUST NOT take any other CLI flag in MCP mode. -#### Scenario: Listing tools at startup +服务 MUST 使用官方 SDK stdio 暴露原有 check_code_style、auto_fix、list_languages,并新增只读 analyze_java_impact。既有工具名保持兼容。 -- **WHEN** a client sends `tools/list` -- **THEN** the response enumerates `check_code_style`, `auto_fix`, `list_languages` with their input schemas +#### Scenario: Listing tools at startup +- **WHEN** 客户端请求 tools/list +- **THEN** 返回四个工具及其输入 schema ### Requirement: `check_code_style` SHALL return a structured envelope with full stderr on disk -`check_code_style(path, languages?)` MUST run the configured linters, write the full stderr of any failing invocation to `/.codeguard-last.log` (default `/out/.codeguard-last.log`), and return a JSON envelope with one entry per language: `{language, passed: bool, exit_code: int, stderr_path: str, log_path: str}`. -#### Scenario: One language fails +结果 MUST 保留 language、passed、exit_code、stderr_path、log_path,增加 status 和 reason;UNVERIFIED/PLANNED 不得变成 passed=true。非 PASS 的执行输出 MUST 可追溯。 -- **WHEN** the path contains a single Python project whose ruff lint fails -- **THEN** the envelope contains one entry with `passed=false`, `exit_code` from ruff, `stderr_path` pointing at the on-disk log, and `log_path` echoing the same path +#### Scenario: One language fails +- **WHEN** Python 检查发现违规 +- **THEN** 返回 status=FAIL、passed=false 与实际退出码和完整日志路径 #### Scenario: All languages pass +- **WHEN** 每个检查真实退出 0 +- **THEN** 返回 status=PASS、passed=true -- **WHEN** every linter exits 0 -- **THEN** every entry has `passed=true`, `exit_code=0`, and the log file is either empty or absent (no stderr written) +#### Scenario: A tool cannot run +- **WHEN** 缺少工具或检查超时 +- **THEN** 返回 status=UNVERIFIED、passed=false 和原因,不丢弃未验证信息 ### Requirement: `auto_fix` SHALL run formatters and re-verify -`auto_fix(path, languages?)` MUST invoke the per-language formatter chain (`format` from `scripts/languages.json`) and then re-run `check_code_style` on the same scope before returning. The response envelope MUST contain both `fixed: bool` (whether any file was modified) and the `check_code_style` envelope for the post-fix re-check. -#### Scenario: Formatter succeeds and lint passes +auto_fix MUST 默认仅处理 Git 改动文件并按相同范围复检;无法确定范围或项目 formatter 无法限制范围时不得默默扩展写入。fixed MUST 表示实际文件修改,而非只表示进程退出 0。结果包含 check 信封与逐语言修复结果。 -- **WHEN** ruff `--fix` removes unused imports and the re-check passes -- **THEN** `fixed=true` and the embedded `check_code_style` envelope is all-pass +#### Scenario: Formatter succeeds and lint passes +- **WHEN** formatter 修改变更文件且复检成功 +- **THEN** fixed=true,check 中结果 PASS,干净文件不变 #### Scenario: Formatter succeeds but lint still fails +- **WHEN** 已修改文件仍有不可修复问题 +- **THEN** fixed=true 且复检 FAIL,不宣称完成 -- **WHEN** ruff `--fix` ran but a remaining error is non-fixable -- **THEN** `fixed=true` and the embedded `check_code_style` envelope contains at least one failing entry +#### Scenario: Git scope is unavailable +- **WHEN** 不能确定 Git 改动范围 +- **THEN** fixed=false、UNVERIFIED,提示显式 CLI 全量修复,不执行 formatter ### Requirement: `list_languages` SHALL return id + name only `list_languages()` MUST return a JSON array of `{id, name}` objects from `scripts/languages.json`, omitting the rest of each language record (lint command, format command, install hint, etc.) so internal commands are not exposed as part of the MCP surface. @@ -44,4 +52,3 @@ TBD - created by archiving change 2026-09-22-fix-gate-trigger-and-mcp. Update Pu - **WHEN** the client calls `list_languages` - **THEN** the response enumerates every entry in `scripts/languages.json` with exactly two fields (`id`, `name`) per record - diff --git a/openspec/specs/verdict-integrity/spec.md b/openspec/specs/verdict-integrity/spec.md new file mode 100644 index 0000000..d2ed68f --- /dev/null +++ b/openspec/specs/verdict-integrity/spec.md @@ -0,0 +1,38 @@ +# verdict-integrity Specification + +## Purpose +保证检查结论绑定明确的代码内容和执行证据,防止逐文件结果、工具错误、暂存区差异或报告转换制造假通过,并保持用户工作树、暂存区和外部技能内容不变。 + +## Requirements + +### Requirement: Verdicts SHALL preserve uncertainty across interfaces + +CLI、MCP 和 hooks MUST 区分 PASS、FAIL、UNVERIFIED、SKIPPED、PLANNED。仅真实执行且成功的检查允许 passed=true;无法验证 MUST NOT 触发自动修复或 all passed。CLI 聚合退出码 MUST 为 FAIL=2、无 FAIL 但存在未验证/计划项=1、全部已验证或无须检查=0。 + +#### Scenario: Configuration error survives MCP serialization +- **WHEN** 检查器因配置错误退出且没有有效违规结论 +- **THEN** CLI 退出 1,MCP 返回 passed=false、status=UNVERIFIED 和原因 + +### Requirement: Every executed file SHALL contribute to the verdict + +逐文件检查 MUST 纳入全部已执行文件结果,不得只保留第一个结果。 + +#### Scenario: The second file fails +- **WHEN** 同语言第一个文件通过、第二个文件违规 +- **THEN** 聚合包含第二个文件失败并拒绝 Git 操作 + +### Requirement: Git gates SHALL verify the proposed content without modifying it + +Git 硬门禁 MUST 在隔离内容快照上检查:纯 commit 取 index,预测暂存包含相应工作树文件,纯 push 取 HEAD。安全检查 MUST 采用同一预测范围。快照失败、无法可靠预测或无法运行工具 MUST 明示 UNVERIFIED,不得宣称通过;不得更改真实 index/工作树。已删除文件不得作为新入库敏感文件拦截。 + +#### Scenario: A dirty index is hidden by a clean worktree +- **WHEN** index 内容有违规而未暂存工作树内容已修好 +- **THEN** 检查 index 快照检出违规,工作树和 index 保持原样 + +#### Scenario: A sensitive file is about to be staged +- **WHEN** 命令是 git add .env 后 commit,文件尚未暂存 +- **THEN** 安全门禁仍检出 .env + +#### Scenario: A push contains a bad committed file +- **WHEN** HEAD 的文件违规但工作树已修好 +- **THEN** push 检查 HEAD 内容而非修好的工作树 diff --git a/scripts/bump-plugin.mjs b/scripts/bump-plugin.mjs index 5e112e6..aa68429 100644 --- a/scripts/bump-plugin.mjs +++ b/scripts/bump-plugin.mjs @@ -87,14 +87,25 @@ if (!plugin) { const oldVersion = plugin.version; const newVersion = bump(oldVersion); -const today = new Date().toISOString().slice(0, 10).replaceAll("-", ""); +const today = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Shanghai", + year: "numeric", + month: "2-digit", + day: "2-digit", +}).format(new Date()).replaceAll("-", ""); const repoDir = path.join(workspace, plugin.localDirectory); const edits = [{ file: catalogPath, description: `${pluginId}: ${oldVersion} -> ${newVersion}` }]; +const plainManifestRels = [".zcode-plugin/plugin.json", "kimi.plugin.json"]; +if (fs.existsSync(path.join(repoDir, "plugin.json"))) plainManifestRels.push("plugin.json"); -for (const rel of [".zcode-plugin/plugin.json", "kimi.plugin.json", ".agents/plugins/marketplace.json"]) { +for (const rel of plainManifestRels) { edits.push({ file: path.join(repoDir, rel), description: `${rel}: ${oldVersion} -> ${newVersion}` }); } +edits.push({ + file: path.join(repoDir, ".agents/plugins/marketplace.json"), + description: `.agents/plugins/marketplace.json: ${oldVersion} -> ${newVersion} + release URLs`, +}); // codex manifest 允许 +codex. 后缀(sync 校验认可的形状) edits.push({ file: path.join(repoDir, ".codex-plugin/plugin.json"), @@ -118,20 +129,38 @@ fs.writeFileSync(catalogPath, catalogText); // 2) 各仓 manifest const bumpPlain = (text) => text.replace(`"version": "${oldVersion}"`, `"version": "${newVersion}"`); -const bumpCodex = (text) => text.replace(/"version": "\d+\.\d+\.\d+\+codex\.\d+"/, `"version": "${newVersion}+codex.${today}"`); +const bumpCodex = (text) => text.replace(/"version": "\d+\.\d+\.\d+(?:\+codex\.\d+)?"/, `"version": "${newVersion}+codex.${today}"`); -fs.writeFileSync(edits[1].file, bumpPlain(fs.readFileSync(edits[1].file, "utf8"))); -fs.writeFileSync(edits[2].file, bumpPlain(fs.readFileSync(edits[2].file, "utf8"))); -fs.writeFileSync(edits[3].file, bumpPlain(fs.readFileSync(edits[3].file, "utf8"))); -fs.writeFileSync(edits[4].file, bumpCodex(fs.readFileSync(edits[4].file, "utf8"))); +for (const rel of plainManifestRels) { + const manifest = path.join(repoDir, rel); + fs.writeFileSync(manifest, bumpPlain(fs.readFileSync(manifest, "utf8"))); +} +const repositoryMarketplace = path.join(repoDir, ".agents/plugins/marketplace.json"); +const marketplace = JSON.parse(fs.readFileSync(repositoryMarketplace, "utf8")); +if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length !== 1) { + throw new Error(`${pluginId}: repository marketplace must contain exactly one plugin`); +} +const marketplacePlugin = marketplace.plugins[0]; +const releaseRef = `v${newVersion}`; +const logoUrl = `https://cdn.jsdelivr.net/gh/${plugin.repository}@${releaseRef}/${plugin.logo}`; +marketplacePlugin.version = newVersion; +marketplacePlugin.description = plugin.description; +marketplacePlugin.interface.shortDescription = plugin.shortDescription; +marketplacePlugin.source.ref = releaseRef; +marketplacePlugin.icon = logoUrl; +marketplacePlugin.interface.logo = logoUrl; +fs.writeFileSync(repositoryMarketplace, `${JSON.stringify(marketplace, null, 2)}\n`); +const codexManifest = path.join(repoDir, ".codex-plugin/plugin.json"); +fs.writeFileSync(codexManifest, bumpCodex(fs.readFileSync(codexManifest, "utf8"))); // 3) 重新生成三平台清单 + 全量校验 -execFileSync(process.execPath, [path.join(root, "scripts/sync-marketplaces.mjs"), "--write"], { stdio: "inherit" }); -execFileSync(process.execPath, [path.join(root, "scripts/sync-marketplaces.mjs")], { stdio: "inherit" }); +const pluginFilter = `--plugin=${pluginId}`; +execFileSync(process.execPath, [path.join(root, "scripts/sync-marketplaces.mjs"), "--write", pluginFilter], { stdio: "inherit" }); +execFileSync(process.execPath, [path.join(root, "scripts/sync-marketplaces.mjs"), pluginFilter], { stdio: "inherit" }); // 4) 提交提示 console.log(` -✅ ${pluginId} ${newVersion} 发版完成。剩余步骤: +✅ ${pluginId} ${newVersion} 本地元数据已准备;尚未提交、推送或发布。剩余步骤: cd ${root} && git add -A && git commit -m "release: ${pluginId} ${newVersion}" && git push cd ${repoDir} && git add -A && git commit -m "release: v${newVersion}" && git push ZCode 插件市场刷新后即可看到「可更新」`); diff --git a/scripts/cve_check.py b/scripts/cve_check.py index d34ef6c..aed0c0a 100644 --- a/scripts/cve_check.py +++ b/scripts/cve_check.py @@ -58,17 +58,38 @@ def run(cmd: list[str], cwd: Path, timeout: int = 600) -> tuple[int, str, str]: def scan_maven(root: Path, threshold: int) -> dict: - """OWASP dependency-check-maven:failBuildOnCVSS 按阈值,报告落 target/dependency-check-report.html""" - rc, out, err = run( - ["mvn", "-q", "org.owasp:dependency-check-maven:check", - f"-DfailBuildOnCVSS={threshold}"], - cwd=root, timeout=1800, - ) + """每次独立 JSON 报告目录,拒绝复用旧报告冒充本次漏洞证据。""" + import tempfile + findings, status = [], "UNVERIFIED" + with tempfile.TemporaryDirectory(prefix="codeguard-cve-") as output_dir: + rc, out, err = run( + ["./mvnw" if (root / "mvnw").exists() else "mvn", "-q", + "org.owasp:dependency-check-maven:aggregate", f"-DfailBuildOnCVSS={threshold}", + "-Dformat=JSON", f"-Dodc.outputDirectory={output_dir}"], cwd=root, timeout=1800) + try: + report = json.loads((Path(output_dir) / "dependency-check-report.json").read_text()) + if not isinstance(report.get("dependencies"), list): + raise TypeError("报告缺少 dependencies") + unknown_score = False + for dependency in report["dependencies"]: + for finding in dependency.get("vulnerabilities", []): + scores = [finding.get(key, {}).get("baseScore", finding.get(key, {}).get("score")) + for key in ("cvssv4", "cvssv3", "cvssv2")] + score = next((s for s in scores if isinstance(s, (int, float))), None) + if score is None: + unknown_score = True + elif score >= threshold: + findings.append({"id": finding.get("name"), "score": score}) + if rc in (0, 1): + status = "FAIL" if findings else ("UNVERIFIED" if unknown_score or rc else "PASS") + except (OSError, ValueError, TypeError, AttributeError): + pass fix_hint = ( "修复路径: mvn versions:display-dependency-updates 查看可升级依赖;" "升级受影响组件版本,或在 dependency-check-suppressions.xml 登记误报" ) return {"ecosystem": "maven", "tool": "owasp dependency-check", "exit": rc, + "status": status, "findings": findings, "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} @@ -80,47 +101,103 @@ def scan_node(root: Path, threshold: str, allow_fix: bool) -> dict: counts = {} fixed_hint = "npm audit fix" try: - meta = json.loads(out).get("metadata", {}).get("vulnerabilities", {}) + meta = json.loads(out).get("metadata", {}).get("vulnerabilities") + if not isinstance(meta, dict) or not meta or any( + not isinstance(v, int) or isinstance(v, bool) or v < 0 for v in meta.values()): + raise ValueError("无有效漏洞统计") counts = {k.lower(): v for k, v in meta.items()} - except json.JSONDecodeError: - pass + counts["medium"] = counts.pop("moderate", counts.get("medium", 0)) + except (json.JSONDecodeError, ValueError, AttributeError): + return {"ecosystem": "node", "tool": "npm audit", "exit": rc, + "status": "UNVERIFIED", "reason": "扫描未返回有效漏洞报告", + "summary_tail": (out + err)[-1000:], "fix_hint": "修复扫描环境后重试"} + if rc not in (0, 1): + return {"ecosystem": "node", "tool": "npm audit", "exit": rc, + "status": "UNVERIFIED", "reason": "扫描进程未正常完成", + "summary_tail": err[-1000:], "fix_hint": "检查网络与扫描器"} over = [s for s in SEVERITY_ORDER if SEVERITY_ORDER[s] >= SEVERITY_ORDER.get(threshold.upper(), 2) and counts.get(s.lower(), 0) > 0] failed = bool(over) fix_hint = fixed_hint if failed else "" return {"ecosystem": "node", "tool": "npm audit", "exit": rc, + "status": "FAIL" if failed else "PASS", "reason": "依据 npm 漏洞统计与严重级别阈值", "counts": counts, "over_threshold": over, "failed": failed, "summary_tail": err[-1000:], "fix_hint": fix_hint} -def scan_pip(root: Path) -> dict: - rc, out, err = run(["pip-audit", "--strict"], cwd=root, timeout=900) +def _native_report_status(rc: int, findings: list, threshold: str) -> tuple[str, str]: + """无严重度的原生报告不能证明 HIGH 阈值通过,也不能冒充高危漏洞。""" + if rc not in (0, 1): + return "UNVERIFIED", "扫描进程未正常完成" + if findings: + if threshold == "LOW": + return "FAIL", "有效报告包含漏洞;LOW 模式报告全部漏洞" + return "UNVERIFIED", "原生报告缺少可比较严重度;已发现漏洞,但不能判定阈值,请用 trivy 复核" + return ("PASS", "有效报告未发现漏洞") if rc == 0 else ("UNVERIFIED", "非零退出但无漏洞证据") + + +def scan_pip(root: Path, threshold: str = "LOW") -> dict: + # 不扫描宿主 Python 环境:必须明确项目依赖输入。 + requirements = next((name for name in ("requirements.txt", "requirements-dev.txt") + if (root / name).is_file()), None) + if requirements: + target = ["--requirement", requirements] + elif (root / "pyproject.toml").is_file(): + target = ["."] + else: + return {"ecosystem": "python", "tool": "pip-audit", "exit": 1, + "status": "UNVERIFIED", "reason": "没有支持的 requirements/pyproject 项目输入"} + rc, out, err = run(["pip-audit", "--strict", "--format", "json", *target], cwd=root, timeout=900) if rc == 127: return {"ecosystem": "python", "tool": "pip-audit", "exit": 127, "summary_tail": "pip-audit not installed (pip install pip-audit)", "fix_hint": "pip install pip-audit"} - fix_hint = "pip-audit 无内置修复;按报告升级 requirements/pyproject 中的受影响包" + status, reason, findings = "UNVERIFIED", "扫描未返回有效漏洞报告", [] + try: + dependencies = json.loads(out)["dependencies"] + if not isinstance(dependencies, list) or any( + not isinstance(d, dict) or not isinstance(d.get("vulns"), list) for d in dependencies): + raise TypeError("无有效依赖列表") + findings = [v for d in dependencies for v in d["vulns"]] + if any(not isinstance(v, dict) or not v.get("id") for v in findings): + raise ValueError("漏洞缺少标识") + status, reason = _native_report_status(rc, findings, threshold) + except (ValueError, TypeError, KeyError): + pass + fix_hint = "按报告升级 requirements/pyproject 中的受影响包;本入口不自动修改 Python 依赖" return {"ecosystem": "python", "tool": "pip-audit", "exit": rc, + "status": status, "reason": reason, "findings": findings, "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} -def scan_cargo(root: Path) -> dict: +def scan_cargo(root: Path, threshold: str = "LOW") -> dict: # cargo 本体存在但 audit 是外部子命令——先探测,未安装返回 127(无法验证) rc_v, _, err_v = run(["cargo", "audit", "--version"], cwd=root, timeout=60) if rc_v == 127 or "no such command" in (err_v or "").lower() or "unrecognized" in (err_v or "").lower(): return {"ecosystem": "rust", "tool": "cargo audit", "exit": 127, "summary_tail": "cargo-audit not installed (cargo install cargo-audit)", "fix_hint": "cargo install cargo-audit"} - rc, out, err = run(["cargo", "audit"], cwd=root, timeout=900) + rc, out, err = run(["cargo", "audit", "--json"], cwd=root, timeout=900) + status, reason, findings = "UNVERIFIED", "扫描未返回有效漏洞报告", [] + try: + findings = json.loads(out)["vulnerabilities"]["list"] + if not isinstance(findings, list): + raise TypeError("无有效漏洞列表") + if any(not isinstance(v, dict) or not v.get("advisory", {}).get("id") for v in findings): + raise ValueError("漏洞缺少 advisory 标识") + status, reason = _native_report_status(rc, findings, threshold) + except (ValueError, TypeError, KeyError, AttributeError): + pass fix_hint = "按报告升级 Cargo.toml 中的受影响 crate(cargo update 可试)" return {"ecosystem": "rust", "tool": "cargo audit", "exit": rc, + "status": status, "reason": reason, "findings": findings, "summary_tail": (out + err)[-2500:], "fix_hint": fix_hint} def scan_trivy(root: Path, threshold: str) -> dict: rc, out, err = run( - ["trivy", "fs", "--scanners", "vuln", + ["trivy", "fs", "--scanners", "vuln", "--format", "json", "--exit-code", "2", "--severity", ",".join(severities_at_and_above(threshold)), "."], cwd=root, timeout=1800, @@ -129,7 +206,16 @@ def scan_trivy(root: Path, threshold: str) -> dict: return {"ecosystem": "universal", "tool": "trivy", "exit": 127, "summary_tail": "trivy not installed (brew install trivy)", "fix_hint": "brew install trivy"} - return {"ecosystem": "universal", "tool": "trivy", "exit": rc, + status = "UNVERIFIED" + try: + report = json.loads(out) + if isinstance(report, dict) and "Results" in report and rc in (0, 2): + findings = [v for item in report["Results"] or [] for v in item.get("Vulnerabilities", []) or [] + if v.get("Severity") in severities_at_and_above(threshold)] + status = "FAIL" if findings else "PASS" + except (ValueError, TypeError, AttributeError): + pass + return {"ecosystem": "universal", "tool": "trivy", "exit": rc, "status": status, "summary_tail": (out + err)[-2500:], "fix_hint": "按报告升级受影响依赖版本"} @@ -146,16 +232,18 @@ def _scan_node_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: if allow_fix and result.get("failed"): print("[codeguard-cve] npm audit fix ...") run(["npm", "audit", "fix"], cwd=root, timeout=900) - result["after_fix"] = scan_node(root, severity, False) + after = scan_node(root, severity, False) + after["before_fix"] = result + result = after return result def _scan_pip_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - return scan_pip(root) + return scan_pip(root, severity) def _scan_cargo_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: - return scan_cargo(root) + return scan_cargo(root, severity) def _scan_trivy_ecosystem(root: Path, severity: str, allow_fix: bool) -> dict: @@ -245,7 +333,26 @@ def main() -> int: ap.add_argument("--severity", default="HIGH", choices=list(SEVERITY_ORDER)) ap.add_argument("--json", action="store_true", dest="as_json") ap.add_argument("path", nargs="?", default=".") - args = ap.parse_args() + try: + args = ap.parse_args() + except SystemExit as exc: + if exc.code == 2: + return EXIT_USAGE + raise + + results = [] + if args.as_json: + import contextlib + import io + with contextlib.redirect_stdout(io.StringIO()): + rc = _scan(args, results) + print(json.dumps({"status": {0: "PASS", 1: "UNVERIFIED", 2: "FAIL", 3: "USAGE"}[rc], + "exit_code": rc, "results": results}, ensure_ascii=False)) + return rc + return _scan(args, results) + + +def _scan(args, results: list) -> int: root = find_project_root(args.path) or Path(args.path).resolve() @@ -272,26 +379,27 @@ def main() -> int: print(f"[codeguard-cve] project: {root}") print(f"[codeguard-cve] ecosystems: {ecosystems or '(none detected)'}") - results = [] for eco in ecosystems: spec = ECOSYSTEM_SCANNERS[eco] scannable, why = precheck(root, eco) if not scannable: - results.append({"ecosystem": eco, "tool": eco, "exit": 127, + results.append({"ecosystem": eco, "tool": eco, "exit": 127, "status": "UNVERIFIED", "summary_tail": f"无法验证: {why}", "fix_hint": "确认项目类型后重试"}) print(f" {eco:8s} SKIP({why})") continue r = spec["scan"](root, args.severity, args.fix) results.append(r) - status = "PASS" if r["exit"] == 0 else f"FAILED(exit={r['exit']})" + # 未提供结构化证据的原生适配器只接受成功;非零不能凭空证明有漏洞。 + r.setdefault("status", "PASS" if r["exit"] == 0 else "UNVERIFIED") + status = r["status"] print(f" {eco:8s} {r['tool']:28s} {status}") if not results: print("[codeguard-cve] 无可扫描生态(或对应工具未安装)") return EXIT_UNVERIFIED - failed = [r for r in results if r["exit"] not in (0, 127) or r.get("failed")] - unverifiable = [r for r in results if r["exit"] == 127] + failed = [r for r in results if r.get("status") == "FAIL"] + unverifiable = [r for r in results if r.get("status", "UNVERIFIED") == "UNVERIFIED"] print() for r in failed: print(f"[codeguard-cve] ❌ {r['ecosystem']} 有漏洞需修复:") @@ -299,7 +407,7 @@ def main() -> int: print(" " + r["summary_tail"].replace("\n", "\n ")[:2000]) print(f" ➜ {r.get('fix_hint', '')}") for r in unverifiable: - print(f"[codeguard-cve] ⚠️ {r['ecosystem']} 无法验证(工具未安装): {r.get('summary_tail', '')}") + print(f"[codeguard-cve] ⚠️ {r['ecosystem']} 无法验证: {r.get('reason', '')} {r.get('summary_tail', '')}") print(f" ➜ 安装后重跑: {r.get('fix_hint', '')}") if failed: n = len(failed) diff --git a/scripts/detect_lang.py b/scripts/detect_lang.py index 0aa03d3..8566dae 100644 --- a/scripts/detect_lang.py +++ b/scripts/detect_lang.py @@ -92,6 +92,7 @@ def _load_registry() -> dict[str, dict[str, Any]]: "probe": _lang.get("probe"), # 显式探活命令(npx 系必配,覆盖包未装场景) "requiresConfig": _lang.get("requiresConfig"), # 未接入配置的项目归 skipped "install_hint": _lang.get("install_hint"), + "append_files": _lang.get("append_files", True), } if _lang.get("install_hint"): LANG_INSTALL_HINTS[_id] = _lang["install_hint"] diff --git a/scripts/git_snapshot.py b/scripts/git_snapshot.py new file mode 100644 index 0000000..11f3a0d --- /dev/null +++ b/scripts/git_snapshot.py @@ -0,0 +1,131 @@ +"""只读 index/HEAD,物化一次性检查目录;从不 stash、checkout 或改写用户 index。""" +from __future__ import annotations + +import contextlib +import os +import subprocess +import tempfile +from pathlib import Path, PurePosixPath + + +class SnapshotError(RuntimeError): + """无法准确取得拟提交内容,调用者必须报告未验证。""" + + +def git(root: Path, *args: str) -> bytes: + try: + proc = subprocess.run(["git", *args], cwd=root, capture_output=True, + check=False, timeout=30) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SnapshotError(str(exc)) from exc + if proc.returncode: + raise SnapshotError(proc.stderr.decode(errors="replace")[:500]) + return proc.stdout + + +def names(root: Path, *args: str) -> set[str]: + return {os.fsdecode(p) for p in git(root, *args).split(b"\0") if p} + + +def safe_path(root: Path, name: str) -> Path: + parts = PurePosixPath(name).parts + if not parts or PurePosixPath(name).is_absolute() or ".." in parts or ".git" in parts: + raise SnapshotError(f"不安全的快照路径: {name}") + return root.joinpath(*parts) + + +def push_paths(root: Path, include_deleted=False) -> set[str]: + for base in ("@{upstream}", "origin/" + git(root, "branch", "--show-current").decode().strip(), + "origin/main", "origin/master"): + try: + return names(root, "diff", "--name-only", "--diff-filter=ACMRD" if include_deleted else "--diff-filter=ACMR", + "--no-renames", "-z", f"{base}...HEAD") + except SnapshotError: + continue + # 首次推送不能因为没有 upstream 而把整个 HEAD 当成已验证。 + return names(root, "ls-tree", "-r", "--name-only", "-z", "HEAD") + + +def overlays(root: Path, lanes=None, extra=None) -> set[str]: + selected: set[str] = set() + if "unstaged" in (lanes or ()): + selected |= names(root, "diff", "--name-only", "-z") + if "untracked" in (lanes or ()): + selected |= names(root, "ls-files", "--others", "--exclude-standard", "-z") + for name in extra or (): + path = safe_path(root, name) + selected |= names(root, "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", name) + if path.is_file() or path.is_symlink(): + selected.add(name) + return selected + + +def proposed_paths(root: Path, mode="commit", *, lanes=None, extra=None, + pending_commit=False, include_deleted=False) -> list[str]: + """拟入库的新/修改路径,保留敏感文件与产物,删除文件不算新入库。""" + selected = push_paths(root, include_deleted) if mode == "push" else set() + if mode != "push" or pending_commit: + selected |= names(root, "diff", "--cached", "--name-only", "--no-renames", + "--diff-filter=ACMRD" if include_deleted else "--diff-filter=ACMR", "-z") + for name in overlays(root, lanes, extra): + path = safe_path(root, name) + if include_deleted or path.exists() or path.is_symlink(): + selected.add(name) + else: + selected.discard(name) + return sorted(selected) + + +@contextlib.contextmanager +def validation_tree(root: Path, mode="commit", *, lanes=None, extra=None, pending_commit=False): + """原始 Git blobs 构成快照;拒绝外链、submodule、冲突和超限内容。""" + head = mode == "push" and not pending_commit + raw = git(root, "ls-tree", "-r", "-z", "HEAD") if head else git(root, "ls-files", "--stage", "-z") + entries = [] + for line in raw.split(b"\0"): + if not line: + continue + meta, filename = line.split(b"\t", 1) + mode_bits, middle, last = meta.decode().split() + oid = last if head else middle + if mode_bits not in ("100644", "100755") or (not head and last != "0"): + raise SnapshotError("符号链接、子模块或未解决冲突需要独立检查") + entries.append((mode_bits, oid, os.fsdecode(filename))) + if len(entries) > 20000: + raise SnapshotError("快照超过 20000 文件上限,需要独立 CI 检查") + object_ids = "".join(oid + "\n" for _, oid, _ in entries).encode() + try: + sizes = subprocess.run(["git", "cat-file", "--batch-check"], input=object_ids, + cwd=root, capture_output=True, check=True, timeout=30).stdout + if sum(int(line.split()[-1]) for line in sizes.splitlines()) > 256 * 1024 * 1024: + raise SnapshotError("快照超过 256 MiB 上限,需要独立 CI 检查") + blob = subprocess.run(["git", "cat-file", "--batch"], input=object_ids, + cwd=root, capture_output=True, check=True, timeout=30).stdout + except (ValueError, OSError, subprocess.SubprocessError) as exc: + raise SnapshotError(f"读取 Git 内容失败: {exc}") from exc + changed = proposed_paths(root, mode, lanes=lanes, extra=extra, pending_commit=pending_commit, include_deleted=True) + with tempfile.TemporaryDirectory(prefix="codeguard-snapshot-") as directory: + target = Path(directory) + offset = 0 + for mode_bits, _oid, name in entries: + end = blob.index(b"\n", offset) + size = int(blob[offset:end].split()[-1]) + dest = safe_path(target, name) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(blob[end + 1:end + 1 + size]) + dest.chmod(0o755 if mode_bits == "100755" else 0o644) + offset = end + size + 2 + if not head: + for name in overlays(root, lanes, extra): + source, dest = safe_path(root, name), safe_path(target, name) + if source.is_symlink() or not source.resolve().is_relative_to(root.resolve()): + raise SnapshotError(f"不跟随仓外路径/符号链接: {name}") + if source.is_file(): + if source.stat().st_size > 32 * 1024 * 1024: + raise SnapshotError(f"工作树文件过大: {name}") + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(source.read_bytes()) + dest.chmod(source.stat().st_mode & 0o777) + elif dest.is_file(): + dest.unlink() + yield target, changed diff --git a/scripts/java_project.py b/scripts/java_project.py new file mode 100644 index 0000000..d7575fe --- /dev/null +++ b/scripts/java_project.py @@ -0,0 +1,216 @@ +"""Java 项目感知:只读构建描述,计算模块反向依赖闭包,不执行构建或索引。""" +from __future__ import annotations + +import argparse +import json +import os +import re +import xml.etree.ElementTree as ET +from pathlib import Path + +_BUILD_FILES = {"pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle", + "settings.gradle.kts", "gradle.properties", "mvnw", "gradlew", "codeguard.json"} + + +def _inside(root: Path, path: Path) -> Path: + resolved = path.resolve() + if not resolved.is_relative_to(root): + raise ValueError(f"构建路径在仓外: {path}") + return resolved + + +def _text(node, path: str, default="") -> str: + return node.findtext(path, default=default).strip() + + +def _maven(root: Path) -> tuple[dict, list[str]]: + modules: dict = {} + reasons: list[str] = [] + + def visit(directory: Path, inherited: dict): + directory = _inside(root, directory) + name = directory.relative_to(root).as_posix() + if name in modules: + return + doc = ET.parse(_inside(root, directory / "pom.xml")).getroot() + props = dict(inherited) + properties = doc.find("{*}properties") + if properties is not None: + props.update({p.tag.split("}")[-1]: (p.text or "").strip() for p in properties}) + + def expand(value: str) -> str: + for _ in range(10): + newer = re.sub(r"\$\{([^}]+)\}", lambda m: props.get(m[1], m[0]), value) + if newer == value: + break + value = newer + if "${" in value: + reasons.append(f"{name}: 无法静态解析 {value},回退全模块") + return value + + group = expand(_text(doc, "{*}groupId") or _text(doc, "{*}parent/{*}groupId") + or props.get("project.groupId", "")) + artifact = expand(_text(doc, "{*}artifactId")) + if not artifact: + raise ValueError(f"{name}: 缺少 artifactId") + props.update({"project.groupId": group, "pom.groupId": group, + "project.artifactId": artifact}) + dependencies = [] + for dep in doc.findall("{*}dependencies/{*}dependency"): + dependencies.append((expand(_text(dep, "{*}groupId")), expand(_text(dep, "{*}artifactId")))) + plugins = [p.text or "" for p in doc.findall("{*}build/{*}plugins/{*}plugin/{*}artifactId")] + modules[name] = {"path": name, "coordinate": (group, artifact), + "raw_dependencies": dependencies, "dependencies": [], "plugins": plugins} + if doc.find("{*}profiles") is not None: + reasons.append(f"{name}: Maven profiles 可能改变模块/依赖,回退根 verify") + if dependencies and doc.findall("{*}modules/{*}module"): + reasons.append(f"{name}: 聚合父 POM 依赖可能被子模块继承,回退根 verify") + for child in doc.findall("{*}modules/{*}module"): + visit(directory / expand((child.text or "").strip()), props) + + visit(root, {}) + coordinates = {} + for name, module in modules.items(): + coordinate = module["coordinate"] + if coordinate in coordinates: + reasons.append("模块坐标不唯一,回退全模块") + coordinates[coordinate] = name + for module in modules.values(): + module["dependencies"] = sorted({coordinates[d] for d in module.pop("raw_dependencies") if d in coordinates}) + module.pop("coordinate") + return modules, reasons + + +def _gradle(root: Path) -> tuple[dict, list[str]]: + reasons = [] + modules = {".": {"path": ".", "dependencies": [], "plugins": []}} + settings = next((p for p in (root / "settings.gradle.kts", root / "settings.gradle") if p.exists()), None) + if settings: + body = _inside(root, settings).read_text() + includes = re.findall(r"\binclude\s*(\([^)]*\)|[^\n]+)", body) + for expression in includes: + literals = re.findall(r"['\"](:?[\w:-]+)['\"]", expression) + residue = re.sub(r"['\"](:?[\w:-]+)['\"]|[\s,()]", "", expression) + if not literals or residue: + reasons.append("Gradle include 使用动态表达式,回退根 check") + for literal in literals: + name = literal.lstrip(":").replace(":", "/") + _inside(root, root / name) + modules[name] = {"path": name, "dependencies": [], "plugins": []} + if re.search(r"includeBuild|projectDir|apply\s|\bfor\s*\(|\.each\b", body): + reasons.append("Gradle settings 存在动态/复合构建,回退根 check") + for name, module in modules.items(): + directory = _inside(root, root / name) + build = next((p for p in (directory / "build.gradle.kts", directory / "build.gradle") if p.exists()), None) + if not build: + reasons.append(f"{name}: 没有可读取的 Gradle 构建描述,回退根 check") + continue + body = _inside(root, build).read_text() + references = re.findall(r"\bproject\s*\(\s*(?:path\s*[:=]\s*)?['\"](:[\w:-]+)['\"]\s*\)", body) + module["dependencies"] = sorted({r.lstrip(":").replace(":", "/") for r in references}) + if any(d not in modules for d in module["dependencies"]): + reasons.append(f"{name}: 引用了未声明项目,回退根 check") + if len(re.findall(r"\bproject\s*\(", body)) != len(references) or re.search( + r"apply\s+from|apply\s*\(\s*from|subprojects|allprojects|buildSrc|\bprojects\.|\bif\s*\(|\bfor\s*\(", body): + reasons.append(f"{name}: Gradle 动态构建无法可靠缩小范围,回退根 check") + if (root / "buildSrc").exists(): + reasons.append("存在 buildSrc 构建逻辑,回退根 check") + return modules, reasons + + +def analyze(project_root: str | Path, changed: list[str] | None = None) -> dict: + """返回只读计划;changed=None 全量,[] 无变更;绝不把规划当成验证通过。""" + root = Path(project_root).resolve() + plan = {"status": "UNVERIFIED", "root": str(root), "build_system": None, + "changed_paths": changed, + "analysis_level": "module", "modules": [], "affected_modules": [], "commands": [], + "conservative": False, "reasons": [], "gaps": ["模块依赖图不是完整符号/业务语义分析"]} + try: + if (root / "pom.xml").is_file(): + system, (modules, reasons) = "maven", _maven(root) + elif any((root / n).is_file() for n in ("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts")): + system, (modules, reasons) = "gradle", _gradle(root) + else: + raise ValueError("未找到 Maven/Gradle 构建描述") + plan.update(build_system=system, modules=list(modules.values())) + wrapper = "mvnw" if system == "maven" else "gradlew" + if os.name == "nt": + wrapper += ".cmd" if system == "maven" else ".bat" + executable = "./" + wrapper if (root / wrapper).is_file() else ("mvn" if system == "maven" else "gradle") + if (root / wrapper).exists() and os.name != "nt" and not os.access(root / wrapper, os.X_OK): + raise ValueError(f"wrapper 不可执行: {wrapper}") + relevant = None if changed is None else [p for p in changed if p.endswith((".java", ".kt", ".groovy", ".scala")) + or "/src/" in "/" + p + or Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/"))] + if relevant is not None: + for name in relevant: + _inside(root, root / name) + full = relevant is None or bool(reasons) or any( + Path(p).name in _BUILD_FILES or p.startswith((".mvn/", "gradle/")) for p in relevant or []) + affected = set(modules) if full else set() + if not full: + for path in relevant or []: + owners = [m for m in modules if m == "." or path.startswith(m + "/")] + if owners: + affected.add(max(owners, key=len)) + # 反向传递闭包:改动依赖的模块必须重新验证。 + while True: + expanded = affected | {m for m, detail in modules.items() if affected.intersection(detail["dependencies"])} + if expanded == affected: + break + affected = expanded + if relevant == []: + affected = set() + commands = [] + if affected: + if system == "maven": + argv = [executable, "-B"] + if not full and "." not in affected: + argv += ["-pl", ",".join(sorted(affected)), "-am"] + argv += ["verify"] + else: + argv = [executable] + (["check"] if full or "." in affected else + [":" + p.replace("/", ":") + ":check" for p in sorted(affected)]) + commands = [{"kind": "verify", "argv": argv}] + config = root / "codeguard.json" + if config.exists(): + explicit = json.loads(_inside(root, config).read_text()).get("java", {}).get("commands") + if explicit is not None: + if not isinstance(explicit, list) or not explicit or any( + not isinstance(c, list) or not c or any(not isinstance(a, str) or not a for a in c) + for c in explicit): + raise ValueError("java.commands 必须是非空 argv 数组列表") + commands = [{"kind": "configured", "argv": c} for c in explicit] + reasons.append("使用 codeguard.json 明确声明的权威检查命令") + plugins = {p for m in modules.values() for p in m["plugins"]} + if not plugins.intersection({"maven-checkstyle-plugin", "maven-pmd-plugin", "spotbugs-maven-plugin"}): + plan["gaps"].append("未确认静态规则已绑定生命周期;verify/check 成功不代表 Checkstyle/PMD 全覆盖") + plan.update(status="PLANNED" if commands else "SKIPPED", commands=commands, + affected_modules=sorted(affected), conservative=bool(full and relevant is not None), + reasons=reasons or ["按构建模块及反向依赖闭包选择;命令尚未执行"]) + except (OSError, ValueError, TypeError, AttributeError, ET.ParseError) as exc: + plan.update(status="UNVERIFIED", commands=[], reasons=[str(exc)]) + return plan + + +def main() -> int: + parser = argparse.ArgumentParser(description="只读 Java 项目与影响分析;不会执行构建或安装工具") + parser.add_argument("--json", action="store_true") + parser.add_argument("--changed", nargs="*", default=None, help="仓根相对路径;缺省全量") + parser.add_argument("path", nargs="?", default=".") + args = parser.parse_args() + plan = analyze(args.path, args.changed) + if args.json: + print(json.dumps(plan, ensure_ascii=False, indent=2)) + else: + print(f"Java {plan['status']} | {plan['build_system']} | 模块级分析(未执行)") + print("影响模块: " + ", ".join(plan["affected_modules"])) + for command in plan["commands"]: + import shlex + print("计划命令: " + shlex.join(command["argv"])) + print("说明: " + ";".join(plan["reasons"] + plan["gaps"])) + return 1 if plan["status"] == "UNVERIFIED" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/languages.json b/scripts/languages.json index 7e010e3..4e660b4 100644 --- a/scripts/languages.json +++ b/scripts/languages.json @@ -2,7 +2,7 @@ "version": 1, "description": "codeguard 语言注册表:单一事实源。detect_lang.py 读取本文件构建识别与命令表;docs/LANGUAGES.md 由 scripts/gen_language_docs.py 从本文件生成。", "status_levels": { - "stable": "默认强制,久经生产验证", + "stable": "已配置可执行命令;实际覆盖取决于项目接入、工具链和本次检查证据", "beta": "已启用强制,需按 install_hint 准备工具", "planned": "已识别扩展名,linter 集成在路线图上(安全跳过)" }, @@ -16,15 +16,17 @@ ], "markers": [ "pom.xml", - "build.gradle" + "build.gradle", + "build.gradle.kts", + "settings.gradle", + "settings.gradle.kts" ], "status": "stable", "since": "V0.1", "lint": [ "mvn", - "-q", - "javadoc:jar", - "-DskipTests" + "-B", + "verify" ], "format": [ "mvn", @@ -36,7 +38,8 @@ "pom.xml", "build.gradle", "build.gradle.kts", - "settings.gradle" + "settings.gradle", + "settings.gradle.kts" ], "linter_config_files": [ ".pre-commit-config.yaml", diff --git a/scripts/run_check.py b/scripts/run_check.py index 2be1555..92dceeb 100644 --- a/scripts/run_check.py +++ b/scripts/run_check.py @@ -9,7 +9,7 @@ python3 run_check.py --mcp # MCP server 模式(官方 SDK stdio) MCP 模式(OpenSpec 2026-09-22-fix-gate-trigger-and-mcp): - 暴露 check_code_style / auto_fix / list_languages 三个工具; + 暴露 check_code_style / auto_fix / list_languages / analyze_java_impact 四个工具; 依赖 `mcp>=1.0`(见根 requirements.txt)。按语言执行细节全部委托给 `scripts/run_per_language.py`;本文件负责 argparse、报告输出与 MCP 接线。 """ @@ -81,12 +81,10 @@ def cli_main(): timeout=args.timeout, fix=args.fix, log_dir=log_dir, ) for r, lang in zip(results, languages, strict=False): - if r.get("dry_run"): - continue - if not r["passed"] and args.fix: + if r.get("status") == "FAIL" and args.fix: print(f"[codeguard] ⚠️ {lang} lint failed, attempting auto-fix...") - if r.get("passed") and r.get("unverified"): - status = f"⚠️ unverified ({r['unverified']})" + if r.get("status") in ("UNVERIFIED", "PLANNED", "SKIPPED"): + status = f"⚠️ {r['status']} ({r.get('reason', '')})" else: status = "✅ passed" if r["passed"] else f"❌ FAILED (exit={r.get('exit_code')})" print(f" {lang:12s} {status}") @@ -94,11 +92,15 @@ def cli_main(): print(f" └─ 完整日志: {r['log_path']}") print() - if all(r["passed"] for r in results): - print("[codeguard] ✅ all passed") + from verdict import exit_status + verdict_code = exit_status(results) + if verdict_code == 0: + print("[codeguard] ✅ all passed" if all(r["passed"] for r in results) + else "[codeguard] 无须检查的项已跳过;其余检查通过") return 0 - print("[codeguard] ❌ some lints failed") - return 2 + print("[codeguard] ❌ some lints failed" if verdict_code == 2 + else "[codeguard] ⚠️ UNVERIFIED:检查未完成,不能视为通过") + return verdict_code # ══════════════════════════ MCP server(官方 SDK) ══════════════════════════ @@ -114,6 +116,8 @@ def _mcp_envelope(results: list[dict]) -> list[dict]: envelope.append({ "language": r.get("language", ""), "passed": bool(r.get("passed")), + "status": r.get("status", "PASS" if r.get("passed") else "FAIL"), + "reason": r.get("reason") or r.get("unverified", ""), "exit_code": int(r.get("exit_code", 0)), "stderr_path": log_path, "log_path": log_path, @@ -129,8 +133,23 @@ def _load_registry_entries() -> list[dict]: ] +def _auto_fix(root: Path, langs: list[str]) -> dict: + """默认仅修复实际改动;无法取得 Git 范围时不扩大写入范围。""" + from scope import changed_files + files = changed_files(root) + if files is None: + return {"fixed": False, "status": "UNVERIFIED", "reason": "无法确定 Git 改动范围;请显式使用 CLI fix --all", + "check": []} + targets = [root / f for f in files if (root / f).is_file() and not (root / f).is_symlink()] + before = {p: p.read_bytes() for p in targets} + fix_results = run_per_language.run_fix(langs, root, files=files) + results = run_per_language.run_check(langs, root, files=files, log_dir=root / "out") + changed = any(not p.is_file() or p.read_bytes() != body for p, body in before.items()) + return {"fixed": changed, "fix_results": fix_results, "check": _mcp_envelope(results)} + + def mcp_main(project_root: Path) -> int: - """官方 mcp SDK stdio server:check_code_style / auto_fix / list_languages。""" + """官方 mcp SDK stdio server,含只读 Java 影响分析。""" try: import mcp.types as mcp_types from mcp.server import Server @@ -162,7 +181,7 @@ async def list_tools() -> list: ), mcp_types.Tool( name="auto_fix", - description="先跑 formatter 链,再复检 lint(嵌入 check 信封)", + description="仅对 Git 改动文件跑 formatter 并按相同范围复检;项目级 formatter 不自动扩展范围", inputSchema=input_schema, ), mcp_types.Tool( @@ -170,10 +189,20 @@ async def list_tools() -> list: description="列出支持的语言 id 与显示名(不含内部 lint/format 命令)", inputSchema={"type": "object", "properties": {}}, ), + mcp_types.Tool( + name="analyze_java_impact", description="只读分析 Java 构建系统、模块依赖和受影响检查计划;不执行构建", + inputSchema={"type": "object", "properties": { + "path": {"type": "string"}, "changed": {"type": "array", "items": {"type": "string"}}, + }}, + ), ] @server.call_tool() async def call_tool(name: str, arguments: dict) -> list: + if name == "analyze_java_impact": + from java_project import analyze + args = arguments or {} + return [text_block(analyze(args.get("path") or project_root, args.get("changed")))] if name == "list_languages": return [text_block(_load_registry_entries())] if name not in ("check_code_style", "auto_fix"): @@ -186,15 +215,7 @@ async def call_tool(name: str, arguments: dict) -> list: if not langs: return [text_block({"error": "未识别到语言", "path": str(root)})] if name == "auto_fix": - fix_results = run_per_language.run_fix(langs, root) - results = run_per_language.run_check(langs, root, log_dir=root / "out") - any_fixed = any( - r.get("fixed") for r in fix_results if not r.get("dry_run") - ) - payload = { - "fixed": bool(any_fixed), - "check": _mcp_envelope(results), - } + payload = _auto_fix(root, langs) else: results = run_per_language.run_check(langs, root, log_dir=root / "out") payload = _mcp_envelope(results) diff --git a/scripts/run_per_language.py b/scripts/run_per_language.py index 7f0cc3f..affe0b4 100644 --- a/scripts/run_per_language.py +++ b/scripts/run_per_language.py @@ -14,7 +14,7 @@ import subprocess from pathlib import Path -from detect_lang import LANG_COMMANDS, detect_language +from detect_lang import LANG_COMMANDS, detect_language, project_uses_linter from scope import scope_cmd __all__ = ["run_check", "run_fix"] @@ -33,7 +33,8 @@ def _run(cmd: list[str], cwd: Path, timeout: int) -> tuple[int, str, str]: def run_check(languages: list[str], project_root: Path, *, timeout: int = 120, fix: bool = False, - dry_run: bool = False, log_dir: Path | None = None) -> list[dict]: + dry_run: bool = False, log_dir: Path | None = None, + files: list[str] | None = None) -> list[dict]: """对每个语言跑 lint;fix=True 时失败后自动跑 format 复检一次。 `log_dir` 非空且有语言失败时,完整输出(stdout+stderr 合并——ruff 等 @@ -41,55 +42,68 @@ def run_check(languages: list[str], project_root: Path, (组头含语言与退出码),失败条目附带 `log_path`; 全部通过则不产生日志文件。 """ + from verdict import FAIL, PASS, PLANNED, SKIPPED, UNVERIFIED, lint_verdict, result + results: list[dict] = [] log_entries: list[tuple[str, int, str]] = [] for lang in languages: cmd_def = LANG_COMMANDS.get(lang) if not cmd_def: - results.append({"language": lang, "passed": False, "error": "no command defined"}) + results.append(result(lang, PLANNED, "没有可执行检查命令")) + continue + lang_files = None if files is None else [f for f in files + if detect_language(f, project_root) == lang and (project_root / f).is_file()] + if lang != "java" and lang_files == []: + results.append(result(lang, SKIPPED, "本次改动未涉及该语言的现存文件")) continue - lint_cmd = cmd_def.get("lint") + if not project_uses_linter(cmd_def, project_root): + results.append(result(lang, UNVERIFIED, "项目缺少已声明的检查配置,未执行")) + continue + lint_cmd = (cmd_def.get("gate") or cmd_def.get("lint")) if files is None else cmd_def.get("lint") + java_plan = None + if lang == "java": + from java_project import analyze + java_plan = analyze(project_root, files) + if not java_plan["commands"]: + results.append(result(lang, java_plan["status"], ";".join(java_plan["reasons"]), + java_plan=java_plan)) + continue + lint_cmd = java_plan["commands"][0]["argv"] if dry_run or not lint_cmd: - results.append({"language": lang, "passed": True, - "dry_run": True, "command": lint_cmd or []}) + results.append(result(lang, PLANNED, "计划检查,尚未执行", + dry_run=True, command=lint_cmd or [])) continue # CLI/MCP 是仓健康检查(全量):注入默认 ruff 配置 + 剔除 vendor 快照, # 否则规则集随机器漂移、供应链快照给出不可修的永久红。 - lint_cmd = scope_cmd(lint_cmd, project_root, full_excludes=True) - rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) - if rc == 2: - # exit 2 = 用法/依赖/配置崩溃,与仓库内容无关 → unverified - # (不计失败、不触发 fix、不写失败日志)。 - # exit 127(命令不存在)**故意保持失败**:check CLI 是 CI/健康面, - # "工具没装"在那里就该红——test_mcp_server 的失败日志与安静模式用例 - # 依赖这条环境无关性(CI 无 ruff,靠 127=失败才进得了失败路径)。 - # 交互钩子路径对 127 归 skipped(不挡人工作):按场景的有意分歧, - # 不是口径 bug(v0.8.1 曾误统一过一次,本提交修正)。 - results.append({ - "language": lang, - "passed": True, - "exit_code": 2, - "unverified": "exit 2(工具链异常,非 lint 结论)", - "stderr_tail": err[-2000:] if err else "", - "stdout_tail": out[-1000:] if out else "", - }) - continue - passed = rc == 0 - if not passed and fix: - fmt = cmd_def.get("format") - if fmt: - _run(scope_cmd(fmt, project_root, full_excludes=True), - 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 "", - }) - if not passed and (err or out): + if java_plan: + commands = [c["argv"] for c in java_plan["commands"]] + elif "{file}" in " ".join(lint_cmd): + if lang_files is None: + results.append(result(lang, UNVERIFIED, "只有单文件命令,未配置项目 gate")) + continue + commands = [scope_cmd(lint_cmd, project_root, single_file=f) for f in lang_files] + else: + commands = [scope_cmd(lint_cmd, project_root, files=lang_files, + full_excludes=lang_files is None, + append_files=cmd_def.get("append_files", True))] + for lint_cmd in commands: + rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) + if rc != 0: + break + status, reason = lint_verdict(rc, lint_cmd, out + err) + if status == FAIL and fix: + fixed = run_fix([lang], project_root, timeout=timeout + 60, files=files)[0] + if fixed.get("fixed") and not fixed.get("dry_run"): + for lint_cmd in commands: + rc, out, err = _run(lint_cmd, cwd=project_root, timeout=timeout) + if rc: + break + status, reason = lint_verdict(rc, lint_cmd, out + err) + results.append(result(lang, status, reason, exit_code=rc, + unverified=reason if status == UNVERIFIED else "", + command=lint_cmd, java_plan=java_plan, + stderr_tail=err[-2000:], stdout_tail=out[-1000:])) + if status != PASS and (err or out): combined = "" if out: combined += out @@ -126,6 +140,11 @@ def run_fix(languages: list[str], project_root: Path, `files` 非空 = 仅修复这些文件(fix.py 缺省 delta 模式);某语言在改动 里没有文件 → 该语言整行跳过(skipped 标记),绝不动仓里其它存量文件。 """ + if files is not None and any( + (project_root / f).is_symlink() or not (project_root / f).resolve().is_relative_to(project_root.resolve()) + for f in files): + return [{"language": lang, "fixed": False, "status": "UNVERIFIED", "exit_code": 1, + "note": "修复范围含符号链接或仓外路径,未执行 formatter"} for lang in languages] results: list[dict] = [] for lang in languages: cmd_def = LANG_COMMANDS.get(lang) @@ -136,17 +155,33 @@ def run_fix(languages: list[str], project_root: Path, if files is not None: lang_files = [f for f in files if detect_language(f, project_root) == lang] if not lang_files: - results.append({"language": lang, "fixed": True, "skipped": True, + results.append({"language": lang, "fixed": False, "skipped": True, "status": "SKIPPED", "note": "本次改动未涉及该语言"}) continue fmt = cmd_def.get("format") - if dry_run or not fmt: - results.append({"language": lang, "fixed": True, - "dry_run": True, "command": fmt or []}) + if files is not None and not cmd_def.get("append_files", True): + results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", + "note": "项目级 formatter 无法限制到改动文件;需显式 --all", "exit_code": 1}) + continue + if not fmt or dry_run: + results.append({"language": lang, "fixed": False, "status": "PLANNED", + "dry_run": dry_run, "command": fmt or [], "exit_code": 1, + "note": "尚未执行修复" if dry_run else "未配置 formatter"}) continue - cmd = scope_cmd(fmt, project_root, files=lang_files, - full_excludes=(lang_files is None)) - rc, _out, err = _run(cmd, cwd=project_root, timeout=timeout) + if "{file}" in " ".join(fmt): + if lang_files is None: + results.append({"language": lang, "fixed": False, "status": "UNVERIFIED", + "note": "formatter 需要明确文件列表", "exit_code": 1}) + continue + commands = [scope_cmd(fmt, project_root, single_file=f) for f in lang_files] + else: + commands = [scope_cmd(fmt, project_root, files=lang_files, + full_excludes=(lang_files is None), + append_files=cmd_def.get("append_files", True))] + for cmd in commands: + rc, _out, err = _run(cmd, cwd=project_root, timeout=timeout) + if rc: + break results.append({ "language": lang, "fixed": rc == 0, diff --git a/scripts/verdict.py b/scripts/verdict.py new file mode 100644 index 0000000..afd1745 --- /dev/null +++ b/scripts/verdict.py @@ -0,0 +1,45 @@ +"""共享结果语义:执行状态不等于代码结论,原始退出码始终保留。""" +from __future__ import annotations + +import re +from pathlib import Path + +PASS, FAIL, UNVERIFIED, SKIPPED, PLANNED = "PASS", "FAIL", "UNVERIFIED", "SKIPPED", "PLANNED" +_ENV_ERROR = re.compile( + r"Could not resolve dependencies|Could not find artifact|DependencyResolutionException|" + r"Cannot find module|ModuleNotFoundError|Unknown lifecycle phase|" + r"Could not resolve all|Could not find or load main class|invalid configuration", + re.IGNORECASE, +) + + +def lint_verdict(rc: int, command: list[str], output: str = "") -> tuple[str, str]: + """按工具契约归一结论;只允许明确的成功返回 PASS。""" + if rc == 0: + return PASS, "检查执行成功" + if rc in (124, 127) or rc < 0 or _ENV_ERROR.search(output): + reason = {124: "检查超时", 127: "工具不存在"}.get(rc, "工具链执行异常") + return UNVERIFIED, reason + tool = Path(command[0]).name if command else "" + # pylint 的 2 为 error 位;不得沿用 ESLint/ruff 的配置错误语义。 + if tool == "pylint": + return (UNVERIFIED, "pylint 用法错误") if rc & 32 else (FAIL, "pylint 发现违规") + if rc == 1 or (tool == "cargo" and rc == 101): + return FAIL, "检查发现违规" + return UNVERIFIED, f"工具退出 {rc},没有可确认的检查结论" + + +def result(language: str, status: str, reason: str, *, exit_code: int = 0, **details) -> dict: + """兼容 passed,同时携带不会在 CLI/MCP 间丢失的明确状态。""" + return {"language": language, "status": status, "reason": reason, + "passed": status == PASS, "exit_code": exit_code, **details} + + +def exit_status(results: list[dict]) -> int: + """聚合优先级:发现违规 > 无法验证 > 已完成/无须检查。""" + statuses = {r.get("status", PASS if r.get("passed") else FAIL) for r in results} + if FAIL in statuses: + return 2 + if not statuses or statuses & {UNVERIFIED, PLANNED}: + return 1 + return 0 diff --git a/tests/run_all.py b/tests/run_all.py index 562efe1..4d13974 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -227,7 +227,7 @@ def test_hooks(): ok("综述后跟细节(标题≠详情)", len(lines) > 3 and "具体问题" in r.stderr) ok("综述行未在细节中重复", lines.count(lines[0]) == 1) - # ── PreToolUse:干净仓 → 完全静默 ── + # ── PreToolUse:无 markdown 配置不能冒充检查通过(兼容 fail-open) ── clean = Path(tempfile.mkdtemp(prefix="cg-clean-")) git(clean, "init", "-q") git(clean, "config", "user.email", "t@t") @@ -236,10 +236,10 @@ def test_hooks(): git(clean, "add", "-A") r = run_hook("pre_tool_git_guard.py", {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, clean) - ok("干净仓 exit 0 且零输出(通过即静默)", r.returncode == 0 and r.stdout == "" and r.stderr == "", + ok("未接入 linter 的仓放行但明确未验证", r.returncode == 0 and "未验证" in r.stdout, f"exit={r.returncode} out={r.stdout[:40]!r}") r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, clean) - ok("干净仓提交意图注入通过确认", r.returncode == 0 and "✅" in r.stdout) + ok("未接入 linter 不注入通过确认", r.returncode == 0 and "未验证" in r.stdout and "✅" not in r.stdout) # ── PreToolUse:安全文件(.env/.venv 入库)→ 🛑 拦截 ── (repo / ".env").write_text("SECRET=1") @@ -723,7 +723,7 @@ def test_cve(): (d / "Cargo.lock").write_text("") orig_node = cve.ECOSYSTEM_SCANNERS["node"]["scan"] orig_rust = cve.ECOSYSTEM_SCANNERS["rust"]["scan"] - cve.ECOSYSTEM_SCANNERS["node"]["scan"] = lambda root, sev, fix: {"ecosystem": "node", "tool": "fake", "exit": 1, "failed": True} + cve.ECOSYSTEM_SCANNERS["node"]["scan"] = lambda root, sev, fix: {"ecosystem": "node", "tool": "fake", "exit": 1, "failed": True, "status": "FAIL"} cve.ECOSYSTEM_SCANNERS["rust"]["scan"] = lambda root, sev, fix: {"ecosystem": "rust", "tool": "fake", "exit": 127} try: sys.argv = ["cve_check.py", str(d)] diff --git a/tests/test_artifact_awareness.py b/tests/test_artifact_awareness.py index 9ee8fd0..89e6e4a 100644 --- a/tests/test_artifact_awareness.py +++ b/tests/test_artifact_awareness.py @@ -24,8 +24,8 @@ sys.path.insert(0, str(PLUGIN / "scripts")) sys.path.insert(0, str(PLUGIN / "hooks")) -import gate_lib # noqa: E402 -import scope # noqa: E402 +import gate_lib +import scope # 双副本/历史漂移中真实缺失过的产物目录(扫描面曾缺前 14 个,入库面曾缺 upstream) CRITICAL_ARTIFACTS = ( @@ -149,14 +149,15 @@ class PostToolUseArtifactSkipTests(unittest.TestCase): """3b) PostToolUse:写到 target/ 的生成物静默跳过(不跑 linter、零输出)。""" def _run_hook(self, file_path: str) -> subprocess.CompletedProcess: - import json as _json, os + import json as _json + import os env = {**os.environ, "CODEGUARD_HOME": tempfile.mkdtemp(prefix="cg-h-"), "PYTHONIOENCODING": "utf-8"} return subprocess.run( [sys.executable, str(PLUGIN / "hooks" / "post_tool_lint.py")], input=_json.dumps({"tool_name": "Write", "tool_input": {"file_path": file_path}}), - capture_output=True, text=True, cwd=PLUGIN, env=env, timeout=120, + capture_output=True, text=True, cwd=PLUGIN, env=env, timeout=120, check=False, ) def test_generated_html_under_target_silently_skipped(self) -> None: diff --git a/tests/test_full_scan_excludes.py b/tests/test_full_scan_excludes.py index 7ed0afe..9a0c877 100644 --- a/tests/test_full_scan_excludes.py +++ b/tests/test_full_scan_excludes.py @@ -22,9 +22,9 @@ sys.path.insert(0, str(PLUGIN / "scripts")) sys.path.insert(0, str(PLUGIN / "hooks")) -import gate_lib # noqa: E402 -import scope # noqa: E402 -import validate_languages_json as vlj # noqa: E402 +import gate_lib +import scope +import validate_languages_json as vlj REGISTRY = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) SHELL_GATE = next(lang for lang in REGISTRY["languages"] if lang["id"] == "shell")["gate"] @@ -78,7 +78,7 @@ class JavadocShScenarioTests(unittest.TestCase): def _gate_exit(self, root: Path) -> int: cmd = scope.scope_cmd(SHELL_GATE, str(root), full_excludes=True) - return subprocess.run(cmd, cwd=root, capture_output=True, timeout=60).returncode + return subprocess.run(cmd, cwd=root, capture_output=True, timeout=60, check=False).returncode def test_generated_javadoc_sh_under_target_passes(self) -> None: root = Path(tempfile.mkdtemp(prefix="cg-scan-")) diff --git a/tests/test_hardening_fixes.py b/tests/test_hardening_fixes.py index 4c61274..1d4488b 100644 --- a/tests/test_hardening_fixes.py +++ b/tests/test_hardening_fixes.py @@ -19,9 +19,9 @@ sys.path.insert(0, str(PLUGIN / "scripts")) sys.path.insert(0, str(PLUGIN / "hooks")) -import gate_lib # noqa: E402 -import pre_tool_git_guard as guard # noqa: E402 -import scope # noqa: E402 +import gate_lib +import pre_tool_git_guard as guard +import scope def _git(root: Path, *args: str) -> subprocess.CompletedProcess: @@ -43,7 +43,7 @@ def _run_hook(script: str, payload: dict | None, cwd: Path, return subprocess.run( [sys.executable, str(PLUGIN / "hooks" / script)], input=json.dumps(payload) if payload is not None else "", - capture_output=True, text=True, cwd=cwd, env=env, timeout=120, + capture_output=True, text=True, cwd=cwd, env=env, timeout=120, check=False, ) @@ -366,7 +366,7 @@ def _pushable_repo() -> Path: bare = Path(tempfile.mkdtemp(prefix="cg-bare-")) work = Path(tempfile.mkdtemp(prefix="cg-work-")) subprocess.run(["git", "init", "--bare", "-q", str(bare)], check=True) - subprocess.run(["git", "clone", "-q", str(bare), str(work)], capture_output=True) + subprocess.run(["git", "clone", "-q", str(bare), str(work)], capture_output=True, check=True) _git(work, "config", "user.email", "t@t") _git(work, "config", "user.name", "t") (work / "good.txt").write_text("ok\n", encoding="utf-8") @@ -374,7 +374,7 @@ def _pushable_repo() -> Path: _git(work, "commit", "-q", "-m", "init") r = subprocess.run( ["git", "push", "-q", "-u", "origin", "HEAD"], cwd=work, - capture_output=True, text=True, + capture_output=True, text=True, check=False, ) assert r.returncode == 0, r.stderr # 绕过门禁(模拟用户手动提交/装插件前的提交):坏 shell 文件入库 @@ -464,8 +464,8 @@ def test_rc_127_is_failure_in_cli(self) -> None: results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-127-"))) finally: rpl.subprocess.run = original - self.assertFalse(results[0]["passed"], "CLI 面工具缺失必须报失败") - self.assertNotIn("unverified", results[0]) + self.assertFalse(results[0]["passed"], "CLI 面工具缺失不能通过") + self.assertEqual(results[0]["status"], "UNVERIFIED") def test_rc_2_is_unverified_in_cli(self) -> None: import run_per_language as rpl @@ -475,8 +475,9 @@ def test_rc_2_is_unverified_in_cli(self) -> None: results = rpl.run_check(["python"], Path(tempfile.mkdtemp(prefix="cg-2-"))) finally: rpl.subprocess.run = original - self.assertTrue(results[0]["passed"]) - self.assertIn("exit 2", results[0].get("unverified", "")) + self.assertFalse(results[0]["passed"]) + self.assertEqual(results[0]["status"], "UNVERIFIED") + self.assertTrue(results[0].get("reason")) class FourHookDedupTests(unittest.TestCase): diff --git a/tests/test_java_project_impact.py b/tests/test_java_project_impact.py new file mode 100644 index 0000000..aefee6c --- /dev/null +++ b/tests/test_java_project_impact.py @@ -0,0 +1,157 @@ +"""Java 规划的可观察契约:不用执行构建即可给出模块影响及真实命令。""" +from __future__ import annotations + +import importlib +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) + + +class JavaImpactTests(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory(prefix="cg-java-") + self.addCleanup(tmp.cleanup) + self.root = Path(tmp.name).resolve() + + def put(self, name, body): + p = self.root / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + return p + + def pom(self, path=".", artifact="root", modules=(), dependencies=(), extra=""): + self.put(f"{path}/pom.xml", '' + '4.0.0example' + f'{artifact}1' + + ''.join(f'{m}' for m in modules) + '' + + ''.join('example' + f'{d}1' for d in dependencies) + + '' + extra + '') + + def analyze(self, changed=None): + self.assertTrue((PLUGIN / "scripts/java_project.py").is_file(), "缺少 Java 项目规划能力") + return importlib.import_module("java_project").analyze(self.root, changed) + + def fixture(self): + self.pom(modules=["api", "service", "app", "other"]) + self.pom("api", "api") + self.pom("service", "service", dependencies=["api"]) + self.pom("app", "app", dependencies=["service"]) + self.pom("other", "other") + + def test_maven_reverse_transitive_closure_and_wrapper(self): + self.fixture() + wrapper = self.put("mvnw", "#!/bin/sh\nexit 0\n") + wrapper.chmod(0o755) + plan = self.analyze(["api/src/main/java/Api.java"]) + self.assertEqual(plan["affected_modules"], ["api", "app", "service"]) + self.assertEqual(plan["commands"][0]["argv"], + ["./mvnw", "-B", "-pl", "api,app,service", "-am", "verify"]) + self.assertEqual(plan["status"], "PLANNED") + self.assertIn("静态规则", " ".join(plan["gaps"])) + + def test_deleted_java_file_still_affects_dependents(self): + self.fixture() + plan = self.analyze(["service/src/main/java/Deleted.java"]) + self.assertEqual(plan["affected_modules"], ["app", "service"]) + + def test_build_descriptor_change_expands_to_every_module(self): + self.fixture() + plan = self.analyze(["api/pom.xml"]) + self.assertEqual(plan["affected_modules"], [".", "api", "app", "other", "service"]) + self.assertNotIn("-pl", plan["commands"][0]["argv"]) + + def test_resource_change_affects_java_consumers(self): + self.fixture() + self.assertEqual(self.analyze(["api/src/main/resources/schema.json"])["affected_modules"], + ["api", "app", "service"]) + + def test_inherited_dependencies_require_conservative_build(self): + self.fixture() + self.pom(".", modules=["api", "service", "app", "other"], dependencies=["api"]) + self.assertTrue(self.analyze(["api/src/A.java"])["conservative"]) + + def test_no_changed_files_does_not_create_commands(self): + self.fixture() + plan = self.analyze([]) + self.assertEqual(plan["commands"], []) + self.assertEqual(plan["status"], "SKIPPED") + + def test_gradle_dependencies_use_gradle_not_maven(self): + self.put("settings.gradle.kts", 'include(":api", ":service", ":other")') + self.put("build.gradle.kts", 'plugins { java }') + self.put("api/build.gradle.kts", 'plugins { java }') + self.put("service/build.gradle.kts", 'dependencies { implementation(project(":api")) }') + self.put("other/build.gradle.kts", 'plugins { java }') + self.put("gradlew", "#!/bin/sh\nexit 0\n").chmod(0o755) + plan = self.analyze(["api/src/main/java/Api.java"]) + self.assertEqual(plan["build_system"], "gradle") + self.assertEqual(plan["affected_modules"], ["api", "service"]) + self.assertEqual(plan["commands"][0]["argv"], ["./gradlew", ":api:check", ":service:check"]) + + def test_dynamic_gradle_uses_full_root_check(self): + self.put("settings.gradle", 'include(moduleNames)') + self.put("build.gradle", 'apply from: "shared.gradle"') + plan = self.analyze(["api/src/main/java/A.java"]) + self.assertTrue(plan["conservative"]) + self.assertEqual(plan["commands"][0]["argv"], ["gradle", "check"]) + self.assertTrue(plan["reasons"]) + + def test_missing_or_malformed_build_is_unverified(self): + self.assertEqual(self.analyze()["status"], "UNVERIFIED") + self.put("pom.xml", "broken XML") + self.assertEqual(self.analyze()["status"], "UNVERIFIED") + + def test_module_path_cannot_escape_repository(self): + self.pom(modules=["../outside"]) + plan = self.analyze(["src/main/java/A.java"]) + self.assertEqual(plan["status"], "UNVERIFIED") + self.assertEqual(plan["commands"], []) + + def test_configured_authoritative_commands_take_precedence(self): + self.pom() + self.put("codeguard.json", json.dumps({"java": {"commands": [["./mvnw", "verify", "-Pquality"]]}})) + plan = self.analyze(["src/main/java/A.java"]) + self.assertEqual(plan["commands"][0]["argv"], ["./mvnw", "verify", "-Pquality"]) + + def test_planning_never_executes_wrapper_or_installs(self): + self.pom() + self.put("mvnw", "#!/bin/sh\ntouch SHOULD_NOT_EXIST\n").chmod(0o755) + plan = self.analyze() + self.assertTrue(plan["commands"]) + self.assertFalse((self.root / "SHOULD_NOT_EXIST").exists()) + + def test_check_executes_gradle_plan(self): + self.put("build.gradle", "plugins { id 'java' }") + self.put("gradlew", '#!/bin/sh\n[ "$1" = check ] || exit 4\nprintf "checked"\n').chmod(0o755) + import run_per_language + results = run_per_language.run_check(["java"], self.root) + self.assertTrue(results[0]["passed"], results) + self.assertEqual(results[0]["command"], ["./gradlew", "check"]) + + def test_java_plan_cli_returns_machine_readable_plan(self): + self.pom() + proc = subprocess.run(["bash", str(PLUGIN / "bin/codeguard"), "java-plan", "--json", str(self.root)], + capture_output=True, text=True, check=False, + env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"}) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertEqual(json.loads(proc.stdout)["build_system"], "maven") + + def test_gradle_kotlin_dsl_java_is_detected_by_cli(self): + self.put("build.gradle.kts", "plugins { java }") + self.put("gradlew", "#!/bin/sh\nexit 0\n").chmod(0o755) + proc = subprocess.run([sys.executable, str(PLUGIN / "scripts/run_check.py"), + "--lang", "java", "--quiet", str(self.root)], + capture_output=True, text=True, check=False) + self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index ea4a524..cf74528 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -68,8 +68,8 @@ def _read_until(proc: subprocess.Popen, want_id: int, timeout: float = 30.0) -> @unittest.skipUnless(MCP_AVAILABLE, "mcp SDK not installed (pip install -r requirements.txt)") class McpServerTests(unittest.TestCase): - def test_mcp_boot_lists_three_tools(self): - """3.2/6.3: server boots over stdio and exposes exactly the 3 tools.""" + def test_mcp_boot_lists_four_tools(self): + """stdio 服务暴露三个兼容工具和 Java 只读分析。""" proc = _spawn_mcp() try: _send(proc, { @@ -86,7 +86,17 @@ def test_mcp_boot_lists_three_tools(self): _send(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) listing = _read_until(proc, 2) names = {t["name"] for t in listing["result"]["tools"]} - self.assertEqual(names, {"check_code_style", "auto_fix", "list_languages"}) + self.assertEqual(names, {"check_code_style", "auto_fix", "list_languages", "analyze_java_impact"}) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "pom.xml").write_text("demo") + _send(proc, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "analyze_java_impact", "arguments": {"path": tmp}}}) + result = _read_until(proc, 3) + plan = json.loads(result["result"]["content"][0]["text"]) + self.assertEqual(plan["status"], "PLANNED") + self.assertEqual(plan["commands"][0]["argv"], ["mvn", "-B", "verify"]) + self.assertFalse((root / "target").exists()) finally: try: if proc.stdin: @@ -94,6 +104,9 @@ def test_mcp_boot_lists_three_tools(self): proc.wait(timeout=10) except Exception: # noqa: BLE001 cleanup proc.kill() + proc.wait(timeout=10) + proc.stdout.close() + proc.stderr.close() def test_mcp_list_languages_returns_id_and_name_only(self): """3.5/6.3: tool result enumerates id+name, never internal commands.""" @@ -127,6 +140,9 @@ def test_mcp_list_languages_returns_id_and_name_only(self): proc.wait(timeout=10) except Exception: # noqa: BLE001 cleanup proc.kill() + proc.wait(timeout=10) + proc.stdout.close() + proc.stderr.close() class FailureLogTests(unittest.TestCase): diff --git a/tests/test_session_fixes_20260922.py b/tests/test_session_fixes_20260922.py index 45d90a5..9825b13 100644 --- a/tests/test_session_fixes_20260922.py +++ b/tests/test_session_fixes_20260922.py @@ -25,10 +25,10 @@ sys.path.insert(0, str(PLUGIN / "scripts")) sys.path.insert(0, str(PLUGIN / "hooks")) -import gate_lib # noqa: E402 -import pre_tool_git_guard as guard # noqa: E402 -import scope # noqa: E402 -from env_check import version_backlog_note # noqa: E402 +import gate_lib +import pre_tool_git_guard as guard +import scope +from env_check import version_backlog_note def _git(root: Path, *args: str) -> subprocess.CompletedProcess: @@ -49,7 +49,7 @@ def _run_guard(command: str, cwd: Path) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, str(PLUGIN / "hooks" / "pre_tool_git_guard.py")], input=json.dumps({"tool_name": "Bash", "tool_input": {"command": command}}), - capture_output=True, text=True, cwd=cwd, env=env, timeout=120, + capture_output=True, text=True, cwd=cwd, env=env, timeout=120, check=False, ) @@ -272,7 +272,7 @@ def test_format_failure_report_contract_unchanged(self) -> None: class StaleAttributionTests(unittest.TestCase): - """#4:delta 下报错文件全在改动集外 → skipped;有交集/无法归因 → failure。""" + """没有独立基线证据时,不根据诊断位置把未修改调用方错误当成历史债。""" def setUp(self) -> None: self.repo = _fresh_repo() @@ -282,15 +282,12 @@ def setUp(self) -> None: def test_bare_filename_outside_changeset_is_stale(self) -> None: out = "old.py:1:1: E501 line too long\nold.py:2:2: E501 again" note = gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) - self.assertIsNotNone(note) - self.assertIn("存量文件", note) - self.assertIn("old.py", note) + self.assertIsNone(note) def test_absolute_path_is_normalized_and_counted(self) -> None: out = f"{self.repo}/old.py:1:1: error" note = gate_lib._stale_attribution(out, self.repo, "python", ["new.py"]) - self.assertIsNotNone(note) - self.assertIn("old.py", note) + self.assertIsNone(note) def test_intersection_keeps_failure(self) -> None: out = "new.py:1:1: E501" diff --git a/tests/test_verdict_integrity.py b/tests/test_verdict_integrity.py new file mode 100644 index 0000000..233248d --- /dev/null +++ b/tests/test_verdict_integrity.py @@ -0,0 +1,341 @@ +"""判定可信度:检查真实内容和外部进程结果,禁止用假绿掩盖未验证。""" +from __future__ import annotations + +import contextlib +import inspect +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN / "scripts"), str(PLUGIN / "hooks")] +import cve_check +import gate_lib +import post_tool_lint +import pre_tool_git_guard +import run_check +import run_per_language +from detect_lang import LANG_COMMANDS + + +class RepoCase(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix="cg-verdict-") + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name).resolve() + self.git("init", "-q") + self.git("config", "user.name", "fixture") + self.git("config", "user.email", "fixture@example.invalid") + self.git("config", "core.hooksPath", "/dev/null") + + def git(self, *args): + return subprocess.run(["git", *args], cwd=self.root, text=True, + capture_output=True, check=True).stdout + + def put(self, name, content): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + def checker(self): + # 原生子进程只读文件,不依赖开发者是否安装 shellcheck。 + return {"lint": [sys.executable, "-c", + ("import pathlib,sys; p=pathlib.Path(sys.argv[1]); " + "bad='BAD' in p.read_text(); " + "print(str(p)+':1:1: bad' if bad else ''); sys.exit(int(bad))"), + "{file}"]} + + +class VerdictTests(RepoCase): + def test_second_file_failure_is_not_hidden_by_first_pass(self): + self.put("a.sh", "GOOD") + self.put("b.sh", "BAD") + with patch.dict(LANG_COMMANDS, {"shell": self.checker()}): + failures, _ = gate_lib._run_gate_uncached( + self.root, {}, ["shell"], scope="delta", changed=["a.sh", "b.sh"]) + self.assertEqual(len(failures), 1) + self.assertIn("b.sh", failures[0][1]) + + def test_tool_config_error_is_not_pass_and_survives_mcp(self): + with patch.dict(LANG_COMMANDS, {"python": { + "lint": [sys.executable, "-c", "import sys; sys.exit(2)"]}}): + results = run_per_language.run_check(["python"], self.root) + self.assertFalse(results[0]["passed"]) + self.assertEqual(results[0].get("status"), "UNVERIFIED") + envelope = run_check._mcp_envelope(results)[0] + self.assertEqual(envelope.get("status"), "UNVERIFIED") + self.assertTrue(envelope.get("reason")) + + def test_cli_honors_linter_configuration_prerequisite(self): + commands = {"lint": [sys.executable, "-c", "pass"], "requiresConfig": [".fixture-config"]} + with patch.dict(LANG_COMMANDS, {"python": commands}): + result = run_per_language.run_check(["python"], self.root)[0] + self.assertEqual(result["status"], "UNVERIFIED") + + def test_missing_tool_cli_is_nonzero_without_all_passed(self): + self.put("sample.py", "x=1\n") + with patch.dict(LANG_COMMANDS, {"python": {"lint": ["codeguard-no-such-linter"]}}), \ + patch.object(sys, "argv", ["run_check.py", "--quiet", "--lang", "python", str(self.root)]): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = run_check.cli_main() + self.assertEqual(rc, 1) + self.assertNotIn("all passed", out.getvalue()) + + def test_project_command_flag_survives_registry_loading(self): + self.assertIs(LANG_COMMANDS["java"].get("append_files"), False) + + def test_repo_check_uses_project_gate_not_empty_file_placeholder(self): + command = {"lint": [sys.executable, "-c", "import sys;sys.exit(1)", "{file}"], + "gate": [sys.executable, "-c", "print('all files checked')"]} + with patch.dict(LANG_COMMANDS, {"shell": command}): + results = run_per_language.run_check(["shell"], self.root) + self.assertTrue(results[0]["passed"]) + + def test_partial_fix_does_not_run_project_formatter(self): + marker = self.root / "formatter-ran" + cmd = {"append_files": False, "format": [sys.executable, "-c", + f"from pathlib import Path;Path({str(marker)!r}).touch()"]} + with patch.dict(LANG_COMMANDS, {"java": cmd}): + results = run_per_language.run_fix(["java"], self.root, files=["src/A.java"]) + self.assertFalse(marker.exists()) + self.assertFalse(results[0]["fixed"]) + + def test_scoped_check_runs_each_placeholder_file(self): + self.put("a.sh", "GOOD") + self.put("b.sh", "BAD") + with patch.dict(LANG_COMMANDS, {"shell": self.checker()}): + result = run_per_language.run_check(["shell"], self.root, files=["a.sh", "b.sh"])[0] + self.assertEqual(result["status"], "FAIL") + self.assertIn("b.sh", result["stdout_tail"]) + + def test_mcp_auto_fix_never_touches_clean_files(self): + self.put("clean.py", "import os\n") + self.git("add", ".") + self.git("commit", "-qm", "base") + self.put("changed.py", "import sys\n") + self.assertTrue(hasattr(run_check, "_auto_fix")) + payload = run_check._auto_fix(self.root, ["python"]) + self.assertEqual((self.root / "clean.py").read_text(), "import os\n") + self.assertTrue(payload["fixed"], payload) + self.assertTrue(payload["check"][0]["passed"], payload) + + def test_scoped_fix_does_not_follow_symlink_to_clean_file(self): + self.put("clean.py", "import os\n") + (self.root / "link.py").symlink_to(self.root / "clean.py") + outcome = run_per_language.run_fix(["python"], self.root, files=["link.py"])[0] + self.assertEqual((self.root / "clean.py").read_text(), "import os\n") + self.assertEqual(outcome.get("status"), "UNVERIFIED") + + def test_no_baseline_means_unchanged_caller_is_not_ignored(self): + self.put("consumer.py", "removed_api()") + self.assertIsNone(gate_lib._stale_attribution( + "consumer.py:1:1: undefined name", self.root, "python", ["api.py"])) + + def test_post_tool_error_does_not_run_formatter(self): + file = self.put("sample.py", "x=1\n") + marker = self.root / "formatter-ran" + commands = {"lint": [sys.executable, "-c", "import sys; sys.exit(2)"], + "format": [sys.executable, "-c", + f"from pathlib import Path; Path({str(marker)!r}).touch()"]} + with patch.dict(LANG_COMMANDS, {"python": commands}), \ + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(file)}), \ + patch.object(post_tool_lint, "find_project_root", return_value=self.root), \ + patch.object(post_tool_lint, "should_suppress_duplicate", return_value=False), \ + patch.object(post_tool_lint, "mac_notify"), \ + patch.dict(os.environ, {"CODEGUARD_HOME": str(self.root / "state")}), \ + contextlib.redirect_stdout(io.StringIO()): + post_tool_lint.main() + self.assertFalse(marker.exists()) + + def test_post_tool_recheck_error_is_json_unverified_not_failure(self): + file = self.put("sample.py", "x=1\n") + out = io.StringIO() + commands = {"lint": ["fixture-check"], "format": ["fixture-format"]} + with patch.dict(LANG_COMMANDS, {"python": commands}), \ + patch.object(post_tool_lint, "read_payload", return_value={"file_path": str(file)}), \ + patch.object(post_tool_lint, "find_project_root", return_value=self.root), \ + patch.object(post_tool_lint, "should_suppress_duplicate", return_value=False), \ + patch.object(post_tool_lint, "load_user_config", return_value={"auto_fix_on_save": True}), \ + patch.object(post_tool_lint, "run", side_effect=[(1, "F401", ""), (0, "", ""), (2, "", "config invalid")]), \ + patch.object(post_tool_lint, "bump_state") as bump, \ + patch.object(post_tool_lint, "mac_notify"), contextlib.redirect_stdout(out): + post_tool_lint.main() + payload = json.loads(out.getvalue()) + self.assertIn("UNVERIFIED", payload["hookSpecificOutput"]["additionalContext"]) + bump.assert_not_called() + + +class ContentTests(RepoCase): + def test_add_update_does_not_include_untracked_files(self): + lanes, _ = pre_tool_git_guard.staging_intent("git add -u && git commit -m t") + self.assertNotIn("untracked", lanes) + + def test_scoped_add_all_keeps_directory_scope(self): + with patch.object(Path, "cwd", return_value=self.root): + lanes, extra = pre_tool_git_guard.staging_intent("git add -A src && git commit -m t") + self.assertNotIn("untracked", lanes) + self.assertIn("src", extra) + + def exact_gate(self, mode="commit", lanes=("staged",), extra=None): + self.assertIn("exact", inspect.signature(gate_lib.run_gate).parameters, + "硬门禁必须能选择准确内容快照") + with patch.dict(LANG_COMMANDS, {"shell": self.checker()}): + return gate_lib.run_gate(self.root, {}, ["shell"], mode=mode, + lanes=lanes, extra=extra, exact=True) + + def test_staged_bad_worktree_clean_checks_index_and_preserves_both(self): + self.put("a.sh", "BAD") + self.git("add", "a.sh") + self.put("a.sh", "GOOD") + before = self.git("diff", "--cached") + failures, _ = self.exact_gate() + self.assertTrue(failures) + self.assertEqual(self.git("diff", "--cached"), before) + self.assertEqual((self.root / "a.sh").read_text(), "GOOD") + + def test_predicted_add_uses_worktree_and_expands_directory(self): + self.put("scripts/a.sh", "GOOD") + self.git("add", ".") + self.put("scripts/a.sh", "BAD") + failures, _ = self.exact_gate(extra=["scripts"]) + self.assertTrue(failures) + self.assertEqual(self.git("show", ":scripts/a.sh"), "GOOD") + + def test_push_uses_head_not_unstaged_repair(self): + self.put("a.sh", "GOOD") + self.git("add", ".") + self.git("commit", "-qm", "base") + self.git("update-ref", "refs/remotes/origin/main", "HEAD") + self.put("a.sh", "BAD") + self.git("add", ".") + self.git("commit", "-qm", "bad") + self.put("a.sh", "GOOD") + failures, _ = self.exact_gate(mode="push") + self.assertTrue(failures) + + def test_safety_sees_predicted_add_and_allows_removal(self): + self.put(".env", "FAKE_DIAGNOSTIC=not-a-secret\n") + self.assertIn("extra", inspect.signature(gate_lib.check_commit_safety).parameters) + violations = gate_lib.check_commit_safety(self.root, "commit", extra=[".env"]) + self.assertEqual(violations[0][0], ".env") + self.git("add", ".env") + self.git("commit", "-qm", "fixture") + self.git("rm", ".env") + self.assertEqual(gate_lib.check_commit_safety(self.root, "commit"), []) + + def test_deleted_file_is_retained_for_impact_but_not_safety(self): + from git_snapshot import validation_tree + self.put("src/A.java", "class A {}") + self.git("add", ".") + self.git("commit", "-qm", "base") + self.git("rm", "src/A.java") + with validation_tree(self.root) as (snapshot, changed): + self.assertIn("src/A.java", changed) + self.assertFalse((snapshot / "src/A.java").exists()) + + +class CveEvidenceTests(RepoCase): + def test_bad_severity_is_usage_not_vulnerability(self): + with patch.object(sys, "argv", ["cve_check.py", "--severity", "invalid"]), \ + contextlib.redirect_stderr(io.StringIO()), patch.object(cve_check, "run") as runner: + try: + code = cve_check.main() + except SystemExit as exc: + code = exc.code + self.assertEqual(code, 3) + runner.assert_not_called() + + def invoke(self, rc, stdout, stderr, severity="HIGH"): + self.put("package.json", "{}") + with patch.object(cve_check, "run", return_value=(rc, stdout, stderr)), \ + patch.object(sys, "argv", ["cve_check.py", "--ecosystem", "node", + "--severity", severity, str(self.root)]): + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cve_check.main() + return code, out.getvalue() + + def test_timeout_is_unverified_not_a_vulnerability(self): + rc, out = self.invoke(124, "", "network timeout") + self.assertEqual(rc, 1) + self.assertNotIn("有漏洞需修复", out) + + def test_low_only_does_not_fail_high_threshold(self): + rc, _ = self.invoke(1, json.dumps({"metadata": {"vulnerabilities": { + "low": 1, "moderate": 0, "high": 0, "critical": 0}}}), "") + self.assertEqual(rc, 0) + + def test_malformed_zero_exit_is_not_pass(self): + rc, _ = self.invoke(0, "not JSON", "") + self.assertEqual(rc, 1) + + def test_moderate_is_medium_threshold(self): + rc, _ = self.invoke(1, json.dumps({"metadata": {"vulnerabilities": { + "low": 0, "moderate": 1, "high": 0, "critical": 0}}}), "", "MEDIUM") + self.assertEqual(rc, 2) + + def test_json_output_is_one_machine_readable_document(self): + self.put("package.json", "{}") + report = json.dumps({"metadata": {"vulnerabilities": {"high": 0}}}) + with patch.object(cve_check, "run", return_value=(0, report, "")), \ + patch.object(sys, "argv", ["cve_check.py", "--json", "--ecosystem", "node", str(self.root)]): + out = io.StringIO() + with contextlib.redirect_stdout(out): + rc = cve_check.main() + self.assertEqual(rc, 0) + try: + data = json.loads(out.getvalue()) + except ValueError: + self.fail("--json 输出混入普通日志") + self.assertEqual(data["status"], "PASS") + + def test_maven_json_evidence_distinguishes_findings_and_network_errors(self): + def fake(cmd, **kwargs): + self.assertIn("org.owasp:dependency-check-maven:aggregate", cmd) + output = Path(next(a.split("=", 1)[1] for a in cmd if a.startswith("-Dodc.outputDirectory="))) + (output / "dependency-check-report.json").write_text(json.dumps({"dependencies": [ + {"vulnerabilities": [{"name": "CVE-2026-0000", "cvssv3": {"baseScore": 9.1}}]}]})) + return 1, "threshold exceeded", "" + # 只有具备结构化输出配置后才启动 fake,缺特性作为断言失败。 + with patch.object(cve_check, "run", return_value=(1, "network timeout", "")): + unavailable = cve_check.scan_maven(self.root, 7) + self.assertEqual(unavailable.get("status"), "UNVERIFIED") + with patch.object(cve_check, "run", side_effect=fake): + found = cve_check.scan_maven(self.root, 7) + self.assertEqual(found["status"], "FAIL") + + def test_pip_report_is_scoped_to_project_requirements(self): + self.put("requirements.txt", "example==1\n") + report = json.dumps({"dependencies": [{"name": "example", "version": "1", "vulns": [{"id": "TEST-0001"}]}]}) + seen = [] + def scan(cmd, **kwargs): + seen.append(cmd) + return 1, report, "" + with patch.object(cve_check, "run", side_effect=scan): + result = cve_check.scan_pip(self.root) + self.assertEqual(result.get("status"), "FAIL") + self.assertIn("--requirement", seen[0]) + + def test_cargo_json_failure_without_advisories_is_unverified(self): + with patch.object(cve_check, "run", side_effect=[(0, "cargo-audit", ""), (1, "", "network error")]): + result = cve_check.scan_cargo(self.root) + self.assertEqual(result.get("status"), "UNVERIFIED") + + def test_cargo_json_advisory_is_not_lost(self): + report = json.dumps({"vulnerabilities": {"found": True, "count": 1, + "list": [{"advisory": {"id": "RUSTSEC-TEST-0001"}}]}}) + with patch.object(cve_check, "run", side_effect=[(0, "cargo-audit", ""), (1, report, "")]): + result = cve_check.scan_cargo(self.root) + self.assertEqual(result.get("status"), "FAIL") + + +if __name__ == "__main__": + unittest.main()