@antprofuse/saddle-skill 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,163 +1,124 @@
1
- # Saddle Service 0.1.1 契约
1
+ # Service 契约
2
2
 
3
- ## 依赖
3
+ ## 目录
4
4
 
5
- 使用已发布门面包的精确版本:
5
+ - [定义和实现](#定义和实现)
6
+ - [描述和注册](#描述和注册)
7
+ - [内部 Service 调用](#内部-service-调用)
8
+ - [稳定错误](#稳定错误)
9
+ - [错误模式](#错误模式)
6
10
 
7
- ```toml
8
- [package]
9
- edition = "2024"
10
- rust-version = "1.85"
11
+ ## 定义和实现
11
12
 
12
- [dependencies]
13
- saddle-framework = "=0.1.1"
14
- serde = { version = "1", features = ["derive"] }
15
- ```
16
-
17
- Cargo 包名是 `saddle-framework`,Rust 库名是 `saddle`。
18
-
19
- ## 正确的最小模式
20
-
21
- 定义一个零大小的契约类型、可序列化的请求/响应类型,以及独立的处理器:
13
+ 为每个业务操作定义零大小契约类型、请求、响应和独立 Handler:
22
14
 
23
15
  ```rust
24
16
  use saddle::{
25
17
  CallContext,
26
18
  service::{Service, ServiceFuture, ServiceHandler},
27
19
  };
28
- use serde::{Deserialize, Serialize};
29
20
 
30
- struct Hello;
21
+ struct GetUser;
31
22
 
32
- #[derive(Deserialize)]
33
- struct HelloRequest {
34
- name: String,
23
+ impl Service for GetUser {
24
+ type Request = GetUserRequest;
25
+ type Response = GetUserResponse;
35
26
  }
36
27
 
37
- #[derive(Serialize)]
38
- struct HelloResponse {
39
- message: String,
40
- }
41
-
42
- impl Service for Hello {
43
- type Request = HelloRequest;
44
- type Response = HelloResponse;
45
- }
46
-
47
- struct HelloHandler;
48
-
49
- impl ServiceHandler<Hello> for HelloHandler {
28
+ impl ServiceHandler<GetUser> for GetUserHandler {
50
29
  fn call<'a>(
51
30
  &'a self,
52
- _context: &'a CallContext,
53
- request: HelloRequest,
54
- ) -> ServiceFuture<'a, HelloResponse> {
31
+ context: &'a CallContext,
32
+ request: GetUserRequest,
33
+ ) -> ServiceFuture<'a, GetUserResponse> {
55
34
  Box::pin(async move {
56
- Ok(HelloResponse {
57
- message: format!("Hello, {}!", request.name),
58
- })
35
+ // 只表达业务规则,并通过注入的 Saddle 能力执行调用和 I/O。
36
+ Ok(GetUserResponse { exists: true })
59
37
  })
60
38
  }
61
39
  }
62
40
  ```
63
41
 
64
- 只通过 Saddle 注册并暴露 Service:
42
+ 请求和响应必须是 `Send + 'static`。通过 JSON 暴露时,请求还必须实现
43
+ `DeserializeOwned`,响应必须实现 `Serialize`。
65
44
 
66
- ```rust
67
- builder.register::<Hello, _>(
68
- ServiceDescriptor::new("helloworld", "hello", "say_hello"),
69
- HelloHandler,
70
- )?;
71
- builder.expose_json::<Hello>("/hello");
72
- ```
45
+ ## 描述和注册
73
46
 
74
- 使用固定的进程入口:
47
+ `ServiceDescriptor::new(module, service, operation)` 的三个标识必须稳定、低基数:
75
48
 
76
49
  ```rust
77
- let database_url = std::env::var("SADDLE_DATABASE_URL")
78
- .expect("SADDLE_DATABASE_URL must be set by deployment configuration");
79
- let config = SaddleConfig::new("helloworld", database_url, listen);
80
-
81
- Saddle::run(config, |builder| {
82
- // Register and expose Services here.
83
- Ok(())
84
- })
50
+ builder.register::<GetUser, _>(
51
+ ServiceDescriptor::new("user", "user", "get"),
52
+ GetUserHandler::new(database.clone()),
53
+ )?;
54
+ builder.expose_json::<GetUser>("/users/get");
85
55
  ```
86
56
 
87
- 完整的可编译消费端见 `examples/helloworld`。
57
+ - `module` 表示业务模块。
58
+ - `service` 表示稳定的业务服务。
59
+ - `operation` 表示具体操作。
60
+ - 只对确实需要外部访问的 Service 调用 `expose_json`。
61
+ - 由 Saddle 检查重复注册、未注册入口和依赖闭合,不要自建注册表。
88
62
 
89
- ## 错误模式
90
-
91
- 不要使用未固定版本或兼容版本范围:
92
-
93
- ```toml
94
- # 错误:0.1.x 可能发生不兼容变化。
95
- saddle-framework = "0.1"
96
- ```
63
+ ## 内部 Service 调用
97
64
 
98
- 不要嵌入数据库凭据,也不要提供不安全的回退值:
65
+ 调用方依赖 `ServiceClient<目标契约>`,不得依赖目标 Handler:
99
66
 
100
67
  ```rust
101
- // 错误:规范示例不能将密钥写入源码。
102
- let database_url = "mysql://user:password@127.0.0.1/application";
68
+ struct CreateOrderHandler {
69
+ users: ServiceClient<GetUser>,
70
+ }
103
71
 
104
- // 错误:缺少部署配置时必须失败,不能静默使用源码中的凭据。
105
- let database_url = std::env::var("SADDLE_DATABASE_URL")
106
- .unwrap_or_else(|_| "mysql://user:password@127.0.0.1/application".into());
72
+ let user = self
73
+ .users
74
+ .call(context, GetUserRequest { user_id })
75
+ .await?;
107
76
  ```
108
77
 
109
- 不要从门面根模块导入 Service trait:
78
+ `register_with` 工厂中解析依赖,使缺失依赖在装配阶段失败:
110
79
 
111
80
  ```rust
112
- // 0.1.1 中错误:这些类型位于 saddle::service。
113
- use saddle::{Service, ServiceHandler};
81
+ builder.register_with::<CreateOrder, CreateOrderHandler, _>(
82
+ ServiceDescriptor::new("order", "order", "create"),
83
+ move |services| {
84
+ Ok(CreateOrderHandler::new(
85
+ database,
86
+ services.client::<GetUser>()?,
87
+ ))
88
+ },
89
+ )?;
114
90
  ```
115
91
 
116
- 不要使用 Tokio 或 Axum 实现业务执行入口:
117
-
118
- ```rust
119
- // 错误:业务代码不能拥有运行时或 HTTP 服务器。
120
- #[tokio::main]
121
- async fn main() {}
92
+ 不要直接构造 `ServiceClient`,也不要直接调用另一个模块的 Handler。
122
93
 
123
- tokio::spawn(async move {});
124
- axum::Router::new();
125
- ```
94
+ ## 稳定错误
126
95
 
127
- 不要虚构 0.1.1 未发布的便捷宏或异步 trait 语法:
96
+ 业务预期失败使用 `SaddleError`,错误码使用稳定的大写标识:
128
97
 
129
98
  ```rust
130
- // 错误:这些不是 0.1.1 中经过验证的公共 API。
131
- #[saddle::service]
132
- async fn hello(...) { ... }
133
-
134
- #[saddle::main]
135
- async fn main() { ... }
99
+ return Err(SaddleError::new(
100
+ ErrorKind::InvalidArgument,
101
+ "ORDER_AMOUNT_INVALID",
102
+ "order amount must be greater than zero",
103
+ ));
136
104
  ```
137
105
 
138
- 不要直接创建注册表或数据库:
106
+ 可用 `ErrorKind`:`InvalidArgument`、`NotFound`、`Conflict`、`Business`、
107
+ `Unavailable`、`Infrastructure`、`Internal`。该枚举是非穷尽的;业务匹配时保留
108
+ 通配分支。错误消息不得包含凭据、SQL 参数、完整 payload 或内部敏感细节。
139
109
 
140
- ```rust
141
- // 错误:装配和托管 I/O 的创建属于 Saddle。
142
- let registry = saddle::service::ServiceRegistryBuilder::new();
143
- let database = saddle::db::Database::connect(...);
144
- ```
110
+ ## 错误模式
145
111
 
146
- ## 验证
112
+ ```rust
113
+ // 错误:绕过 Saddle 的内部调用和上下文传播。
114
+ let response = GetUserHandler::new(database).call(context, request).await?;
147
115
 
148
- 执行:
116
+ // 错误:0.1.1 没有这些宏。
117
+ #[saddle::service]
118
+ async fn create_order(...) { ... }
149
119
 
150
- ```bash
151
- cargo +1.85.0 check --locked --manifest-path examples/helloworld/Cargo.toml
152
- cargo check --locked --manifest-path examples/helloworld/Cargo.toml
153
- cargo tree --locked --manifest-path examples/helloworld/Cargo.toml \
154
- -p saddle-framework@0.1.1
120
+ // 错误:Service trait 位于 saddle::service。
121
+ use saddle::{Service, ServiceHandler};
155
122
  ```
156
123
 
157
- 当前已验证结果:
158
-
159
- - Rust 1.85 和当前工具链检查均可针对 crates.io 的 `saddle-framework 0.1.1` 制品编译。
160
- - 依赖树将所有 Saddle 组件解析为 0.1.1。
161
- - 锁文件使用 Cargo 1.85 的不兼容 Rust 版本回退机制解析,因此所有选中包都支持声明的最低 Rust 版本。
162
-
163
- 该示例不能证明运行时已就绪,因为 `SADDLE_DATABASE_URL` 必须指向可访问的数据库,且配置的监听地址必须可用。
124
+ 完整组合方式见 `assets/v1-business/src/user.rs`、`order.rs` 和 `main.rs`。
@@ -0,0 +1,50 @@
1
+ # 验证
2
+
3
+ ## 编译
4
+
5
+ 在 Saddle 仓库执行:
6
+
7
+ ```bash
8
+ cargo +1.85.0 fmt --manifest-path examples/v1-business/Cargo.toml -- --check
9
+ cargo +1.85.0 check --locked --manifest-path examples/v1-business/Cargo.toml
10
+ cargo check --locked --manifest-path examples/v1-business/Cargo.toml
11
+ cargo tree --locked --manifest-path examples/v1-business/Cargo.toml \
12
+ -p saddle-framework@0.1.1
13
+ gates/check-business-boundaries.sh examples/v1-business/Cargo.toml
14
+ ```
15
+
16
+ 对安装后的 Skill,将路径替换为 `assets/v1-business/Cargo.toml`。
17
+
18
+ ## 依赖边界
19
+
20
+ 确认业务清单只直接声明精确版本 Saddle 门面和批准的序列化/纯计算依赖。依赖树中
21
+ 出现 Tokio、Axum 或 sqlx 作为 Saddle 的传递依赖是正常的;业务 `Cargo.toml`
22
+ 直接声明或业务源码直接使用它们才是旁路。
23
+
24
+ ## 当前事实
25
+
26
+ - Rust 1.85 和当前工具链可编译 `assets/v1-business`。
27
+ - 所有 Saddle 组件解析为 0.1.1。
28
+ - 示例覆盖 Service 注册与 HTTP/JSON 暴露、内部 `ServiceClient`、托管 DB、
29
+ 单层事务、稳定错误和领域事件。
30
+ - 示例已在 MariaDB 10.11 上完成真实 HTTP 验证:正常订单同时提交 `orders` 和
31
+ `order_audit`;让第二条写入失败后返回 `db.query_failed`,第一条写入被回滚。
32
+ - 示例实测 `InvalidArgument` 映射为 HTTP 400、`Business` 映射为 HTTP 422、
33
+ DB 基础设施错误映射为 HTTP 500;响应头和错误体携带同一个 trace ID。
34
+ - 32 位十六进制 `x-saddle-trace-id` 被继承;无效值由框架替换。日志中的外部入口、
35
+ 内部 Service、DB、事务和领域事件共享 trace,并记录 commit 或 rollback。
36
+
37
+ 这些 HTTP 和日志结论是 `saddle-framework 0.1.1` 配合本示例的验证基线。升级制品后
38
+ 必须重新运行,不能把示例观测外推为未发布版本的承诺。
39
+
40
+ ## MariaDB 部署前提
41
+
42
+ 0.1.1 会校验数据库入站包上限。MariaDB 的 `max_allowed_packet` 大于框架允许值时,
43
+ 示例启动返回 `db.invalid_config`;本次以 8 MiB 配置验证通过。部署前显式确认:
44
+
45
+ ```sql
46
+ SELECT @@global.max_allowed_packet;
47
+ ```
48
+
49
+ 不要为了通过启动检查提高该值;生产配置应由部署环境按 0.1.1 的 8 MiB 结果边界
50
+ 统一设置。
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
5
+ exec python3 "$script_dir/check_business_boundaries.py" "$@"
@@ -0,0 +1,356 @@
1
+ #!/usr/bin/env python3
2
+ """Conservative, closed-policy gate for Saddle V1 business crates."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import tomllib
12
+ from pathlib import Path
13
+
14
+
15
+ ALLOWED_DEPENDENCIES = {"saddle-framework", "serde"}
16
+ ALLOWED_TOP_LEVEL = {"package", "dependencies", "workspace"}
17
+ ALLOWED_PACKAGE_KEYS = {"name", "version", "edition", "rust-version", "publish"}
18
+ FORBIDDEN_MANIFEST_KEYS = {
19
+ "lib", "bin", "example", "test", "bench", "target",
20
+ "build-dependencies", "dev-dependencies",
21
+ }
22
+ SOURCE_RULES = {
23
+ "THREAD": [r"\bstd\s*::\s*thread\b"],
24
+ "RUNTIME": [
25
+ r"\btokio\s*::\s*runtime\b", r"\basync_std\s*::\s*task\s*::\s*block_on\b",
26
+ r"\bsmol\s*::\s*block_on\b",
27
+ ],
28
+ "TASK": [
29
+ r"\btokio\s*::\s*(?:task\s*::\s*)?spawn\b", r"\bspawn_blocking\b",
30
+ r"\basync_std\s*::\s*task\s*::\s*spawn\b", r"\bsmol\s*::\s*spawn\b",
31
+ ],
32
+ "WEB": [
33
+ r"\baxum\s*::", r"\bhyper\s*::", r"\bactix_web\s*::", r"\bwarp\s*::",
34
+ r"\bpoem\s*::", r"\brocket\s*::",
35
+ ],
36
+ "DB": [
37
+ r"\bsqlx\s*::", r"\bdiesel\s*::", r"\bsea_orm\s*::",
38
+ r"\b(?:MySql|Pg|Sqlite)Pool\b",
39
+ ],
40
+ "OBSERVABILITY": [r"\btracing_subscriber\b", r"\blog4rs\b", r"\benv_logger\b"],
41
+ "IO": [
42
+ r"\bstd\s*::\s*fs\b", r"\bstd\s*::\s*process\b",
43
+ r"\bstd\s*::\s*io\s*::\s*(?:stdin|stdout|stderr|Read|Write|BufRead)\b",
44
+ r"\bstd\s*::\s*net\s*::\s*(?:TcpStream|TcpListener|UdpSocket)\b",
45
+ r"\b(?:reqwest|ureq|surf|isahc|curl|tonic|tarpc|lapin|rdkafka|nats|redis)\s*::",
46
+ ],
47
+ }
48
+
49
+
50
+ class Gate:
51
+ def __init__(self, manifest: Path) -> None:
52
+ self.manifest = manifest.resolve()
53
+ self.root = self.manifest.parent
54
+ self.failures = 0
55
+
56
+ def report(self, rule: str, detail: str) -> None:
57
+ print(f"SADDLE_GATE {rule} {detail}", file=sys.stderr)
58
+ self.failures += 1
59
+
60
+ def check_manifest(self, data: dict) -> None:
61
+ for key in data.keys() - ALLOWED_TOP_LEVEL:
62
+ rule = "DEPENDENCY" if key in {"patch", "replace"} else "MANIFEST"
63
+ self.report(rule, f"{self.manifest}: 不允许 Cargo 顶层配置 [{key}]")
64
+ for key in FORBIDDEN_MANIFEST_KEYS & data.keys():
65
+ self.report("TARGET", f"{self.manifest}: 不允许 [{key}] 或自定义/附加 Cargo target")
66
+
67
+ package = data.get("package")
68
+ if not isinstance(package, dict):
69
+ self.report("MANIFEST", f"{self.manifest}: 缺少 [package]")
70
+ else:
71
+ for key in package.keys() - ALLOWED_PACKAGE_KEYS:
72
+ rule = "BUILD" if key in {"build", "links"} else "TARGET"
73
+ self.report(rule, f"{self.manifest}: 不允许 package.{key}")
74
+
75
+ workspace = data.get("workspace")
76
+ if workspace not in ({}, None):
77
+ self.report("MANIFEST", f"{self.manifest}: [workspace] 不得包含配置")
78
+
79
+ dependencies = data.get("dependencies")
80
+ if not isinstance(dependencies, dict):
81
+ self.report("DEPENDENCY", f"{self.manifest}: 缺少 [dependencies]")
82
+ return
83
+
84
+ for name, spec in dependencies.items():
85
+ if name not in ALLOWED_DEPENDENCIES:
86
+ self.report("DEPENDENCY", f"{self.manifest}: 未批准的直接依赖 {name}")
87
+ continue
88
+ if name == "saddle-framework":
89
+ if isinstance(spec, dict) and spec.get("version") == "=0.1.1":
90
+ self.report("DEPENDENCY", f"{self.manifest}: saddle-framework 禁止 path/git/registry/package 等依赖来源")
91
+ elif spec != "=0.1.1":
92
+ self.report("SADDLE_VERSION", f'{self.manifest}: 必须为 saddle-framework = "=0.1.1"')
93
+ elif name == "serde":
94
+ valid = (
95
+ isinstance(spec, dict)
96
+ and spec.get("version") == "1"
97
+ and set(spec) <= {"version", "features"}
98
+ and spec.get("features") == ["derive"]
99
+ )
100
+ if not valid:
101
+ self.report("DEPENDENCY", f'{self.manifest}: serde 仅允许 version="1", features=["derive"]')
102
+
103
+ if "saddle-framework" not in dependencies:
104
+ self.report("SADDLE_VERSION", f'{self.manifest}: 必须直接依赖 saddle-framework = "=0.1.1"')
105
+
106
+ def check_layout(self) -> list[Path]:
107
+ main = self.root / "src" / "main.rs"
108
+ if not main.is_file():
109
+ self.report("ENTRY", f"{main}: 必须使用默认二进制入口 src/main.rs")
110
+
111
+ forbidden = [self.root / "build.rs", self.root / "src" / "lib.rs"]
112
+ forbidden.extend((self.root / "src" / "bin").glob("*.rs") if (self.root / "src" / "bin").is_dir() else [])
113
+ for path in forbidden:
114
+ if path.exists():
115
+ self.report("TARGET", f"{path}: 不允许额外 target 或 build script")
116
+
117
+ if (self.root / ".cargo").exists():
118
+ self.report("DEPENDENCY", f"{self.root / '.cargo'}: 不允许项目级 Cargo source/config 覆盖")
119
+
120
+ source_dir = self.root / "src"
121
+ return sorted(source_dir.rglob("*.rs")) if source_dir.is_dir() else []
122
+
123
+ def check_sources(self, sources: list[Path]) -> None:
124
+ stripped: dict[Path, str] = {}
125
+ for path in sources:
126
+ text = strip_rust_non_code(path.read_text(encoding="utf-8"))
127
+ stripped[path] = text
128
+ self.check_closed_source(path, text)
129
+ for rule, patterns in SOURCE_RULES.items():
130
+ if any(re.search(pattern, text) for pattern in patterns):
131
+ self.report(rule, f"{path}: 命中禁止能力")
132
+
133
+ main_count = sum(len(re.findall(r"\bfn\s+main\s*\(", text)) for text in stripped.values())
134
+ if main_count != 1:
135
+ self.report("ENTRY", f"{self.root / 'src'}: 必须且只能定义一个 fn main,实际为 {main_count}")
136
+
137
+ main_path = self.root / "src" / "main.rs"
138
+ main_text = stripped.get(main_path, "")
139
+ body = function_body(main_text, "main")
140
+ normalized_main = re.sub(r"\s+", "", main_text)
141
+ if "use::saddle::Saddle;" not in normalized_main:
142
+ self.report("ENTRY", f"{main_path}: 必须以 use ::saddle::Saddle 绑定发布 facade")
143
+ saddle_occurrences = sum(
144
+ len(re.findall(r"\bSaddle\b", source_text))
145
+ for source_text in stripped.values()
146
+ )
147
+ if saddle_occurrences != 2:
148
+ self.report("ENTRY", f"{self.root / 'src'}: Saddle 标识只能用于 facade 导入和唯一启动调用")
149
+ if body is not None and re.search(r"\buse\s+", body):
150
+ self.report("ENTRY", f"{main_path}: fn main/块作用域内禁止 use 与名称遮蔽")
151
+ if body is None or len(re.findall(r"\bSaddle\s*::\s*run\s*\(", body)) != 1:
152
+ self.report("ENTRY", f"{main_path}: fn main 函数体必须调用 Saddle::run")
153
+ elif not call_is_tail_expression(body, "Saddle", "run"):
154
+ self.report("ENTRY", f"{main_path}: Saddle::run 必须是 fn main 的唯一尾部启动调用")
155
+
156
+ def check_closed_source(self, path: Path, text: str) -> None:
157
+ if re.search(r"\bmacro_rules\s*!|\bmacro\s+[A-Za-z_]", text):
158
+ self.report("MACRO", f"{path}: 禁止业务定义声明式或过程宏")
159
+ for macro_name in re.findall(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*!\s*[\(\{\[]", text):
160
+ self.report("MACRO", f"{path}: 未批准的宏调用 {macro_name}!")
161
+
162
+ for attribute in re.findall(r"#\s*\[\s*([^\]]+)\]", text, re.S):
163
+ compact = re.sub(r"\s+", "", attribute)
164
+ derive = re.fullmatch(r"derive\(([^)]+)\)", compact)
165
+ names = set(derive.group(1).split(",")) if derive else set()
166
+ if not derive or not names or not names <= {"Deserialize", "Serialize"}:
167
+ self.report("ATTRIBUTE", f"{path}: 仅允许 serde Deserialize/Serialize derive")
168
+
169
+ if re.search(r"\b(?:include|include_str|include_bytes)\s*!", text) or re.search(r"#\s*\[\s*path\s*=", text):
170
+ self.report("SOURCE", f"{path}: 禁止 include!/外部 path 源码注入")
171
+ if re.search(r"\bextern\s+crate\b", text):
172
+ self.report("SOURCE", f"{path}: 禁止 extern crate/别名注入")
173
+ if re.search(r"\bunsafe\b|\bextern\s*\"|\b(?:global_asm|asm)\s*!|#\s*\[\s*(?:link|no_mangle)", text):
174
+ self.report("SOURCE", f"{path}: 禁止 unsafe、FFI、汇编或链接注入")
175
+
176
+ for statement in re.findall(r"\buse\s+[^;]+;", text, re.S):
177
+ compact = re.sub(r"\s+", "", statement)
178
+ if re.search(r"\bas\b", statement):
179
+ self.report("SOURCE", f"{path}: use 不允许 as 别名")
180
+ if compact.startswith("use::std::"):
181
+ if compact != "use::std::net::{IpAddr,Ipv4Addr,SocketAddr};":
182
+ self.report("IO", f"{path}: 未批准的 ::std 导入")
183
+ elif compact.startswith(("use::saddle::", "use::serde::")):
184
+ pass
185
+ elif compact.startswith(("usecrate::", "useself::", "usesuper::")):
186
+ pass
187
+ elif compact.startswith(("usestd::", "usesaddle::", "useserde::")):
188
+ self.report("SOURCE", f"{path}: 外部 crate 必须使用不可遮蔽的绝对路径")
189
+
190
+ allowed_std = re.sub(
191
+ r"use\s*::\s*std\s*::\s*net\s*::\s*\{\s*IpAddr\s*,\s*Ipv4Addr\s*,\s*SocketAddr\s*\}\s*;",
192
+ "",
193
+ text,
194
+ )
195
+ allowed_std = re.sub(r"::\s*std\s*::\s*env\s*::\s*var\b", "", allowed_std)
196
+ if re.search(r"(?<!:)\bstd\s*::|::\s*std\s*::", allowed_std):
197
+ self.report("IO", f"{path}: 仅允许约定的 std 地址类型和部署环境变量读取")
198
+ if re.search(r"(?<!:)\b(?:saddle|serde)\s*::", text):
199
+ self.report("SOURCE", f"{path}: saddle/serde 必须从绝对 external-prelude 路径导入")
200
+ for root in re.findall(r"(?<![:\w])::\s*([A-Za-z_][A-Za-z0-9_]*)\s*::", text):
201
+ if root not in {"saddle", "serde", "std"}:
202
+ self.report("SOURCE", f"{path}: 未批准的绝对 crate 路径 ::{root}")
203
+
204
+ def check_compiler(self) -> None:
205
+ lock = self.root / "Cargo.lock"
206
+ if not lock.is_file():
207
+ self.report("COMPILER", f"{lock}: 缺少锁文件,无法验证真实依赖解析")
208
+ return
209
+ with tempfile.TemporaryDirectory(prefix="saddle-gate-") as target:
210
+ environment = os.environ.copy()
211
+ environment["CARGO_TARGET_DIR"] = target
212
+ result = subprocess.run(
213
+ ["cargo", "check", "--locked", "--quiet", "--manifest-path", str(self.manifest)],
214
+ env=environment,
215
+ capture_output=True,
216
+ text=True,
217
+ check=False,
218
+ )
219
+ if result.returncode:
220
+ detail = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "cargo check 失败"
221
+ self.report("COMPILER", f"{self.manifest}: {detail}")
222
+
223
+
224
+ def strip_rust_non_code(text: str) -> str:
225
+ """Replace comments and literals with spaces while preserving newlines."""
226
+ out = list(text)
227
+ i = 0
228
+ block_depth = 0
229
+ while i < len(text):
230
+ if block_depth:
231
+ if text.startswith("/*", i):
232
+ out[i:i + 2] = " "
233
+ block_depth += 1
234
+ i += 2
235
+ elif text.startswith("*/", i):
236
+ out[i:i + 2] = " "
237
+ block_depth -= 1
238
+ i += 2
239
+ else:
240
+ if text[i] != "\n":
241
+ out[i] = " "
242
+ i += 1
243
+ continue
244
+ if text.startswith("//", i):
245
+ end = text.find("\n", i)
246
+ end = len(text) if end < 0 else end
247
+ out[i:end] = " " * (end - i)
248
+ i = end
249
+ continue
250
+ if text.startswith("/*", i):
251
+ out[i:i + 2] = " "
252
+ block_depth = 1
253
+ i += 2
254
+ continue
255
+ raw = re.match(r"(?:b)?r(#{0,255})\"", text[i:])
256
+ if raw:
257
+ marker = '"' + raw.group(1)
258
+ start = i
259
+ i += raw.end()
260
+ end = text.find(marker, i)
261
+ i = len(text) if end < 0 else end + len(marker)
262
+ for j in range(start, i):
263
+ if text[j] != "\n":
264
+ out[j] = " "
265
+ continue
266
+ char = re.match(r"(?:b)?'(?:\\(?:u\{[0-9A-Fa-f_]+\}|.)|[^\\'\n])'", text[i:])
267
+ if char:
268
+ end = i + char.end()
269
+ out[i:end] = " " * (end - i)
270
+ i = end
271
+ continue
272
+ prefix = 1 if text.startswith('b"', i) else 0
273
+ quote_at = i + prefix
274
+ if quote_at < len(text) and text[quote_at] == '"':
275
+ quote = '"'
276
+ start = i
277
+ i = quote_at + 1
278
+ while i < len(text):
279
+ if text[i] == "\\":
280
+ i += 2
281
+ elif text[i] == quote:
282
+ i += 1
283
+ break
284
+ else:
285
+ i += 1
286
+ for j in range(start, min(i, len(text))):
287
+ if text[j] != "\n":
288
+ out[j] = " "
289
+ continue
290
+ i += 1
291
+ return "".join(out)
292
+
293
+
294
+ def function_body(text: str, name: str) -> str | None:
295
+ match = re.search(rf"\bfn\s+{re.escape(name)}\s*\([^)]*\)[^{{;]*\{{", text, re.S)
296
+ if not match:
297
+ return None
298
+ start = match.end() - 1
299
+ depth = 0
300
+ for index in range(start, len(text)):
301
+ if text[index] == "{":
302
+ depth += 1
303
+ elif text[index] == "}":
304
+ depth -= 1
305
+ if depth == 0:
306
+ return text[start + 1:index]
307
+ return None
308
+
309
+
310
+ def call_is_tail_expression(body: str, type_name: str, method: str) -> bool:
311
+ match = re.search(
312
+ rf"\b{re.escape(type_name)}\s*::\s*{re.escape(method)}\s*\(",
313
+ body,
314
+ )
315
+ if not match:
316
+ return False
317
+ opening = body.find("(", match.start())
318
+ depth = 0
319
+ for index in range(opening, len(body)):
320
+ if body[index] == "(":
321
+ depth += 1
322
+ elif body[index] == ")":
323
+ depth -= 1
324
+ if depth == 0:
325
+ return not body[index + 1:].strip()
326
+ return False
327
+
328
+
329
+ def main() -> int:
330
+ if len(sys.argv) != 2:
331
+ print(f"用法: {sys.argv[0]} <业务 Cargo.toml>", file=sys.stderr)
332
+ return 2
333
+ manifest = Path(sys.argv[1])
334
+ if not manifest.is_file():
335
+ print(f"SADDLE_GATE MANIFEST {manifest}: 文件不存在", file=sys.stderr)
336
+ return 2
337
+ try:
338
+ data = tomllib.loads(manifest.read_text(encoding="utf-8"))
339
+ except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
340
+ print(f"SADDLE_GATE MANIFEST {manifest}: {error}", file=sys.stderr)
341
+ return 2
342
+
343
+ gate = Gate(manifest)
344
+ gate.check_manifest(data)
345
+ gate.check_sources(gate.check_layout())
346
+ if not gate.failures:
347
+ gate.check_compiler()
348
+ if gate.failures:
349
+ print(f"SADDLE_GATE rejected: {gate.failures} violation(s)", file=sys.stderr)
350
+ return 1
351
+ print(f"SADDLE_GATE passed: {gate.manifest}")
352
+ return 0
353
+
354
+
355
+ if __name__ == "__main__":
356
+ raise SystemExit(main())