Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ Generic `dependencies`, generic `security`, arbitrary category/language combinat

An opt-in source build with `--features codeguard-cli/wasm-precheck` has narrow TypeScript and Java single-file candidate paths. For TypeScript, a visible local ESLint 10 entry and one flat config take priority: when an executable Node is resolved from `PATH`, the CLI runs its existing bounded native probe; without Node it reports the setup gap. Only when the project-local ESLint package is not observed can the bundled TypeScript/TSX candidate provide suspected syntax positions; an untrusted entry, package identity or config returns a specific setup blocker. The Java candidate likewise remains incomplete; no bundled grammar has an accepted language/dialect range. Native observations and prechecks never approve delivery. Default and published binaries keep their previous native-context behavior. [TypeScript acceptance](tests/acceptance/typescript-syntax-fallback-candidate.md) · [native-first acceptance](tests/acceptance/native-first-eslint-candidate.md) · [Java acceptance](tests/acceptance/java-syntax-fallback-candidate.md).

With an initialized `--workspace`, that TypeScript/TSX fallback now returns a real, stable native-confirmation task ID after sync; repeated WASM scans never close it. The individual suspected positions and a capability-matched native closure still need implementation. [Workbench acceptance](tests/acceptance/typescript-syntax-confirmation-task.md).
With an initialized `--workspace`, that TypeScript/TSX fallback now returns a real, stable native-confirmation task ID after sync; repeated WASM scans never close it. A versioned local report now preserves bounded suspected positions with source and grammar identities; capability-matched native closure still needs implementation. [Workbench acceptance](tests/acceptance/typescript-syntax-confirmation-task.md).

```bash
codeguard lint java .
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ Python 编辑快反馈可执行 `codeguard lint python . --file src/changed.py -

可选源码构建已有 Java/TypeScript 单文件候选路径。TypeScript 项目若有可见的本地 ESLint 10 入口和唯一 flat config,会先尝试从 `PATH` 解析 Node 并运行既有有界原生探针;缺 Node 时报告准备缺口;仅本地 ESLint 包未观察到时才提供 TypeScript/TSX 疑似语法初检。入口、包身份或配置不可信时保留具体环境阻塞。Java 候选仍须原生确认,所有内置 grammar 的版本/方言尚未验收,原生局部观察和初检都不能批准交付。默认及公开二进制沿用既有原生上下文路径,见 [Java 局部验收](tests/acceptance/java-syntax-fallback-candidate.md)、[TypeScript 局部验收](tests/acceptance/typescript-syntax-fallback-candidate.md)与[原生优先局部验收](tests/acceptance/native-first-eslint-candidate.md)。

已初始化工作区使用 `--workspace` 时,TypeScript/TSX 候选回退在同步后返回真实且稳定的原生确认任务 ID;反复 WASM 扫描不会自行关闭任务。逐个疑似位置的持久关联和能力匹配的原生关闭仍待实现,见[局部验收](tests/acceptance/typescript-syntax-confirmation-task.md)。
已初始化工作区使用 `--workspace` 时,TypeScript/TSX 候选回退在同步后返回真实且稳定的原生确认任务 ID;反复 WASM 扫描不会自行关闭任务。版本化本地报告已按源码与 grammar 身份保存有界疑似位置;能力匹配的原生关闭仍待实现,见[局部验收](tests/acceptance/typescript-syntax-confirmation-task.md)。

```bash
codeguard lint java .
Expand Down
7 changes: 7 additions & 0 deletions crates/codeguard-cli/src/eslint_lint_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ fn connect_syntax_confirmation(args: &EslintLintArguments, report: &mut Value) {
"eslint_syntax_precheck_unavailable"
};
report["workbench"] = match workspace.canonicalize() {
Ok(root) if root == *workspace && reason == "eslint_syntax_confirmation_needed" => {
crate::eslint_workbench::connect_syntax_preparation(
&root,
args,
&report["syntax_precheck"],
)
}
Ok(root) if root == *workspace => {
crate::eslint_workbench::connect_preparation(&root, args, reason)
}
Expand Down
64 changes: 55 additions & 9 deletions crates/codeguard-cli/src/eslint_preparation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ pub(crate) fn prepare(
"affected_paths":[relative],"source_sha256":format!("{:x}",Sha256::digest(bytes))}),
)
}
#[cfg(unix)]
pub(crate) fn prepare_with_syntax(
root: &Path,
args: &EslintLintArguments,
precheck: &Value,
) -> Result<Value, &'static str> {
let mut report = prepare(root, args, "eslint_syntax_confirmation_needed")?;
let source = read_bounded_regular_file(&args.source, 16 * 1024 * 1024)
.map_err(|_| "eslint_source_unavailable")?;
if report["source_sha256"] != format!("{:x}", Sha256::digest(&source)) {
return Err("syntax_evidence_source_changed");
}
let scope = report["scope"]
.as_str()
.ok_or("preparation_scope_invalid")?;
let evidence = crate::eslint_syntax_evidence::project(precheck, scope, &source)?;
report["schema_version"] = json!("0.2.0");
report["syntax_evidence"] = evidence;
Ok(report)
}
pub(crate) fn fingerprint(workspace: &str, scope: &str) -> String {
let mut hash = Sha256::new();
for part in [
Expand Down Expand Up @@ -104,10 +124,20 @@ pub(crate) fn valid_report(report: &Value, workspace: &str) -> bool {
return false;
};
let fp = fingerprint(workspace, scope);
report
.as_object()
.is_some_and(|m| m.len() == keys.len() && keys.iter().all(|k| m.contains_key(*k)))
&& report["schema_version"] == "0.1.0"
let version_valid = match report["schema_version"].as_str() {
Some("0.1.0") => report.as_object().is_some_and(|m| m.len() == keys.len()),
Some("0.2.0") => {
report
.as_object()
.is_some_and(|m| m.len() == keys.len() + 1 && m.contains_key("syntax_evidence"))
&& report["diagnostic_reason"] == "eslint_syntax_confirmation_needed"
}
_ => false,
};
version_valid
&& report
.as_object()
.is_some_and(|m| keys.iter().all(|k| m.contains_key(*k)))
&& report["report_type"] == "eslint_preparation_observation"
&& report["workspace_binding"] == "bound"
&& report["workspace_id"] == workspace
Expand Down Expand Up @@ -296,10 +326,19 @@ pub(crate) fn guidance(root: &Path, fact: &Value) -> Value {
return fallback;
}
let source = root.join(scope);
if source.canonicalize().ok().as_deref() != Some(source.as_path())
|| read_bounded_regular_file(&source, 16 * 1024 * 1024)
.ok()
.is_none_or(|b| report["source_sha256"] != format!("{:x}", Sha256::digest(b)))
if source.canonicalize().ok().as_deref() != Some(source.as_path()) {
return fallback;
}
let Ok(source_bytes) = read_bounded_regular_file(&source, 16 * 1024 * 1024) else {
return fallback;
};
if report["source_sha256"] != format!("{:x}", Sha256::digest(&source_bytes))
|| (report["schema_version"] == "0.2.0"
&& !crate::eslint_syntax_evidence::valid(
&report["syntax_evidence"],
scope,
&source_bytes,
))
{
return fallback;
}
Expand Down Expand Up @@ -333,5 +372,12 @@ pub(crate) fn guidance(root: &Path, fact: &Value) -> Value {
"核对最新ESLint前置诊断、原配置、parser/插件和运行条件;解析或抑制先复现根因,恢复原检查后复扫,不修改无关源码"
}
};
json!({"disposition":"actionable","step":step,"diagnostic_reason":report["diagnostic_reason"],"run_id":run,"report_sha256":sha})
let mut guidance = json!({"disposition":"actionable","step":step,"diagnostic_reason":report["diagnostic_reason"],"run_id":run,"report_sha256":sha});
if report["schema_version"] == "0.2.0" {
guidance["suspected_positions"] = report["syntax_evidence"]["observations"].clone();
guidance["source_sha256"] = report["source_sha256"].clone();
guidance["grammar_sha256"] = report["syntax_evidence"]["grammar_sha256"].clone();
guidance["evidence_authority"] = json!("local_unverified_suspected");
}
guidance
}
145 changes: 145 additions & 0 deletions crates/codeguard-cli/src/eslint_syntax_evidence.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//! ESLint 原生确认任务中的脱敏 WASM 疑似位置;只具待核实证据权威。

use codeguard_adapters::bundled_grammar_candidates;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};

/// 从当轮候选反馈提取有界坐标,不保存源码片段或语法节点文本。
/// 参数为初检反馈、工作区相对路径和当前源码字节;返回绑定固定 grammar 的疑似证据。
pub(crate) fn project(precheck: &Value, scope: &str, source: &[u8]) -> Result<Value, &'static str> {
let observations = precheck["observations"]
.as_array()
.filter(|rows| rows.len() <= 128)
.ok_or("syntax_evidence_observations_invalid")?;
let positions: Vec<Value> = observations
.iter()
.map(|row| {
json!({
"classification":row["classification"],
"kind":row["kind"],
"start_line":row["start_line"],
"start_column":row["start_column"],
"end_line":row["end_line"],
"end_column":row["end_column"],
"group_id":row["group_id"]
})
})
.collect();
let evidence = json!({
"schema_version":"1.0.0",
"backend":"bundled_tree_sitter_wasm_candidate",
"language":precheck["language"],
"scope":scope,
"source_sha256":precheck["source_sha256"],
"grammar_sha256":precheck["grammar_sha256"],
"grammar_qualified":precheck["grammar_qualified"],
"precheck_reason":precheck["reason"],
"truncated":precheck["truncated"],
"observations":positions
});
if !valid(&evidence, scope, source) {
return Err("syntax_evidence_identity_or_position_invalid");
}
Ok(evidence)
}

/// 核对当前源码、固定 grammar 身份和每个 Unicode 标量坐标。
/// 参数为不可信报告投影、工作区相对路径及当前源码;返回能否作为未核实任务证据。
pub(crate) fn valid(evidence: &Value, scope: &str, source: &[u8]) -> bool {
let keys = [
"schema_version",
"backend",
"language",
"scope",
"source_sha256",
"grammar_sha256",
"grammar_qualified",
"precheck_reason",
"truncated",
"observations",
];
let Some(rows) = evidence["observations"]
.as_array()
.filter(|rows| rows.len() <= 128)
else {
return false;
};
let Some(language) = evidence["language"].as_str() else {
return false;
};
if !evidence.as_object().is_some_and(|object| {
object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key))
}) || evidence["schema_version"] != "1.0.0"
|| evidence["backend"] != "bundled_tree_sitter_wasm_candidate"
|| evidence["scope"] != scope
|| evidence["source_sha256"] != format!("{:x}", Sha256::digest(source))
|| evidence["grammar_qualified"] != false
|| !matches_language(scope, language)
|| !evidence["truncated"].is_boolean()
|| (evidence["truncated"] == true
&& evidence["precheck_reason"] != "recovery_budget_truncated")
|| (evidence["truncated"] == false
&& evidence["precheck_reason"] != "grammar_version_unqualified")
{
return false;
}
let Ok(manifest) = bundled_grammar_candidates() else {
return false;
};
if !manifest.assets.iter().any(|asset| {
asset.language == language
&& asset.sha256 == evidence["grammar_sha256"]
&& asset.release_status == "candidate_unvalidated"
}) {
return false;
}
let Ok(text) = std::str::from_utf8(source) else {
return false;
};
let lines: Vec<&str> = text.split('\n').collect();
rows.iter().all(|row| valid_position(row, &lines))
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Verify persisted positions against the parser output

For an imported 0.2.0 report, the source and grammar hashes are checked, but each observation is accepted solely because its coordinates are in bounds. A report can therefore retain the genuine hashes, change an observation to any other in-bounds line and column, and pass work sync; next then presents that fabricated location as suspected Tree-sitter evidence. Recompute the recovery nodes with the identified grammar, or otherwise verify a worker-derived binding for the observation set, rather than treating coordinate bounds as provenance.

Useful? React with 👍 / 👎.

}

fn matches_language(scope: &str, language: &str) -> bool {
match language {
"typescript" => [".ts", ".mts", ".cts"]
.iter()
.any(|suffix| scope.ends_with(suffix)),
"tsx" => scope.ends_with(".tsx"),
_ => false,
}
}

fn valid_position(row: &Value, lines: &[&str]) -> bool {
let keys = [
"classification",
"kind",
"start_line",
"start_column",
"end_line",
"end_column",
"group_id",
];
let (Some(start_line), Some(start_column), Some(end_line), Some(end_column), Some(group_id)) = (
row["start_line"].as_u64(),
row["start_column"].as_u64(),
row["end_line"].as_u64(),
row["end_column"].as_u64(),
row["group_id"].as_u64(),
) else {
return false;
};
row.as_object().is_some_and(|object| {
object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key))
}) && row["classification"] == "suspected"
&& matches!(row["kind"].as_str(), Some("ERROR" | "MISSING"))
&& group_id > 0
&& start_line > 0
&& end_line >= start_line
&& end_line <= lines.len() as u64
&& start_column > 0
&& end_column > 0
&& start_column <= lines[start_line as usize - 1].chars().count() as u64 + 1
&& end_column <= lines[end_line as usize - 1].chars().count() as u64 + 1
&& (start_line, start_column) <= (end_line, end_column)
}
53 changes: 32 additions & 21 deletions crates/codeguard-cli/src/eslint_workbench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,41 @@ pub(crate) fn connect(
}
pub(crate) fn connect_preparation(root: &Path, args: &EslintLintArguments, reason: &str) -> Value {
match crate::eslint_preparation::prepare(root, args, reason) {
Ok(report) => {
let id = report["blocker_id"].as_str().unwrap_or("").to_owned();
let mut result = persist(root, report, "node.eslint.preparation");
if matches!(
reason,
"eslint_syntax_confirmation_needed" | "eslint_syntax_precheck_unavailable"
) && result["status"] == "synced_partial"
{
if let Ok(brief) = crate::next_command::read_task_brief(root, &id) {
result["task_id"] = json!(id);
result["next"] = json!({
"schema_version":"0.1.0","report_type":"repair_brief_preview",
"operation":"next","command_status":"complete","exit_code":0,
"disposition":brief["disposition"],"reason":"current_source_confirmation_task",
"repair_brief":brief,"next_actions":[],
"authority":"local_unverified","delivery_decision":"not_evaluated"
});
}
}
result
}
Ok(report) => persist_preparation(root, report, reason),
Err(reason) => json!({"status":reason}),
}
}
pub(crate) fn connect_syntax_preparation(
root: &Path,
args: &EslintLintArguments,
precheck: &Value,
) -> Value {
match crate::eslint_preparation::prepare_with_syntax(root, args, precheck) {
Ok(report) => persist_preparation(root, report, "eslint_syntax_confirmation_needed"),
Err(reason) => json!({"status":reason}),
}
}
fn persist_preparation(root: &Path, report: Value, reason: &str) -> Value {
let id = report["blocker_id"].as_str().unwrap_or("").to_owned();
let mut result = persist(root, report, "node.eslint.preparation");
if matches!(
reason,
"eslint_syntax_confirmation_needed" | "eslint_syntax_precheck_unavailable"
) && result["status"] == "synced_partial"
{
if let Ok(brief) = crate::next_command::read_task_brief(root, &id) {
result["task_id"] = json!(id);
result["next"] = json!({
"schema_version":"0.1.0","report_type":"repair_brief_preview",
"operation":"next","command_status":"complete","exit_code":0,
"disposition":brief["disposition"],"reason":"current_source_confirmation_task",
"repair_brief":brief,"next_actions":[],
"authority":"local_unverified","delivery_decision":"not_evaluated"
});
}
}
result
}
fn persist(root: &Path, report: Value, checker: &str) -> Value {
if let Err(reason) = save_local_report(root, &report) {
return json!({"status":reason});
Expand Down
1 change: 1 addition & 0 deletions crates/codeguard-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub mod eslint_probe_request;
pub mod eslint_probe_result;
#[cfg(unix)]
mod eslint_project_context;
mod eslint_syntax_evidence;
mod eslint_task_recheck;
#[cfg(unix)]
mod eslint_workbench;
Expand Down
12 changes: 12 additions & 0 deletions crates/codeguard-cli/src/work_sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,10 @@ fn import_one(
codeguard_adapters::parse_unique_json(&bytes)
.map_err(|_| "npm_report_duplicate_or_invalid_json")?;
}
if value["report_type"] == "eslint_preparation_observation" {
codeguard_adapters::parse_unique_json(&bytes)
.map_err(|_| "eslint_preparation_duplicate_or_invalid_json")?;
}
// 已消费的历史报告按原字节收据确认,不用当前源码重演历史输入。
if matches!(
value["report_type"].as_str(),
Expand Down Expand Up @@ -2030,6 +2034,14 @@ fn render_blocker_task(report: &ReportInput, blocker: &BlockerInput) -> String {
blocker.id, blocker.reason, report.digest, report.run_id
);
}
if blocker.checker_id == "node.eslint.preparation"
&& blocker.diagnostic_reason.as_deref() == Some("eslint_syntax_confirmation_needed")
{
return format!(
"# {} TypeScript 语法原生确认任务\n\n- 问题证据:源码范围 `{}` 的候选 WASM 初检尚未验收;脱敏疑似位置、源码及 grammar 摘要在 `.codeguard/reports/{}.json` 的 `syntax_evidence`,报告 SHA-256 `{}`。这些不是已确认源码违规。\n- 规则依据:Tree-sitter 恢复节点只提示疑似语法位置;原生 TypeScript/ESLint 能力和项目原配置仍须确认。\n- 允许范围:核对本源码、对应构建根、原生工具及配置;不得仅凭 WASM 恢复节点修改源码、增加忽略或白名单。\n- 修复步骤:先查看同 run 的脱敏位置,再恢复适用的原生语法检查;若原生反证,保留证据并进入 grammar 误报调查。\n- 复检命令:codeguard lint typescript <原源码> --workspace <原工作区> --node-tool <核验绝对路径> --eslint-entry <核验绝对路径> --eslint-version <核验版本> --config <原配置绝对路径> --cwd <原工作目录绝对路径>。\n- 历史尝试:首次 run `{}`;后续观察、尝试和复检保留在同一任务,首张 Markdown 不是完整历史。\n- 关闭条件:同输入、范围、方言及语法能力的原生复检和正式策略/覆盖核验均满足;安装、WASM 零恢复或任务勾选不能关闭。\n",
Comment on lines +2037 to +2041

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh existing blockers with syntax evidence

When this source-scoped blocker already exists—for example after an earlier eslint_syntax_precheck_unavailable scan or an upgrade from the previous report format—persist_blocker never calls render_blocker_task because it writes task Markdown only when first is true. A subsequent successful 0.2.0 scan reuses the same fingerprint, so this new branch is skipped and the task permanently retains the generic text without the promised report path or digest, even though current syntax evidence is now available. Update or migrate an existing task when it first receives syntax-confirmation evidence.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape the source scope before rendering Markdown

When a TypeScript filename contains Markdown or HTML metacharacters such as a backtick, safe_path accepts it and the raw workspace-relative scope is interpolated directly into this generated task. Such a filename can terminate the code span and inject attacker-controlled links or instruction-like text into the task that a user or agent is expected to inspect. Encode or escape the scope before placing it in Markdown, as the other task renderers do for source paths.

Useful? React with 👍 / 👎.

blocker.id, blocker.scope, report.run_id, report.digest, report.run_id
);
}
if blocker.checker_id == "node.eslint.preparation" {
return format!(
"# {} ESLint 检查完整性任务\n\n- 问题证据:诊断 `{}`,报告摘要 `{}`。\n- 规则依据:原工具、配置和报告检查完整性;不是已确认源码违规。\n- 允许范围:核对Node、ESLint入口/版本、原配置、parser/插件及报告归属;不修改无关源码。\n- 修复步骤:恢复缺失上下文;若为解析或抑制诊断,先复现并定位根因,不默认添加忽略或白名单。\n- 复检:codeguard lint typescript <原源码> --workspace <原工作区> --node-tool <核验绝对路径> --eslint-entry <核验绝对路径> --eslint-version <核验版本> --config <原配置绝对路径> --cwd <原工作目录绝对路径>。\n- 历史尝试:首次run `{}`,后续诊断保留在同一任务观察中。\n- 关闭条件:本来受阻的原检查恢复并完成正式复检;任务勾选和局部成功不能关闭。\n",
Expand Down
Loading
Loading