Skip to content

Commit dc13d15

Browse files
committed
feat: 为 Zig 增加原生 AST 优先的语法候选检查
1 parent 387a1eb commit dc13d15

16 files changed

Lines changed: 534 additions & 2 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
run: |
3434
cargo test -p codeguard-runtime --features wasm-precheck --test process_contract
3535
cargo test -p codeguard-runtime --features wasm-precheck --test wasm_grammar_load --test dart_upstream_corpus
36-
cargo test -p codeguard-cli --features wasm-precheck --test syntax_worker_candidate --test grammar_probe_cli --test typescript_syntax_fallback_candidate --test java_syntax_fallback_candidate
36+
cargo test -p codeguard-cli --features wasm-precheck --test syntax_worker_candidate --test grammar_probe_cli --test zig_lint_cli --test typescript_syntax_fallback_candidate --test java_syntax_fallback_candidate
3737
3838
- name: Run test suite
3939
run: cargo test --workspace --all-targets

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ The current source-built CLI's `codeguard grammar status --format=json` reports
102102

103103
In a source build with `--features wasm-precheck`, `codeguard grammar probe <language> <file> --format=json` can explicitly run any of the 32 pinned candidates in an isolated worker. It always exits 3 with `status=incomplete`, `native.status=not_run`, and `delivery_decision=not_evaluated`; a completed parse also has `precheck.status=incomplete`. Input or worker failures use the same [closed JSON schema](schemas/grammar-probe-v0.1.schema.json). Recovery anchors are suspected observations only. This diagnostic command does not yet connect all grammars to native-first `lint/check` or to the published npm package.
104104

105+
The source-built Unix CLI also has a narrow Zig route: `codeguard lint zig FILE --zig-tool /absolute/path/to/zig --format=json`. An explicitly supplied tool reporting Zig 0.16.0 and retaining the same byte digest runs native `ast-check` first; its source positions are retained without exposing source snippets. Without an explicit tool, the pinned Zig WASM provides an unqualified observation. Both paths remain incomplete because `ast-check` covers only local AST errors, not full lint, build or tests. See the [Zig feedback schema](schemas/zig-lint-feedback-v0.1.schema.json).
106+
105107
On Apple Silicon macOS, the published `0.1.2` package was verified with a fresh npm cache:
106108

107109
```bash

‎README.zh-CN.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ cargo build --locked -p codeguard-cli
102102

103103
源码以 `--features wasm-precheck` 构建后,可显式运行 `codeguard grammar probe <language> <file> --format=json`,由隔离 worker 调用 32 份固定候选中的任意一份。命令始终以退出码 3 返回,并标记 `status=incomplete`、`native.status=not_run`、`delivery_decision=not_evaluated`;解析完成时还标记 `precheck.status=incomplete`。输入或 worker 失败也遵守同一份[封闭 JSON Schema](schemas/grammar-probe-v0.1.schema.json)。恢复锚点只是疑似观察。此诊断入口尚未把全部语种接入原生优先的 `lint/check`,也尚未进入已发布 npm 包。
104104

105+
源码构建的 Unix CLI 还提供局部 Zig 入口:`codeguard lint zig FILE --zig-tool /absolute/path/to/zig --format=json`。显式提供且报告 Zig 0.16.0、执行前后字节摘要一致的工具优先运行原生 `ast-check`,仅输出诊断位置,不回显源码;未显式提供工具时,固定 Zig WASM 给出未验收候选观察。两条路径都保持未完成,因为 `ast-check` 仅覆盖局部 AST 错误,不等于完整 lint、构建或测试。见 [Zig 报告 Schema](schemas/zig-lint-feedback-v0.1.schema.json)。
106+
105107
已在 Apple Silicon macOS 上通过全新 npm 缓存验证公开 `0.1.2` 包:
106108

107109
```bash

‎crates/codeguard-cli/src/grammar_probe_command.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ fn emit_incomplete(language: &str, source_path: &str, reason: &str) -> ExitCode
9797
ExitCode::from(3)
9898
}
9999

100-
fn read_plain_source(path: &Path) -> Result<Vec<u8>, &'static str> {
100+
pub(crate) fn read_plain_source(path: &Path) -> Result<Vec<u8>, &'static str> {
101101
if path
102102
.components()
103103
.any(|component| matches!(component, Component::ParentDir))

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ mod whitelist_correction_projection;
199199
pub mod work_sync;
200200
pub mod workspace_refresh;
201201
pub mod workspace_view_command;
202+
#[cfg(all(feature = "wasm-precheck", unix))]
203+
pub mod zig_lint_command;
202204

203205
mod cargo_module_graph;
204206
mod module_graph;

‎crates/codeguard-cli/src/main.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ fn main() -> ExitCode {
7070
println!(
7171
"显式候选语法观察:grammar probe <language> <file> [--format=json];隔离加载 32 份固定资产之一,始终返回未完成和退出码 3,不代替原生 lint。"
7272
);
73+
#[cfg(all(feature = "wasm-precheck", unix))]
74+
println!(
75+
"Zig 原生优先局部检查:lint zig FILE [--zig-tool ABS_PATH] [--format=json];显式 Zig 0.16.0 使用 ast-check,未提供工具时使用未验收 WASM 候选;始终不签发完整 lint 或交付通过。"
76+
);
7377
println!(
7478
"检查预算:check all 支持 --jobs 1–64 和 CODEGUARD_JOBS;--timeout 优先于 CODEGUARD_TIMEOUT,默认 30m。项目默认值见 .codeguard/runtime.json 1.1;当前仅原生执行受截止时间约束。"
7579
);
@@ -163,6 +167,17 @@ fn main() -> ExitCode {
163167
[command, rest @ ..] if command == "lint" => {
164168
if rest.first().is_some_and(|language| language == "java") {
165169
codeguard_cli::java_lint_dispatch::run(&rest[1..])
170+
} else if cfg!(feature = "wasm-precheck")
171+
&& rest.first().is_some_and(|language| language == "zig")
172+
{
173+
#[cfg(feature = "wasm-precheck")]
174+
{
175+
codeguard_cli::zig_lint_command::run(&rest[1..])
176+
}
177+
#[cfg(not(feature = "wasm-precheck"))]
178+
{
179+
ExitCode::from(2)
180+
}
166181
} else if rest
167182
.first()
168183
.is_some_and(|language| language == "typescript")
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
//! Zig 0.16 单文件原生 AST 检查优先;缺少可核对工具时才作候选 WASM 初检。
2+
3+
use crate::grammar_probe_command::read_plain_source;
4+
use crate::syntax_worker_runner::run_syntax_worker_candidate;
5+
use codeguard_runtime::{ProcessSpec, Termination, read_bounded_regular_file, run_process};
6+
use serde_json::{Value, json};
7+
use sha2::{Digest, Sha256};
8+
use std::collections::BTreeMap;
9+
use std::ffi::OsString;
10+
use std::path::{Path, PathBuf};
11+
use std::process::ExitCode;
12+
use std::sync::atomic::AtomicBool;
13+
use std::time::{Duration, Instant};
14+
15+
struct Args {
16+
source: PathBuf,
17+
zig_tool: Option<PathBuf>,
18+
json: bool,
19+
}
20+
21+
/// 执行单文件 Zig 原生语法检查或未验收 WASM 初检,不签发完整 lint/交付通过。
22+
/// 参数为源码文件、可选受控 Zig 二进制路径和输出格式;退出码 3 表示仍需完整验收。
23+
pub fn run(args: &[String]) -> ExitCode {
24+
let args = match parse_args(args) {
25+
Ok(args) => args,
26+
Err(reason) => {
27+
eprintln!("{reason}");
28+
return ExitCode::from(2);
29+
}
30+
};
31+
let deadline = Instant::now() + Duration::from_secs(30);
32+
let mut report = json!({
33+
"schema_version":"0.1.0", "report_type":"zig_lint_feedback",
34+
"operation":"lint", "language":"zig", "path":args.source,
35+
"status":"incomplete", "coverage_proven":false,
36+
"delivery_decision":"not_evaluated", "authority":"local_unverified",
37+
"source_sha256":null,
38+
"native":{"status":"not_run","reason":"explicit_zig_tool_not_provided","version":null,"tool_sha256":null,"diagnostics":[]},
39+
"syntax_precheck":null,
40+
"next_action":"提供适用的 Zig 0.16.0 原生工具并运行 ast-check;候选语法初检不能代替完整 lint、编译或测试"
41+
});
42+
if args
43+
.source
44+
.extension()
45+
.is_none_or(|extension| extension != "zig")
46+
{
47+
report["native"]["reason"] = json!("zig_source_extension_required");
48+
emit(&report, args.json);
49+
return ExitCode::from(3);
50+
}
51+
let source = match read_plain_source(&args.source) {
52+
Ok(source) => source,
53+
Err(reason) => {
54+
report["native"]["reason"] = json!(reason);
55+
emit(&report, args.json);
56+
return ExitCode::from(3);
57+
}
58+
};
59+
report["source_sha256"] = json!(format!("{:x}", Sha256::digest(&source)));
60+
if let Some(tool) = args.zig_tool.as_deref() {
61+
if let Some(native) = observe_native(tool, &source, deadline) {
62+
let completed = matches!(
63+
native["status"].as_str(),
64+
Some("completed" | "diagnostics_observed")
65+
);
66+
report["native"] = native;
67+
if completed {
68+
report["next_action"] =
69+
json!(if report["native"]["status"] == "diagnostics_observed" {
70+
"核对原生 Zig AST 诊断的源码位置并修复;仍需执行项目完整 lint、构建和测试"
71+
} else {
72+
"Zig ast-check 未发现局部语法错误;仍需执行项目完整 lint、构建和测试"
73+
});
74+
emit(&report, args.json);
75+
return ExitCode::from(3);
76+
}
77+
} else {
78+
report["native"]["reason"] = json!("zig_tool_unavailable_or_untrusted");
79+
}
80+
}
81+
let name = args
82+
.source
83+
.file_name()
84+
.and_then(|name| name.to_str())
85+
.unwrap_or("source.zig");
86+
match std::env::current_exe() {
87+
Ok(executable) => match run_syntax_worker_candidate(
88+
&executable,
89+
"zig",
90+
name,
91+
&source,
92+
deadline,
93+
&AtomicBool::new(false),
94+
) {
95+
Ok(observation) => {
96+
report["syntax_precheck"] = json!({
97+
"status":"incomplete",
98+
"grammar_qualified":false,
99+
"grammar_sha256":observation.grammar_sha256,
100+
"precheck":observation.precheck,
101+
"recoveries":observation.recoveries
102+
});
103+
report["next_action"] = json!(if report["syntax_precheck"]["recoveries"]
104+
.as_array()
105+
.is_some_and(|items| !items.is_empty())
106+
{
107+
"先用匹配 Zig 版本的原生语法检查确认疑似位置;不要仅凭 WASM 修改源码或关闭任务"
108+
} else {
109+
"候选语法初检未见恢复节点;仍需配置并运行原生 Zig 检查"
110+
});
111+
}
112+
Err(reason) => {
113+
report["syntax_precheck"] = json!({"status":"incomplete","reason":reason});
114+
}
115+
},
116+
Err(_) => {
117+
report["syntax_precheck"] =
118+
json!({"status":"incomplete","reason":"worker_executable_unavailable"});
119+
}
120+
}
121+
emit(&report, args.json);
122+
ExitCode::from(3)
123+
}
124+
125+
fn observe_native(tool: &Path, source: &[u8], deadline: Instant) -> Option<Value> {
126+
if !tool.is_absolute() {
127+
return None;
128+
}
129+
let executable = tool.canonicalize().ok()?;
130+
if !executable.is_file() {
131+
return None;
132+
}
133+
let tool_sha256 = format!(
134+
"{:x}",
135+
Sha256::digest(read_bounded_regular_file(&executable, 64 * 1024 * 1024).ok()?)
136+
);
137+
let cancelled = AtomicBool::new(false);
138+
let version = run_process(
139+
&ProcessSpec {
140+
executable: executable.clone(),
141+
args: vec![OsString::from("version")],
142+
cwd: std::env::current_dir().ok()?,
143+
env: BTreeMap::new(),
144+
stdin: None,
145+
deadline,
146+
output_limit_bytes: 1024,
147+
},
148+
&cancelled,
149+
);
150+
if version.termination != Termination::Exited(0)
151+
|| std::str::from_utf8(&version.stdout).ok()?.trim() != "0.16.0"
152+
{
153+
return Some(
154+
json!({"status":"incomplete","reason":"zig_version_unverified_or_unsupported","version":null,"tool_sha256":tool_sha256,"diagnostics":[]}),
155+
);
156+
}
157+
let outcome = run_process(
158+
&ProcessSpec {
159+
executable: executable.clone(),
160+
args: vec![
161+
OsString::from("ast-check"),
162+
OsString::from("--color"),
163+
OsString::from("off"),
164+
],
165+
cwd: std::env::current_dir().ok()?,
166+
env: BTreeMap::new(),
167+
stdin: Some(source.to_vec()),
168+
deadline,
169+
output_limit_bytes: 64 * 1024,
170+
},
171+
&cancelled,
172+
);
173+
let current_tool_sha256 = read_bounded_regular_file(&executable, 64 * 1024 * 1024)
174+
.ok()
175+
.map(|bytes| format!("{:x}", Sha256::digest(bytes)));
176+
if current_tool_sha256.as_deref() != Some(tool_sha256.as_str()) {
177+
return Some(
178+
json!({"status":"incomplete","reason":"zig_tool_changed_during_check","version":"0.16.0","tool_sha256":tool_sha256,"diagnostics":[]}),
179+
);
180+
}
181+
let diagnostics = parse_diagnostic_positions(&outcome.stderr);
182+
Some(json!({
183+
"status":match outcome.termination {
184+
Termination::Exited(0) if outcome.stderr.is_empty() => "completed",
185+
Termination::Exited(1) if !diagnostics.is_empty() => "diagnostics_observed",
186+
_ => "incomplete",
187+
},
188+
"reason":match outcome.termination {
189+
Termination::Exited(0) if outcome.stderr.is_empty() => "ast_check_no_diagnostics",
190+
Termination::Exited(1) if !diagnostics.is_empty() => "ast_check_diagnostics",
191+
_ => "zig_ast_check_incomplete",
192+
},
193+
"version":"0.16.0",
194+
"tool_sha256":tool_sha256,
195+
"diagnostic_count":diagnostics.len(),
196+
"diagnostics":diagnostics
197+
}))
198+
}
199+
200+
fn parse_diagnostic_positions(raw: &[u8]) -> Vec<Value> {
201+
let Ok(text) = std::str::from_utf8(raw) else {
202+
return Vec::new();
203+
};
204+
text.lines()
205+
.filter_map(|line| {
206+
let rest = line.strip_prefix("<stdin>:")?;
207+
let (row, rest) = rest.split_once(':')?;
208+
let (column, rest) = rest.split_once(':')?;
209+
if !rest.trim_start().starts_with("error:") {
210+
return None;
211+
}
212+
let row = row.parse::<u32>().ok()?;
213+
let column = column.parse::<u32>().ok()?;
214+
if row == 0 || column == 0 {
215+
return None;
216+
}
217+
Some(json!({"line":row,"column":column,"rule_id":"zig.ast_check.error"}))
218+
})
219+
.take(32)
220+
.collect()
221+
}
222+
223+
fn parse_args(args: &[String]) -> Result<Args, &'static str> {
224+
let mut source = None;
225+
let mut zig_tool = None;
226+
let mut json = false;
227+
let mut index = 0;
228+
while index < args.len() {
229+
match args[index].as_str() {
230+
"--zig-tool" => {
231+
index += 1;
232+
let value = args.get(index).ok_or("--zig-tool 缺少路径")?;
233+
if zig_tool.replace(PathBuf::from(value)).is_some() {
234+
return Err("--zig-tool 重复");
235+
}
236+
}
237+
"--format=json" => json = true,
238+
"--format=human" => json = false,
239+
value if !value.starts_with('-') && source.is_none() => {
240+
source = Some(PathBuf::from(value))
241+
}
242+
_ => return Err("lint zig 参数无效"),
243+
}
244+
index += 1;
245+
}
246+
Ok(Args {
247+
source: source.ok_or("lint zig 缺少源码文件")?,
248+
zig_tool,
249+
json,
250+
})
251+
}
252+
253+
fn emit(report: &Value, json_format: bool) {
254+
if json_format {
255+
println!("{report}");
256+
} else {
257+
println!(
258+
"Zig 局部检查未完成:原生 {},候选语法 {};{}",
259+
report["native"]["status"].as_str().unwrap_or("incomplete"),
260+
report["syntax_precheck"]["status"]
261+
.as_str()
262+
.unwrap_or("未运行"),
263+
report["next_action"]
264+
.as_str()
265+
.unwrap_or("核对原生 Zig 结果")
266+
);
267+
for diagnostic in report["native"]["diagnostics"]
268+
.as_array()
269+
.into_iter()
270+
.flatten()
271+
{
272+
println!(
273+
" 原生 AST 错误:{}:{}",
274+
diagnostic["line"], diagnostic["column"]
275+
);
276+
}
277+
}
278+
}

0 commit comments

Comments
 (0)