Skip to content

Commit fe41466

Browse files
committed
建立宿主事件检查档位的 Rust 路由契约
1 parent f1a2a7b commit fe41466

13 files changed

Lines changed: 412 additions & 0 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use serde::Serialize;
2+
3+
/// 宿主报告的触发阶段;它本身不是可信代码或 Git 快照。
4+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5+
#[serde(rename_all = "snake_case")]
6+
pub enum HookEvent {
7+
/// 会话启动或恢复。
8+
SessionStart,
9+
/// 用户提交提示;仅供非阻断性意图提示。
10+
PromptSubmitted,
11+
/// 文件编辑工具完成后。
12+
FileChanged,
13+
/// 智能体完成一次修复尝试后。
14+
RepairReady,
15+
/// 提交前。
16+
PreCommit,
17+
/// 推送前。
18+
PrePush,
19+
/// 持续集成或正式交付检查。
20+
Ci,
21+
/// 会话结束。
22+
Stop,
23+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
use serde::Serialize;
2+
3+
/// 一次事件应请求的检查阶段;具体检查器由正式 CheckPlan 决定。
4+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5+
#[serde(rename_all = "snake_case")]
6+
pub enum HookTriggerAction {
7+
/// 已确认写入失败,无须启动源码检查。
8+
NoCheck,
9+
/// 只读观察项目语言与配置。
10+
DiscoverProject,
11+
/// 根据用户意图展示非阻断性检查建议,不运行 Git 门禁。
12+
ShowIntentGuidance,
13+
/// 对明确编辑且确认写入的文件提供快速反馈。
14+
FastFileCheck,
15+
/// 无可靠编辑目标时重新确定范围,不能称为已检查。
16+
ResolveChangedScope,
17+
/// 按任务身份运行原检查器复检。
18+
VerifyTask,
19+
/// 对本轮 Git index 内容面运行严格提交门禁。
20+
CommitGate,
21+
/// 对本轮实际推送 ref 内容面运行严格推送门禁。
22+
PushGate,
23+
/// 执行完整项目义务。
24+
FullProjectCheck,
25+
/// 仅汇总本次会话状态。
26+
ShowSummary,
27+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use crate::{HookEvent, HookWriteOutcome};
2+
3+
/// 宿主事件的未核验输入;规划器不能把它当成代码或 Git 事实。
4+
#[derive(Clone, Debug, Eq, PartialEq)]
5+
pub struct HookTriggerInput {
6+
/// 事件阶段。
7+
pub event: HookEvent,
8+
/// 编辑事件携带的工作区相对路径;交付事件必须重新获取 Git 范围。
9+
pub changed_paths: Vec<String>,
10+
/// 修复复检事件的稳定任务 ID。
11+
pub task_id: Option<String>,
12+
/// 宿主对编辑工具写入结果的观察;不证明实际字节身份。
13+
pub write_outcome: HookWriteOutcome,
14+
/// 宿主声明是否支持可靠阻断;此输入尚未经真实宿主协议验收。
15+
pub host_claims_blocking: bool,
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
use serde::Serialize;
2+
3+
use crate::HookTriggerAction;
4+
5+
/// 事件路由的只读候选计划;没有执行证据,不可签发交付通过。
6+
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
7+
pub struct HookTriggerPlan {
8+
/// 本事件请求的检查阶段。
9+
pub action: HookTriggerAction,
10+
/// 编辑快检的目标路径;交付阶段必须忽略宿主猜测的路径集合。
11+
pub target_paths: Vec<String>,
12+
/// 原检查器复检目标。
13+
pub task_id: Option<String>,
14+
/// 是否须从本轮 Git 获取真实提交或推送快照。
15+
pub requires_git_snapshot: bool,
16+
/// 局部完整结果是否可作为软反馈复用候选;复用仍需核验全部身份。
17+
pub soft_result_reuse_candidate: bool,
18+
/// 宿主对阻断能力的声明,仅供后续真实宿主核验。
19+
pub host_blocking_claimed: bool,
20+
/// 规划从不证明检查已完成或允许交付。
21+
pub may_claim_delivery: bool,
22+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use std::collections::BTreeSet;
2+
3+
use crate::{HookEvent, HookTriggerAction, HookTriggerInput, HookTriggerPlan, HookWriteOutcome};
4+
5+
/// 把宿主事件映射为检查阶段,交付事件始终要求重新取得真实范围。
6+
/// 返回候选计划或损坏事件输入;不执行检查、复用缓存或签发门禁结论。
7+
pub fn plan_hook_trigger(input: &HookTriggerInput) -> Result<HookTriggerPlan, &'static str> {
8+
let mut plan = HookTriggerPlan {
9+
action: HookTriggerAction::DiscoverProject,
10+
target_paths: Vec::new(),
11+
task_id: None,
12+
requires_git_snapshot: false,
13+
soft_result_reuse_candidate: false,
14+
host_blocking_claimed: false,
15+
may_claim_delivery: false,
16+
};
17+
match input.event {
18+
HookEvent::SessionStart => {}
19+
HookEvent::PromptSubmitted => plan.action = HookTriggerAction::ShowIntentGuidance,
20+
HookEvent::FileChanged => {
21+
let mut seen = BTreeSet::new();
22+
for path in &input.changed_paths {
23+
if !valid_relative_path(path) {
24+
return Err("hook_changed_path_invalid");
25+
}
26+
if seen.insert(path.as_str()) {
27+
plan.target_paths.push(path.clone());
28+
}
29+
}
30+
plan.action = match input.write_outcome {
31+
HookWriteOutcome::Failed => HookTriggerAction::NoCheck,
32+
HookWriteOutcome::Confirmed if !plan.target_paths.is_empty() => {
33+
plan.soft_result_reuse_candidate = true;
34+
HookTriggerAction::FastFileCheck
35+
}
36+
HookWriteOutcome::Confirmed | HookWriteOutcome::Unknown => {
37+
HookTriggerAction::ResolveChangedScope
38+
}
39+
};
40+
}
41+
HookEvent::RepairReady => {
42+
let id = input
43+
.task_id
44+
.as_deref()
45+
.filter(|id| valid_task_id(id))
46+
.ok_or("hook_task_id_invalid")?;
47+
plan.action = HookTriggerAction::VerifyTask;
48+
plan.task_id = Some(id.into());
49+
}
50+
HookEvent::PreCommit => {
51+
plan.action = HookTriggerAction::CommitGate;
52+
plan.requires_git_snapshot = true;
53+
plan.host_blocking_claimed = input.host_claims_blocking;
54+
}
55+
HookEvent::PrePush => {
56+
plan.action = HookTriggerAction::PushGate;
57+
plan.requires_git_snapshot = true;
58+
plan.host_blocking_claimed = input.host_claims_blocking;
59+
}
60+
HookEvent::Ci => {
61+
plan.action = HookTriggerAction::FullProjectCheck;
62+
plan.host_blocking_claimed = input.host_claims_blocking;
63+
}
64+
HookEvent::Stop => plan.action = HookTriggerAction::ShowSummary,
65+
}
66+
Ok(plan)
67+
}
68+
69+
fn valid_relative_path(path: &str) -> bool {
70+
!path.is_empty()
71+
&& !path.starts_with('/')
72+
&& !path.contains('\\')
73+
&& !path.chars().any(char::is_control)
74+
&& path
75+
.split('/')
76+
.all(|part| !part.is_empty() && part != "." && part != "..")
77+
}
78+
79+
fn valid_task_id(id: &str) -> bool {
80+
id.len() > 3
81+
&& id.len() <= 64
82+
&& id.starts_with("CG-")
83+
&& id[3..]
84+
.bytes()
85+
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
86+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
use serde::Serialize;
2+
3+
/// 编辑工具的写入结果;失败不触发源码检查,未知结果需重新确定范围。
4+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
5+
#[serde(rename_all = "snake_case")]
6+
pub enum HookWriteOutcome {
7+
/// 宿主确认写入成功;仍须核对真实源码身份。
8+
Confirmed,
9+
/// 宿主确认未写入。
10+
Failed,
11+
/// 宿主无法确认是否写入。
12+
Unknown,
13+
}

‎crates/codeguard-core/src/lib.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ mod delivery_gate;
1010
mod evaluation;
1111
mod false_positive_allowlist;
1212
mod finding;
13+
mod hook_event;
14+
mod hook_trigger_action;
15+
mod hook_trigger_input;
16+
mod hook_trigger_plan;
17+
mod hook_trigger_planner;
18+
mod hook_write_outcome;
1319
mod native_checker_binding;
1420
mod obligation_result;
1521
mod observation_port;
@@ -45,6 +51,12 @@ pub use false_positive_allowlist::{
4551
valid_false_positive_identity,
4652
};
4753
pub use finding::{Finding, FindingLocation, GateImpact};
54+
pub use hook_event::HookEvent;
55+
pub use hook_trigger_action::HookTriggerAction;
56+
pub use hook_trigger_input::HookTriggerInput;
57+
pub use hook_trigger_plan::HookTriggerPlan;
58+
pub use hook_trigger_planner::plan_hook_trigger;
59+
pub use hook_write_outcome::HookWriteOutcome;
4860
pub use native_checker_binding::NativeCheckerBinding;
4961
pub use obligation_result::ObligationResult;
5062
pub use observation_port::{ObservationPort, ObservedPathKind};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
use codeguard_core::{
2+
HookEvent, HookTriggerAction, HookTriggerInput, HookWriteOutcome, plan_hook_trigger,
3+
};
4+
5+
fn request(event: HookEvent) -> HookTriggerInput {
6+
HookTriggerInput {
7+
event,
8+
changed_paths: Vec::new(),
9+
task_id: None,
10+
write_outcome: HookWriteOutcome::Unknown,
11+
host_claims_blocking: true,
12+
}
13+
}
14+
15+
#[test]
16+
fn startup_and_stop_do_not_execute_quality_checks() {
17+
let startup = plan_hook_trigger(&request(HookEvent::SessionStart)).unwrap();
18+
assert_eq!(startup.action, HookTriggerAction::DiscoverProject);
19+
assert!(!startup.requires_git_snapshot);
20+
let stop = plan_hook_trigger(&request(HookEvent::Stop)).unwrap();
21+
assert_eq!(stop.action, HookTriggerAction::ShowSummary);
22+
assert!(!stop.may_claim_delivery);
23+
let prompt = plan_hook_trigger(&request(HookEvent::PromptSubmitted)).unwrap();
24+
assert_eq!(prompt.action, HookTriggerAction::ShowIntentGuidance);
25+
assert!(!prompt.requires_git_snapshot);
26+
assert!(!prompt.may_claim_delivery);
27+
}
28+
29+
#[test]
30+
fn successful_edit_requests_only_file_feedback() {
31+
let mut input = request(HookEvent::FileChanged);
32+
input.write_outcome = HookWriteOutcome::Confirmed;
33+
input.changed_paths = vec!["src/A.java".into(), "src/B.java".into()];
34+
let plan = plan_hook_trigger(&input).unwrap();
35+
assert_eq!(plan.action, HookTriggerAction::FastFileCheck);
36+
assert_eq!(plan.target_paths, input.changed_paths);
37+
assert!(plan.soft_result_reuse_candidate);
38+
assert!(!plan.may_claim_delivery);
39+
assert!(!plan.requires_git_snapshot);
40+
}
41+
42+
#[test]
43+
fn uncertain_edit_or_missing_target_cannot_be_silently_skipped() {
44+
let mut input = request(HookEvent::FileChanged);
45+
input.write_outcome = HookWriteOutcome::Confirmed;
46+
assert_eq!(
47+
plan_hook_trigger(&input).unwrap().action,
48+
HookTriggerAction::ResolveChangedScope
49+
);
50+
input.changed_paths = vec!["src/A.java".into()];
51+
input.write_outcome = HookWriteOutcome::Unknown;
52+
assert_eq!(
53+
plan_hook_trigger(&input).unwrap().action,
54+
HookTriggerAction::ResolveChangedScope
55+
);
56+
input.changed_paths = vec!["../outside.java".into()];
57+
assert!(plan_hook_trigger(&input).is_err());
58+
}
59+
60+
#[test]
61+
fn failed_write_does_not_start_a_source_check() {
62+
let mut input = request(HookEvent::FileChanged);
63+
input.write_outcome = HookWriteOutcome::Failed;
64+
input.changed_paths = vec!["src/A.java".into()];
65+
let plan = plan_hook_trigger(&input).unwrap();
66+
assert_eq!(plan.action, HookTriggerAction::NoCheck);
67+
assert!(!plan.soft_result_reuse_candidate);
68+
}
69+
70+
#[test]
71+
fn repair_requests_original_task_verification() {
72+
let mut input = request(HookEvent::RepairReady);
73+
input.task_id = Some("CG-abc123".into());
74+
let plan = plan_hook_trigger(&input).unwrap();
75+
assert_eq!(plan.action, HookTriggerAction::VerifyTask);
76+
assert_eq!(plan.task_id.as_deref(), Some("CG-abc123"));
77+
assert!(!plan.soft_result_reuse_candidate);
78+
assert!(plan_hook_trigger(&request(HookEvent::RepairReady)).is_err());
79+
}
80+
81+
#[test]
82+
fn delivery_events_always_request_current_git_or_project_scope() {
83+
for (event, action) in [
84+
(HookEvent::PreCommit, HookTriggerAction::CommitGate),
85+
(HookEvent::PrePush, HookTriggerAction::PushGate),
86+
(HookEvent::Ci, HookTriggerAction::FullProjectCheck),
87+
] {
88+
let mut input = request(event);
89+
input.changed_paths = vec!["src/A.java".into()];
90+
let plan = plan_hook_trigger(&input).unwrap();
91+
assert_eq!(plan.action, action);
92+
assert!(
93+
plan.target_paths.is_empty(),
94+
"caller paths cannot define delivery scope"
95+
);
96+
assert!(!plan.soft_result_reuse_candidate);
97+
assert_eq!(plan.requires_git_snapshot, event != HookEvent::Ci);
98+
assert!(
99+
!plan.may_claim_delivery,
100+
"planning is never proof of a gate"
101+
);
102+
}
103+
}
104+
105+
#[test]
106+
fn nonblocking_host_cannot_claim_a_strict_gate() {
107+
let mut input = request(HookEvent::PreCommit);
108+
input.host_claims_blocking = false;
109+
let plan = plan_hook_trigger(&input).unwrap();
110+
assert_eq!(plan.action, HookTriggerAction::CommitGate);
111+
assert!(!plan.host_blocking_claimed);
112+
assert!(!plan.may_claim_delivery);
113+
}

‎docs/Codeguard-Architecture.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,34 @@ For required setup, create one setup/confirmation task per module/checker obliga
309309

310310
Allowlist dispositions bind rule, source target, grammar identity and reason; retain observations and reevaluate after relevant changes. They cannot remove required native checking or fabricate coverage. Repeated runs update stable tasks and attempt history. Emit the initial brief, then meaningful changes; preserve a status view instead of repeating unchanged installation requests. See the technical design's **7.3–7.4** for bilingual report examples and the proposed data contract.
311311

312+
### 8.4 Host events and check tiers — target
313+
314+
Host hooks pass events, workspace identity and known targets. Rust core selects the check tier; the formal CheckPlan then chooses native checkers and resource budgets. An agent cannot change delivery obligations through a prompt, an old soft cache entry or a project-local `skipGate`. This remains a target design: the [pure domain router](../crates/codeguard-core/src/hook_trigger_planner.rs) has unit coverage, but no plugin, CLI or real Git/CI wiring and no verified automatic host trigger yet.
315+
316+
```mermaid
317+
flowchart LR
318+
H[Host event] --> R[Pure Rust event routing]
319+
R -->|Session start| D[Read-only discovery]
320+
R -->|Confirmed edit| F[Changed-file feedback]
321+
R -->|Repair ready| V[Original task and checker recheck]
322+
R -->|Commit / push| G[Acquire current Git content scope]
323+
R -->|CI| A[Full project obligations]
324+
F --> P[Unified report / task / next action]
325+
V --> P
326+
G --> P
327+
A --> P
328+
```
329+
330+
| Event | Requested tier | Required boundary |
331+
| :--- | :--- | :--- |
332+
| Start/resume, prompt, stop | Read-only discovery, nonblocking intent guidance, session summary | Prompt text cannot replace a real Git gate or claim delivery |
333+
| Confirmed write | Affected-file fast check | Reuse a complete soft result only when content and all configuration identities match; queue project-wide work |
334+
| Failed/unknown write | No source check / resolve scope | Unknown cannot mean unchanged or checked |
335+
| Repair attempt completed | Original `task verify` | Installing a tool, ticking a task or WASM clean alone does not close it |
336+
| Commit, push, CI | Strict commit scope, actual pushed refs, full project obligations | Ignore host-supplied path guesses and old save feedback; disclose missing enforcement support |
337+
338+
Coalescing applies only to duplicate fast feedback for the same content snapshot; it cannot swallow new input, failed rechecks or delivery events. Cache identity must cover source and dependency closure, tool, rules, configuration, platform and vulnerability database freshness. Strict gates revalidate full obligations and identities, then recompute or report incomplete when proof is missing. Measure latency percentiles, repeat execution, invalidations, missed findings and false positives per tier before fixing budgets.
339+
312340
## 9. Persistence and ownership
313341

314342
```text

0 commit comments

Comments
 (0)