diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 1e30f62..54f31d6 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.14.15" + "ref": "v0.15.0" }, "policy": { "installation": "AVAILABLE", "authentication": "ON_USE" }, "category": "Developer Tools", - "version": "0.14.15", + "version": "0.15.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.14.15/assets/official-logo.png", + "icon": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.0/assets/official-logo.png", "interface": { "displayName": "代码规范守卫", "shortDescription": "Trustworthy code checks and Java impact analysis", - "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.14.15/assets/official-logo.png" + "logo": "https://cdn.jsdelivr.net/gh/full-stack-plugins/codeguard-plugin@v0.15.0/assets/official-logo.png" } } ] diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d633737..5d29744 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.15+codex.20260923", + "version": "0.15.0+codex.20260923", "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", diff --git a/.zcode-plugin/plugin.json b/.zcode-plugin/plugin.json index a14c4b6..94d186d 100644 --- a/.zcode-plugin/plugin.json +++ b/.zcode-plugin/plugin.json @@ -5,7 +5,7 @@ "en": "CodeGuard", "zh-CN": "代码规范检查" }, - "version": "0.14.15", + "version": "0.15.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": "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.", diff --git a/AGENTS.md b/AGENTS.md index 042d0b1..32fb4b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,9 @@ git add -A && git commit -m "release: <插件id> <版本>" && git push ### 硬性禁令 +- **`.` 开头的目录与文件默认忽略**(检查/保存/发现/全量扫描面):不扫描、不检查、不报告; + 两个例外照常生效——入库安全检查照拦密钥模式(`.env`/`*.pem` 等),linter 配置发现 + (`requiresConfig`/`linter_config_files`)照常匹配点文件 - 禁止改代码不 bump 版本(「小版本也要发」) - 禁止手改 catalog.json 的 version 以外的生成产物、或手改三份市场清单—— 它们只能由 `scripts/bump-plugin.mjs` 与 `plugins/scripts/sync-marketplaces.mjs` 生成 diff --git a/docs/current-architecture.md b/docs/current-architecture.md index f5a1581..8d1e8eb 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -26,6 +26,7 @@ flowchart TD Entry -. 会话与去重 .-> State[storage / hook_state / cache] ``` +检查作用域默认忽略项目根下任一以 `.` 开头的目录与文件(PostToolUse 保存面、delta 门禁面、语言发现面与全量扫描的 ruff/`find` 型通道;项目根本身位于点前缀父目录下不构成命中);入库安全检查与 linter 配置发现不受此影响——密钥模式照拦,点前缀配置文件照常判定接入。 `execution` 只记录进程证据,不把非零退出自动认定为代码违规;stdout/stderr 合计按默认 16 MiB 捕获预算并发读取,超限终止执行、保留预算内诊断且返回 `output_limit`/UNVERIFIED,不把片段当成完整日志。`verdict`、CVE 与 Dockerfile 报告解析决定 PASS、FAIL 或 UNVERIFIED;入口仅按各自协议呈现和聚合退出码。Git 提交检查使用 index 或预测暂存快照,推送检查使用 HEAD;保存钩子只给反馈。计划、未执行和未验证不能宣传为通过。 Git 准确快照复用统一执行器的原始字节模式:路径列表默认最多捕获 16 MiB,`cat-file --batch-check` 按对象数分配响应预算;通过大小响应确认对象总量不超过 256 MiB 后,`--batch` 才按总 blob 大小和逐项头部预算读取。stdout/stderr 合计超限时及时终止 Git,抛出 `SnapshotError`,不交付截断内容。预测暂存的工作树覆盖层只列举一次,并以同一集合决定 `changed` 和物化内容;最多 20,000 项、单文件 32 MiB、实际复制总量 256 MiB,以 64 KiB 块流式读取。打开时使用非阻塞与不跟随末级链接的可用平台标志,避免类型检查后变成 FIFO 时卡住。特殊文件、读取期间变化和超限不交付临时树,文件↔目录转换按父路径先处理。随后严格解析 index/HEAD 对象列表的 NUL 帧、模式、ID、类型/阶段和唯一文件名,并与独立路径列举核对;`cat-file` 两阶段响应再逐项核对 ID、blob 类型、大小与边界,按 SHA-1/SHA-256 blob 对象格式复算内容哈希。缺项、截断、尾部脏数据或同长度内容替换均不交付临时树,而是明确标记 Git UNVERIFIED。原始 index 不在观察过程中改写。该校验不是恶意 Git 进程沙箱或完整 Git/Shell 模拟。 `language_check` 将 `PlanExecution` 的实际命令依序映射为应用层有界 `execution_trace`(检查、修复、复检阶段);`check_application` 将其进一步压成 MCP 安全元数据,不回显原始 argv、环境覆盖或捕获输出。MCP `auto_fix` 以 `fingerprint.file_content` 对仓内改动文件在修复前、formatter 后、复检后分别采集有总量预算的流式身份,仓外路径、符号链接或读取故障不启动 formatter;顶层 `fixed` 只归因于 formatter 且复检后仍保留的变化。公开 `fix_results` 将命令成功 `formatter_succeeded` 与逐项修复确认 `fixed` 分离;只有唯一成功执行的 formatter 且内容变化可验证时才把全局变化归给该项,多 formatter 执行不猜测归属。后续身份不可采集、formatter 失败后仍有内容变化或检查器改写目标文件时,保留实际执行证据、整体标为 UNVERIFIED,不把未知副作用声称为已确认修复。`fix_results` 只公开状态与安全元数据;formatter stderr 尾部写入私有 `.codeguard-fix.log` 后只公开路径,日志不可用时不退回公开原文。失败日志保留各已执行检查在捕获预算内的输出;输出超限时日志只含片段,不能称为完整诊断,且可能含敏感文本,应排除出版本控制。项目日志和门禁截断日志共用 `storage.write_private_text` 的私有原子落盘;预置日志文件链接不被跟随,默认 `out` 目录为链接或不可写时不返回虚假日志路径,也不覆盖检查结论。终止命令仍决定既有状态、退出码和旧字段;未运行的计划命令不进入证据。 diff --git a/kimi.plugin.json b/kimi.plugin.json index 6703e88..154dd65 100644 --- a/kimi.plugin.json +++ b/kimi.plugin.json @@ -1,6 +1,6 @@ { "name": "codeguard", - "version": "0.14.15", + "version": "0.15.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" diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/proposal.md b/openspec/changes/2026-09-23-dot-prefix-default-skip/proposal.md new file mode 100644 index 0000000..de7ddf7 --- /dev/null +++ b/openspec/changes/2026-09-23-dot-prefix-default-skip/proposal.md @@ -0,0 +1,32 @@ +# 2026-09-23-dot-prefix-default-skip + +## Why + +用户指令(2026-09-23):codeguard 要默认忽略 `.` 开头的目录和文件,且这套硬性约束必须**既在代码中也在提示词中**。 + +实测背景:插件仓发版时 `.agents/plugins/marketplace.json` 与 `.codex-plugin/plugin.json` 被提交安全检查判成「不应入库」阻断发版(入库面已由 `0f284ee` 修复);但**检查/保存/发现/全量扫描**四类面仍会把点前缀路径当检查对象——宿主工具目录(`.cursor/`、`.claude/`、`.mimosa/` 快照等新点目录不在 `FULL_SCAN_EXCLUDES` 枚举内)、点前缀配置文件(`.eslintrc.js`)会被 lint 报与仓库内容无关的问题,且枚举永远追不上新宿主目录。 + +## What Changes + +- 单一谓词 `path_policy.is_dot_prefixed(path, root)`:相对项目根任一路径段 `.` 开头(`.`/`..` 段除外)即点前缀。 +- 检查面四通道接入默认忽略:PostToolUse 保存面、提交门禁 delta 面(`changed_files`)、全量扫描的 ruff 与 `find` 型 gate 注入。 +- 语言发现面(`detect_languages`)不再计入点前缀文件。 +- **两个例外面不受影响**:入库安全检查照拦密钥模式(`.env`/`*.pem`/`.DS_Store`),linter 配置发现(`requiresConfig`/`linter_config_files`)照常匹配点文件。 +- 提示词面声明约束:SessionStart「codeguard 项目记忆」上下文与 `AGENTS.md` 硬性禁令写明该规则,并以测试锚定。 + +## Capabilities + +### New Capabilities + +- `scan-scope-policy`:检查作用域的点前缀默认忽略规则、例外面边界与提示词声明要求。 + +### Modified Capabilities + +无。既有 `gate-trigger-policy`、`verdict-integrity` 等行为契约不变。 + +## Impact + +- `scripts/codeguard/path_policy.py`(新谓词)、`scripts/scope.py`(delta 过滤 + ruff/find 注入)、`scripts/codeguard/save_application.py`、`scripts/codeguard/discovery.py`、`scripts/codeguard/startup_application.py`(提示词)。 +- `AGENTS.md`、`docs/current-architecture.md`(提示词/文档面)。 +- 行为变更:检查面默认忽略点前缀路径——此前会被检查的 `.eslintrc.js`、`.github/` 下文件等不再进入检查面;入库安全与配置发现行为不变。`0f284ee` 的入库面行为一并纳入规格固化。 +- 不改动受管技能内容、不改 `FULL_SCAN_EXCLUDES` 枚举本体。 diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md b/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md new file mode 100644 index 0000000..aa1a354 --- /dev/null +++ b/openspec/changes/2026-09-23-dot-prefix-default-skip/specs/scan-scope-policy/spec.md @@ -0,0 +1,73 @@ +# scan-scope-policy:检查作用域与点前缀默认忽略 + +## Purpose + +定义 codeguard 检查作用域的点前缀默认忽略规则:哪些路径面默认不检查、哪些例外面必须照常生效,以及该约束在提示词面的声明要求,使「宿主工具目录与配置文件不再产生与仓库内容无关的结论」成为可测试契约。 + +## ADDED Requirements + +### Requirement: Check and save faces MUST skip dot-prefixed paths by default + +相对项目根,任一路径段以 `.` 开头(`.`、`..` 段除外)的目录与文件为点前缀路径,默认忽略:不扫描、不检查、不报告。适用于 PostToolUse 保存面、提交门禁 delta 面、语言发现面,以及全量扫描的 ruff 与 `find` 型 gate 通道。项目根本身位于点前缀父目录下(如 `~/.config/proj/`)不构成点前缀命中。 + +#### Scenario: PostToolUse skips dot-prefixed files + +- **WHEN** AI 保存 `.cursor/rules.py` 或 `.eslintrc.js` +- **THEN** 保存面静默跳过,不触发 lint、不产生告警 + +#### Scenario: Delta gate face excludes dot-prefixed paths + +- **WHEN** 本次提交同时改动 `.github/workflows/ci.yml` 与 `src/main.py` +- **THEN** 门禁检查面只含 `src/main.py` + +#### Scenario: Language discovery ignores dot-prefixed files + +- **WHEN** 项目根仅有 `.eslintrc.js` 与 `main.py` +- **THEN** 发现面只计入 python,不因 `.eslintrc.js` 计入 javascript + +#### Scenario: Full-scan channels exclude dot-prefixed subtrees + +- **WHEN** 全量扫描执行 ruff 或 `find` 型 gate +- **THEN** ruff 命令带点前缀排除参数,`find` 表达式注入 `-not -path '*/.*'`,`.agents/` 等子树不产生结论 + +#### Scenario: Project under a dot-prefixed parent is not skipped + +- **WHEN** 项目根为 `~/.config/proj/` 且检查 `main.py` +- **THEN** 不因父目录点前缀跳过,正常检查 + +### Requirement: Commit safety face MUST NOT be weakened by dot-prefix skipping + +点前缀默认忽略只作用于检查面。入库安全检查独立收集拟入库路径:密钥/凭据类文件模式(`.env`、`*.pem`、`.DS_Store` 等)无论点前缀与否照常拦截;点前缀目录(宿主插件清单与第一方配置)照常可入库。 + +#### Scenario: Secret files remain blocked + +- **WHEN** 拟提交 `.env` 或 `id_rsa` +- **THEN** 入库安全检查照常给出违规与修复指令 + +#### Scenario: Host manifest dirs remain committable + +- **WHEN** 拟提交 `.agents/plugins/marketplace.json` +- **THEN** 不因目录点前缀被判「不应入库」 + +### Requirement: Config discovery MUST keep matching dot-prefixed config files + +linter 配置发现(`requiresConfig`、`linter_config_files` 项目级匹配)不受点前缀忽略影响:点前缀配置文件照常使语言判定为已接入。 + +#### Scenario: requiresConfig matches dot files + +- **WHEN** 项目根存在 `.markdownlint-cli2.jsonc` 且 markdown 声明 `requiresConfig` 含该文件名 +- **THEN** markdown 判定为已接入,进入正常检查流程 + +### Requirement: Prompt surfaces MUST state the constraint + +点前缀默认忽略规则 MUST 出现在提示词面:SessionStart「codeguard 项目记忆」上下文与 `AGENTS.md` 硬性禁令,且须同时声明两个例外面(入库安全照拦、配置发现照常),并有测试锚定提示词包含该声明。 + +#### Scenario: SessionStart context states the rule + +- **WHEN** SessionStart 生成项目记忆文本 +- **THEN** 文本含默认忽略声明及例外说明 + +#### Scenario: AGENTS.md hard rules state the rule + +- **WHEN** 读取 `AGENTS.md` 硬性禁令 +- **THEN** 含点前缀默认忽略条目 diff --git a/openspec/changes/2026-09-23-dot-prefix-default-skip/tasks.md b/openspec/changes/2026-09-23-dot-prefix-default-skip/tasks.md new file mode 100644 index 0000000..066f963 --- /dev/null +++ b/openspec/changes/2026-09-23-dot-prefix-default-skip/tasks.md @@ -0,0 +1,34 @@ +# Tasks: 2026-09-23-dot-prefix-default-skip + +## 1. 单一谓词 + +- [x] 1.1 `path_policy.is_dot_prefixed(path, root)`:相对 root 判段,`.`/`..` 段豁免;root 外或无法相对化时返回 False(多检查不漏检查) +- [x] 1.2 路径分隔符统一 `/`(沿用 `is_build_artifact` 的 Windows 兼容写法,不用 `lstrip('./')`) + +## 2. 检查面四通道接入 + +- [x] 2.1 `scope.changed_files` 返回前过滤点前缀路径(delta 门禁面 + push 面同时生效) +- [x] 2.2 `scope._inject_find_excludes` 注入 `-not -path '*/.*'`(幂等,OR 组括号前提不变) +- [x] 2.3 `scope.scope_cmd` ruff 分支 full_excludes 时追加 `--exclude '.*'` 与 `--exclude '**/.*'` +- [x] 2.4 `save_application.should_skip` 点前缀文件静默跳过(root 由 `find_project_root` 兜底推导) + +## 3. 发现面 + +- [x] 3.1 `discovery.detect_languages` 的 include 不计入点前缀文件 + +## 4. 例外面回归锚定(不许被忽略吞掉) + +- [x] 4.1 入库安全:`.env`/`*.pem` 照拦;`.agents/`/`.codex-plugin/` 等点目录照常可入库(固化 `0f284ee`) +- [x] 4.2 配置发现:`requiresConfig` 匹配 `.markdownlint-cli2.jsonc` 等点文件仍判已接入 + +## 5. 提示词面 + +- [x] 5.1 `startup_application` 项目记忆文本声明默认忽略规则与两个例外 +- [x] 5.2 `AGENTS.md` 硬性禁令补该条 +- [x] 5.3 `docs/current-architecture.md` 行为段落补一句 + +## 6. 测试与发版 + +- [x] 6.1 `tests/test_dot_prefix_default_skip.py` 锚定 1–5 全部场景(含「项目根本身在点目录下不误伤」陷阱) +- [x] 6.2 全量回归(run_all + unittest discover)通过 +- [x] 6.3 按仓规 bump minor、市场仓同步、PR/CI 合并、两仓推送 diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 6305aff..0de8f9d 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -52,7 +52,7 @@ "codeguard.registry_schema": {"__future__", "re"}, "codeguard.registry": {"__future__", "json", "pathlib", "typing", "codeguard.registry_schema"}, "codeguard.config": {"__future__", "json", "re", "pathlib", "codeguard.registry"}, - "codeguard.discovery": {"__future__", "fnmatch", "re", "pathlib", "codeguard.config", "codeguard.registry"}, + "codeguard.discovery": {"__future__", "fnmatch", "re", "pathlib", "codeguard.config", "codeguard.path_policy", "codeguard.registry"}, "codeguard.toolchain": {"__future__", "pathlib", "threading", "codeguard.execution"}, "codeguard.language_check": {"__future__", "pathlib", "codeguard.config", "codeguard.discovery", "codeguard.execution", "codeguard.models", "codeguard.planning", diff --git a/scripts/codeguard/discovery.py b/scripts/codeguard/discovery.py index e26a427..038982e 100644 --- a/scripts/codeguard/discovery.py +++ b/scripts/codeguard/discovery.py @@ -6,6 +6,7 @@ from pathlib import Path from .config import get_overrides +from .path_policy import is_dot_prefixed from .registry import EXT_LANG_MAP, FILE_LANG_MAP, PROJECT_MARKERS @@ -74,6 +75,8 @@ def detect_languages(project_root: str | Path) -> list[str]: ext_map = {**EXT_LANG_MAP, **overrides.get("extensions", {})} def include(file: Path) -> None: + if is_dot_prefixed(file, project_root): + return # 点前缀默认忽略(scan-scope-policy):.eslintrc.js 不计入语言 if file.is_file() and not _excluded(file, exclude): language = ext_map.get(file.suffix.lower()) or FILE_LANG_MAP.get(file.name) if language: diff --git a/scripts/codeguard/path_policy.py b/scripts/codeguard/path_policy.py index 3ea7011..e678c55 100644 --- a/scripts/codeguard/path_policy.py +++ b/scripts/codeguard/path_policy.py @@ -63,6 +63,29 @@ def is_build_artifact(path: str | Path) -> bool: return any(seg in FULL_SCAN_EXCLUDES for seg in parts) +def is_dot_prefixed(path: str | Path, root: str | Path | None = None) -> bool: + """路径相对 root 的任一段以 `.` 开头(`.`/`..` 段除外)→ 点前缀,默认忽略。 + + 检查面的单一谓词(scan-scope-policy spec):PostToolUse 保存面、delta 门禁面、 + 全量扫描与语言发现都用它跳过点前缀目录与文件(.cursor/、.claude/、 + .eslintrc.js 等——枚举追不上新宿主目录,谓词才是硬约束)。 + 例外面由调用方保证:入库安全检查(check_paths)与配置发现不走本谓词。 + + root 语义:path 相对 root 判段——项目根本身位于点前缀父目录(~/.config/proj/) + 不算命中(实测陷阱);root 缺省或 path 不在 root 下时退化为全路径判段。 + 不能用 lstrip("./")(会把 `.tox` 的点剥掉,同 is_build_artifact 的踩点)。 + """ + p = Path(path) + if root is not None: + try: + p = p.resolve().relative_to(Path(root).resolve()) + except (ValueError, OSError): + pass # root 外或无法解析:全路径判段,宁可多忽略也不错扫宿主目录 + parts = [seg for seg in str(p).replace("\\", "/").split("/") + if seg not in ("", ".", "..")] + return any(seg.startswith(".") for seg in parts) + + def check_paths(paths: list[str]) -> list[tuple[str, str, str]]: """纯路径策略:只判断给定拟入库路径,不发现文件、不读 Git、不执行修复。""" violations: list[tuple[str, str, str]] = [] @@ -80,6 +103,14 @@ def check_paths(paths: list[str]) -> list[tuple[str, str, str]]: # (实测误伤,且打断依赖它的 skills-check CI)。 if seg == "vendor" and idx != 0: continue + # 点前缀目录默认忽略(2026-09-23):.agents/.codex-plugin/.zcode/ + # .github/.claude/ 等是宿主插件清单与第一方配置,必须可入库—— + # 裸段匹配曾把插件仓的 marketplace.json/plugin.json 判成"不应入库" + # (实测阻断发版)。扫描面本就不扫这些目录(FULL_SCAN_EXCLUDES), + # 这里只放开"入库面"的目录拦截;密钥类**文件**模式不受影响 + # (.env、*.pem 等仍按 GUARD_EXCLUDE_FILES 拦截)。 + if seg.startswith("."): + continue hit_dir = seg break if hit_dir: diff --git a/scripts/codeguard/save_application.py b/scripts/codeguard/save_application.py index 5b228f6..9777892 100644 --- a/scripts/codeguard/save_application.py +++ b/scripts/codeguard/save_application.py @@ -7,10 +7,10 @@ from dataclasses import dataclass, field from pathlib import Path -from scope import is_build_artifact +from scope import is_build_artifact, is_dot_prefixed from . import hook_state -from .discovery import detect_language, project_uses_linter +from .discovery import detect_language, find_project_root, project_uses_linter from .execution import execute from .fingerprint import check_identity from .hook_state import codeguard_home @@ -73,6 +73,8 @@ def run(cmd: list[str], cwd: Path, timeout: int = 300) -> tuple[int, str, str]: def should_skip(file_path: str, languages: list[str]) -> tuple[bool, str]: if not file_path or is_build_artifact(file_path): return True, "" + if is_dot_prefixed(file_path, find_project_root(file_path)): + return True, "" lang = detect_language(file_path) if not lang: return True, "" diff --git a/scripts/codeguard/startup_application.py b/scripts/codeguard/startup_application.py index 60c5813..602f2c4 100644 --- a/scripts/codeguard/startup_application.py +++ b/scripts/codeguard/startup_application.py @@ -123,6 +123,7 @@ def build_startup_report(project_root: Path, *, plugin_version: str = "", if note: lines.append(note) + lines.append("- **硬性约束:`.` 开头的目录与文件默认忽略**——不扫描、不检查、不报告(.cursor/、.claude/、.eslintrc.js 等宿主工具目录与配置文件);两个例外照常生效:入库安全检查照拦密钥模式(.env/*.pem 等),linter 配置发现照常匹配点文件") lines.append("- AI 写完代码会被 PostToolUse 钩子自动 lint,告警会出现在这里,按告警里的「怎么修」处理") lines.append("- 用户要求「提交/push」时,UserPromptSubmit 钩子会再次确认所有 linter 通过,未通过会拦截提交") lines.append("") diff --git a/scripts/scope.py b/scripts/scope.py index 65a5512..a723b4a 100644 --- a/scripts/scope.py +++ b/scripts/scope.py @@ -23,7 +23,7 @@ import subprocess from pathlib import Path -from codeguard.path_policy import FULL_SCAN_EXCLUDES, is_build_artifact +from codeguard.path_policy import FULL_SCAN_EXCLUDES, is_build_artifact, is_dot_prefixed __all__ = [ "DEFAULT_LANES", @@ -31,6 +31,7 @@ "changed_files", "child_git_repo_names", "is_build_artifact", + "is_dot_prefixed", "is_git_repo", "ruff_config_args", "scope_cmd", @@ -79,7 +80,10 @@ def _inject_find_excludes(expr: str, excludes: tuple[str, ...] | list[str] = FUL """ if "find " not in expr: return expr - additions = "".join( + # 点前缀默认忽略(scan-scope-policy):`*/.*` 要求路径含 `/.` 段接点, + # `./src/x.py` 不命中、`./.cursor/x.py` 与 `./x/.eslintrc.js` 命中 + additions = "" if "'*/.*'" in expr else " -not -path '*/.*'" + additions += "".join( f" -not -path '*/{d}/*'" for d in excludes if f"'*/{d}/*'" not in expr and f"'*/{d}'" not in expr @@ -195,6 +199,9 @@ def scope_cmd( if full_excludes: for d in scan_excludes: out += ["--exclude", d] + # 点前缀默认忽略(scan-scope-policy):两种 glob 兜住 + # 「basename 任意层匹配」与「全路径匹配」两种语义读法 + out += ["--exclude", ".*", "--exclude", "**/.*"] elif full_excludes and Path(out[0]).name in {"bash", "sh", "dash", "zsh", "ksh"}: # 只改 Shell 的命令体;普通 argv/Python -c 中的 "find " 是字面数据,不能注入语法。 for index in range(1, len(out) - 1): @@ -298,4 +305,5 @@ def changed_files( names.update(_unpushed_files(root)) # 构建产物不进任何面:force-add 进索引的 target 文件、未被 gitignore 的 # 生成物,对 linter 只是"下次构建就重写"的假红(单一事实源谓词过滤) - return sorted(n for n in names if not is_build_artifact(n)) + return sorted(n for n in names + if not is_build_artifact(n) and not is_dot_prefixed(n, root)) diff --git a/tests/run_all.py b/tests/run_all.py index bb09017..87c3915 100755 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -241,7 +241,7 @@ def test_hooks(): r = run_hook("user_prompt_validator.py", {"user_prompt": "提交代码"}, clean) ok("未接入 linter 不注入通过确认", r.returncode == 0 and "未验证" in r.stdout and "✅" not in r.stdout) - # ── PreToolUse:安全文件(.env/.venv 入库)→ 🛑 拦截 ── + # ── PreToolUse:安全文件(.env 入库)→ 🛑 拦截;点目录 .venv 默认忽略 ── (repo / ".env").write_text("SECRET=1") (repo / ".venv" / "lib").mkdir(parents=True) (repo / ".venv" / "lib" / "x.py").write_text("x=1") @@ -249,7 +249,8 @@ def test_hooks(): r = run_hook("pre_tool_git_guard.py", {"tool_name": "Bash", "tool_input": {"command": "git commit -m t"}}, repo) ok("安全违规 exit 2", r.returncode == 2) - ok("安全报告含 .env 与 .venv", ".env" in r.stderr and ".venv" in r.stderr) + ok("安全报告含 .env", ".env" in r.stderr) + ok("点目录 .venv 默认忽略(2026-09-23 策略)", ".venv" not in r.stderr) ok("安全报告给出 rm --cached 修法", "rm --cached" in r.stderr) # ── PreToolUse:多 cd 命令链 → 逐 git 段解析边界 ── diff --git a/tests/test_dot_dir_commit_policy.py b/tests/test_dot_dir_commit_policy.py new file mode 100644 index 0000000..dce4cf3 --- /dev/null +++ b/tests/test_dot_dir_commit_policy.py @@ -0,0 +1,74 @@ +"""点前缀目录入库策略测试(2026-09-23)。 + +需求来源:用户指令「codeguard 要默认忽略 . 开头的目录和文件」。实测背景: +插件仓发版时 .agents/plugins/marketplace.json 与 .codex-plugin/plugin.json +被提交内容安全检查判成"依赖/产物目录不应入库",阻断 codegraph-plugin 发版 +——它们是宿主插件清单(第一方配置),必须可入库。 + +锁定三件事: +1. 点前缀**目录**段不再触发"不应入库"目录拦截(.agents/.codex-plugin/.github/...); +2. 非点目录(build/dist/target/vendor/node_modules...)维持原拦截; +3. 密钥类**文件**模式不受点规则影响(.env、*.pem、.DS_Store 仍拦截), + vendor 嵌套特例与 fixture db 特例维持不变。 +""" +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PLUGIN / "scripts")) + +from codeguard.path_policy import check_paths + + +def flagged(paths: list[str]) -> set[str]: + return {v[0] for v in check_paths(paths)} + + +class DotDirCommitPolicyTests(unittest.TestCase): + def test_dot_dirs_are_default_ignored(self) -> None: + """点前缀目录 = 宿主插件清单/第一方配置,允许入库。""" + paths = [ + ".agents/plugins/marketplace.json", + ".codex-plugin/plugin.json", + ".zcode-plugin/plugin.json", + ".github/workflows/ci.yml", + ".claude/settings.json", + ".kimi-code/state.json", + ] + self.assertEqual(flagged(paths), set(), f"点目录不应被拦截: {flagged(paths)}") + + def test_non_dot_dirs_still_flagged(self) -> None: + """非点产物/依赖目录维持原拦截。""" + paths = [ + "build/out.js", + "dist/bundle.js", + "target/site/a.html", + "node_modules/p/index.js", + "coverage/lcov.info", + "vendor/pkg/x.py", + ] + self.assertEqual(flagged(paths), set(paths)) + + def test_secret_dot_files_still_flagged(self) -> None: + """点规则只放开目录;密钥/垃圾**文件**模式照拦。""" + paths = [".env", "src/secret.pem", "keys/server.key", ".DS_Store"] + self.assertEqual(flagged(paths), set(paths)) + + def test_root_dotfile_without_secret_pattern_allowed(self) -> None: + """无敏感模式的普通点文件(.gitignore/.npmrc)本来就不拦。""" + self.assertEqual(flagged([".gitignore", ".npmrc", ".markdownlint-cli2.jsonc"]), set()) + + def test_vendor_and_fixture_exceptions_unchanged(self) -> None: + """vendor 嵌套特例与 fixture db 特例维持原行为。""" + self.assertEqual( + flagged(["scripts/vendor/skill_vendor.py", "tests/fixtures/sample.db"]), + set(), + ) + self.assertEqual(flagged(["sample.db"]), {"sample.db"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_dot_prefix_default_skip.py b/tests/test_dot_prefix_default_skip.py new file mode 100644 index 0000000..8b1ad84 --- /dev/null +++ b/tests/test_dot_prefix_default_skip.py @@ -0,0 +1,142 @@ +"""点前缀默认忽略硬约束测试(2026-09-23 用户指令,scan-scope-policy spec)。 + +锁定五件事: +1. 检查/保存/发现/全量扫描四类面默认忽略点前缀目录与文件; +2. 入库安全面不受影响(密钥照拦、点目录照常可入库,固化 0f284ee); +3. 配置发现不受影响(requiresConfig 点文件照常判已接入); +4. 提示词面(SessionStart 项目记忆 + AGENTS.md 硬性禁令)声明该约束; +5. 项目根本身位于点前缀父目录下不误伤。 +""" +from __future__ import annotations + +import itertools +import json +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")) +sys.path.insert(0, str(PLUGIN / "hooks")) + +import scope +from codeguard.discovery import detect_languages, project_uses_linter +from codeguard.path_policy import check_paths, is_dot_prefixed +from codeguard.save_application import should_skip + +REGISTRY = json.loads((PLUGIN / "scripts" / "languages.json").read_text(encoding="utf-8")) +SHELL_GATE = next(l for l in REGISTRY["languages"] if l["id"] == "shell")["gate"] + + +def _git(root: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=root, capture_output=True, check=False) + + +class PredicateTests(unittest.TestCase): + def test_dot_segments_relative_to_root(self) -> None: + self.assertTrue(is_dot_prefixed(".cursor/a.py", ".")) + self.assertTrue(is_dot_prefixed("src/.hidden/x.py", ".")) + self.assertTrue(is_dot_prefixed(".eslintrc.js", ".")) + self.assertFalse(is_dot_prefixed("src/a.py", ".")) + + def test_root_and_parent_segments_exempt(self) -> None: + # `./src/a.py` 的 `.` 段、`../x` 的 `..` 段不算命中(lstrip("./") 踩点) + self.assertFalse(is_dot_prefixed("./src/a.py", ".")) + self.assertFalse(is_dot_prefixed("../proj/a.py", ".")) + + def test_project_under_dot_parent_not_skipped(self) -> None: + # 实测陷阱:项目根 ~/.config/proj/ 的父段点前缀不得让全项目被忽略 + tmp = Path(tempfile.mkdtemp()) + proj = tmp / ".config" / "proj" + proj.mkdir(parents=True) + (proj / "main.py").write_text("x=1\n") + self.assertFalse(is_dot_prefixed(proj / "main.py", proj)) + self.assertTrue(is_dot_prefixed(proj / ".eslintrc.js", proj)) + + +class CheckFaceTests(unittest.TestCase): + def test_should_skip_dot_file(self) -> None: + tmp = Path(tempfile.mkdtemp()) + (tmp / "src").mkdir() + (tmp / ".cursor").mkdir() + (tmp / "src" / "a.py").write_text("x=1\n") + (tmp / ".cursor" / "b.py").write_text("x=1\n") + self.assertEqual(should_skip(str(tmp / ".cursor" / "b.py"), ["python"]), (True, "")) + self.assertEqual(should_skip(str(tmp / "src" / "a.py"), ["python"]), (False, "python")) + + def test_changed_files_filters_dot_paths(self) -> None: + tmp = Path(tempfile.mkdtemp()) + _git(tmp, "init", "-q") + _git(tmp, "config", "user.email", "t@t") + _git(tmp, "config", "user.name", "t") + (tmp / ".cursor").mkdir() + (tmp / ".cursor" / "rules.py").write_text("x=1\n") + (tmp / "main.py").write_text("x=1\n") + _git(tmp, "add", "-A") + changed = scope.changed_files(tmp, mode="commit") + self.assertIn("main.py", changed) + self.assertNotIn(".cursor/rules.py", changed) + + def test_detect_languages_ignores_dot_files(self) -> None: + tmp = Path(tempfile.mkdtemp()) + (tmp / ".eslintrc.js").write_text("module.exports = {}\n") + (tmp / "main.py").write_text("x=1\n") + (tmp / "src").mkdir() + (tmp / "src" / ".hidden").mkdir() + (tmp / "src" / ".hidden" / "x.js").write_text("x=1\n") + langs = detect_languages(tmp) + self.assertIn("python", langs) + self.assertNotIn("typescript", langs) # .eslintrc.js 与 .hidden/x.js 不计入 + + def test_full_scan_find_injects_dot_exclude(self) -> None: + once = scope.scope_cmd(SHELL_GATE, ".", full_excludes=True) + expr = once[-1] + self.assertIn("-not -path */.*", expr.replace(chr(39), "")) + self.assertLess(expr.index("*/.*"), expr.index("-print0")) + twice = scope.scope_cmd(once, ".", full_excludes=True) + self.assertEqual(once, twice) # 幂等 + + def test_full_scan_ruff_gains_dot_excludes(self) -> None: + out = scope.scope_cmd(["ruff", "check", "."], ".", full_excludes=True) + pairs = list(itertools.pairwise(out)) + self.assertIn(("--exclude", ".*"), pairs) + self.assertIn(("--exclude", "**/.*"), pairs) + + +class ExceptionFaceTests(unittest.TestCase): + def test_secret_files_still_blocked(self) -> None: + violations = check_paths([".env", "id_rsa", "x/id_ed25519"]) + self.assertEqual(len(violations), 3) + + def test_dot_dirs_remain_committable(self) -> None: + self.assertEqual(check_paths([".agents/plugins/marketplace.json"]), []) + self.assertEqual(check_paths([".codex-plugin/plugin.json"]), []) + + def test_requires_config_matches_dot_files(self) -> None: + tmp = Path(tempfile.mkdtemp()) + self.assertFalse(project_uses_linter({"requiresConfig": [".markdownlint-cli2.jsonc"]}, tmp)) + (tmp / ".markdownlint-cli2.jsonc").write_text("{}") + self.assertTrue(project_uses_linter({"requiresConfig": [".markdownlint-cli2.jsonc"]}, tmp)) + + +class PromptFaceTests(unittest.TestCase): + def test_startup_context_states_rule(self) -> None: + from codeguard.startup_application import build_startup_report + tmp = Path(tempfile.mkdtemp()) + (tmp / "main.py").write_text("x=1\n") + report = build_startup_report(tmp) + self.assertIn("默认忽略", report.text) + self.assertIn("入库安全检查照拦密钥模式", report.text) + self.assertIn("配置发现照常匹配点文件", report.text) + + def test_agents_md_states_rule(self) -> None: + text = (PLUGIN / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("默认忽略", text) + self.assertIn("硬性禁令", text) + self.assertIn("requiresConfig", text) + + +if __name__ == "__main__": + unittest.main()