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 @@ -181,7 +181,7 @@ Generic `dependencies`, generic `security`, arbitrary category/language combinat

**The published `0.1.2` does not yet implement bundled WASM fallback or automatic conversation delivery.** The existing commands remain the unified entry points; native adapter-specific prerequisites still apply today:

An opt-in source build with `--features codeguard-cli/wasm-precheck` now has narrow TypeScript and Java single-file candidate paths: without explicit native context, they return versioned [TypeScript](schemas/eslint-local-feedback-v0.3.schema.json) or [Java](schemas/java-syntax-precheck-feedback-v0.1.schema.json) feedback with suspected parser recovery positions and native `not_run`. Both remain incomplete because the bundled grammar has no accepted language/dialect range. Default and published binaries keep their previous native-context behavior. [TypeScript acceptance](tests/acceptance/typescript-syntax-fallback-candidate.md) · [Java acceptance](tests/acceptance/java-syntax-fallback-candidate.md).
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. If no local entry is visible, the bundled TypeScript/TSX candidate can provide suspected syntax positions. 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).

```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 @@ -180,7 +180,7 @@ Python 编辑快反馈可执行 `codeguard lint python . --file src/changed.py -

**公开 `0.1.2` 尚未实现内置 WASM 兜底或自动对话交付。** 保留已有命令作为统一入口;当前仍须满足各原生适配器的参数和前置条件:

可选源码构建的单文件候选路径已存在:`codeguard lint java Foo.java` 与 `codeguard lint typescript foo.ts` 在完全缺少显式原生上下文时分别输出 [Java](schemas/java-syntax-precheck-feedback-v0.1.schema.json) 与 [TypeScript](schemas/eslint-local-feedback-v0.3.schema.json) 的版本化初检反馈。疑似恢复位置仍需原生确认;无恢复节点也因 grammar 版本/方言未验收保持 incomplete。默认及公开二进制沿用既有原生上下文路径,见 [Java 局部验收](tests/acceptance/java-syntax-fallback-candidate.md) 与 [TypeScript 局部验收](tests/acceptance/typescript-syntax-fallback-candidate.md)。
可选源码构建已有 Java/TypeScript 单文件候选路径。TypeScript 项目若有可见的本地 ESLint 10 入口和唯一 flat config,会先尝试从 `PATH` 解析 Node 并运行既有有界原生探针;缺 Node 时报告准备缺口,无本地入口时才提供 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)。

```bash
codeguard lint java .
Expand Down
37 changes: 27 additions & 10 deletions crates/codeguard-cli/src/eslint_lint_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,38 @@ pub fn run(args: &[String]) -> ExitCode {
}
#[cfg(feature = "wasm-precheck")]
if crate::typescript_syntax_precheck::eligible(&args) {
if crate::eslint_native_first_candidate::observed_candidate(&args.source) {
let mut report = observe_with_preparation(&args, deadline);
if report["reason"] == "eslint_execution_context_missing"
&& report["workbench"]["next"]["repair_brief"]["step"].is_null()
{
report["next_action"] = json!(
"项目本地 ESLint 候选和配置已发现;先核对 Node 路径、ESLint 版本与原配置并执行原生检查。候选尚未通过探测,不能认定 lint 已完成"
);
}
if let Some(candidate) =
crate::eslint_native_first_candidate::observed_candidate(&args.source)
{
let mut native_args = args.clone();
native_args.entry = Some(candidate.entry);
native_args.config = Some(candidate.config);
native_args.cwd = Some(candidate.root);
native_args.version = Some(candidate.version);
let report = if let Some(node) = crate::eslint_native_first_candidate::node_on_path() {
native_args.node = Some(node);
observe_with_preparation(&native_args, deadline)
} else {
let mut report = observe_with_preparation(&args, deadline);
if report["reason"] == "eslint_execution_context_missing"
&& report["workbench"]["next"]["repair_brief"]["step"].is_null()
{
report["next_action"] = json!(
"项目本地 ESLint 候选和配置已发现;当前未解析到 Node,请提供 Node 路径并执行原生检查。候选尚未通过探测,不能认定 lint 已完成"
);
}
report
};
if args.json {
println!("{report}");
} else {
print_feedback(&report);
}
return ExitCode::from(3);
return ExitCode::from(if report["reason"] == "request_cancelled" {
130
} else {
3
});
}
let report = crate::typescript_syntax_precheck::observe(&args, deadline);
if args.json {
Expand Down
97 changes: 69 additions & 28 deletions crates/codeguard-cli/src/eslint_native_first_candidate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,57 +3,98 @@
use codeguard_adapters::{EslintConfigState, inspect_eslint_local_candidate};
use codeguard_core::ObservedPathKind;
use codeguard_runtime::read_bounded_regular_file;
use std::path::Path;
use std::{
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
};

/// 识别源码最近的项目根是否具备可进一步原生探测的本地 ESLint 与配置。
/// 参数为普通源码路径;返回真仅表示应先确认原生调用,不证明工具可执行或规则有效。
pub(crate) fn observed_candidate(source: &Path) -> bool {
let Ok(source) = source.canonicalize() else {
return false;
};
let Some(parent) = source.parent() else {
return false;
};
/// 已观察到的项目本地 ESLint 10 上下文,尚不代表原生执行成功。
pub(crate) struct LocalEslintCandidate {
/// 项目构建根,同时是原生检查的工作目录。
pub(crate) root: PathBuf,
/// 本地 ESLint JS 入口。
pub(crate) entry: PathBuf,
/// 唯一的普通 flat config。
pub(crate) config: PathBuf,
/// 本地包清单中观察到的具体版本,须由原生版本探针复核。
pub(crate) version: String,
}

/// 识别源码最近项目根的本地 ESLint 包、入口和单一 flat config。
/// 参数为普通源码路径;返回候选仅表示可以尝试原生版本探测,不证明规则有效。
pub(crate) fn observed_candidate(source: &Path) -> Option<LocalEslintCandidate> {
let source = source.canonicalize().ok()?;
let parent = source.parent()?;
for root in parent.ancestors() {
let manifest = root.join("package.json");
if !regular_file(&manifest) {
continue;
}
let Ok(project_manifest) = read_bounded_regular_file(&manifest, 256 * 1024) else {
return false;
};
let project_manifest = read_bounded_regular_file(&manifest, 256 * 1024).ok()?;
let local_root = root.join("node_modules");
let package_root = local_root.join("eslint");
if !regular_directory(&local_root) || !regular_directory(&package_root) {
return false;
return None;
}
let local_manifest = package_root.join("package.json");
let entry = package_root.join("bin/eslint.js");
if !regular_file(&local_manifest) || !regular_file(&entry) {
return false;
return None;
}
let Ok(local_manifest) = read_bounded_regular_file(&local_manifest, 256 * 1024) else {
return false;
};
// 仅把 ESLint 10 可直接选取的普通 flat config 作为候选;旧配置和 TS loader 须另行确认。
let config_count = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"]
let local_manifest = read_bounded_regular_file(&local_manifest, 256 * 1024).ok()?;
// 旧配置、TS loader 与多配置选择尚未受控,不能构造自动原生命令。
let configs: Vec<_> = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"]
.iter()
.filter(|name| {
let path = root.join(name);
regular_file(&path) && read_bounded_regular_file(&path, 1024 * 1024).is_ok()
});
if config_count.count() != 1 {
return false;
.map(|name| root.join(name))
.filter(|path| std::fs::symlink_metadata(path).is_ok())
.collect();
let [config] = configs.as_slice() else {
return None;
};
if !regular_file(config) || read_bounded_regular_file(config, 1024 * 1024).is_err() {
return None;
}
let candidate = inspect_eslint_local_candidate(
Some(&project_manifest),
Some(&local_manifest),
Some(ObservedPathKind::File),
EslintConfigState::Observed,
);
return candidate.state == "local_candidate_requires_native_probe";
if candidate.state != "local_candidate_requires_native_probe" {
return None;
}
return Some(LocalEslintCandidate {
root: root.to_path_buf(),
entry,
config: config.clone(),
version: candidate.observed_version?,
});
}
None
}

/// 从当前进程 PATH 选择普通可执行 Node 文件;缺失只表示当前调用未解析到运行时。
/// 返回路径会被原生探针按字节冻结,不能据此宣称项目工具不存在或检查完成。
pub(crate) fn node_on_path() -> Option<PathBuf> {
for directory in std::env::split_paths(&std::env::var_os("PATH")?) {
if !directory.is_absolute() {
continue;
}
let node = directory.join("node");
let Ok(node) = node.canonicalize() else {
continue;
};
let Ok(metadata) = std::fs::symlink_metadata(&node) else {
continue;
};
if metadata.is_file()
&& metadata.permissions().mode() & 0o111 != 0
&& read_bounded_regular_file(&node, 128 * 1024 * 1024).is_ok()
{
return Some(node);
}
}
false
None
}

fn regular_file(path: &Path) -> bool {
Expand Down
2 changes: 1 addition & 1 deletion crates/codeguard-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ mod eslint_effective_settings;
mod eslint_lint_arguments;
#[cfg(unix)]
pub mod eslint_lint_command;
#[cfg(feature = "wasm-precheck")]
#[cfg(all(feature = "wasm-precheck", unix))]
mod eslint_native_first_candidate;
mod eslint_preparation;
pub mod eslint_probe;
Expand Down
98 changes: 96 additions & 2 deletions crates/codeguard-cli/tests/typescript_syntax_fallback_candidate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,19 @@ fn observed_project_local_eslint_candidate_is_not_treated_as_missing_native_lint
)
.unwrap();

let (status, report) = project.lint("app.ts");
assert_eq!(status, 3);
let output = Command::new(env!("CARGO_BIN_EXE_codeguard"))
.args([
"lint",
"typescript",
project.0.join("app.ts").to_str().unwrap(),
"--format",
"json",
])
.env("PATH", "")
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3));
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["reason"], "eslint_execution_context_missing");
assert!(report.get("syntax_precheck").is_none());
assert!(
Expand All @@ -125,6 +136,89 @@ fn observed_project_local_eslint_candidate_is_not_treated_as_missing_native_lint
assert_eq!(report["delivery_decision"], "not_evaluated");
}

#[test]
fn available_project_local_eslint_runs_before_wasm_and_preserves_native_finding() {
use std::os::unix::fs::PermissionsExt;

let project = Project::new();
let source = project.0.join("app.ts");
fs::write(&source, "debugger;\n").unwrap();
fs::write(
project.0.join("package.json"),
r#"{"devDependencies":{"eslint":"10.0.0"}}"#,
)
.unwrap();
fs::write(
project.0.join("eslint.config.cjs"),
"module.exports = [];\n",
)
.unwrap();
fs::create_dir_all(project.0.join("node_modules/eslint/bin")).unwrap();
fs::write(
project.0.join("node_modules/eslint/package.json"),
r#"{"name":"eslint","version":"10.0.0","bin":{"eslint":"bin/eslint.js"}}"#,
)
.unwrap();
fs::write(
project.0.join("node_modules/eslint/bin/eslint.js"),
"local entry fixture\n",
)
.unwrap();
let native = serde_json::json!([{
"filePath": source.canonicalize().unwrap(),
"messages": [{"ruleId":"no-debugger","severity":2,"message":"private native text","line":1,"column":1}],
"suppressedMessages":[],"errorCount":1,"warningCount":0,"fatalErrorCount":0,
"fixableErrorCount":0,"fixableWarningCount":0
}]);
let node = project.0.join("node");
fs::write(
&node,
format!(
"#!/bin/sh\nfor arg in \"$@\"; do if [ \"$arg\" = --version ]; then printf 'v10.0.0\\n'; exit 0; fi; done\nwhile [ \"$#\" -gt 0 ]; do if [ \"$1\" = --output-file ]; then shift; report=$1; fi; shift; done\nprintf '%s' '{}' > \"$report\"\nexit 1\n",
native
),
)
.unwrap();
fs::set_permissions(&node, fs::Permissions::from_mode(0o700)).unwrap();

let output = Command::new(env!("CARGO_BIN_EXE_codeguard"))
.args([
"lint",
"typescript",
source.to_str().unwrap(),
"--format",
"json",
])
.env("PATH", &project.0)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(3));
let report: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(report["local_coherent"], true, "{report}");
assert_eq!(report["findings"][0]["rule_id"], "no-debugger");
assert!(report.get("syntax_precheck").is_none());
assert_eq!(report["delivery_decision"], "not_evaluated");
assert!(!String::from_utf8_lossy(&output.stdout).contains("private native text"));

fs::write(&node, "#!/bin/sh\nprintf 'v10.0.1\\n'\n").unwrap();
let mismatch = Command::new(env!("CARGO_BIN_EXE_codeguard"))
.args([
"lint",
"typescript",
source.to_str().unwrap(),
"--format",
"json",
])
.env("PATH", &project.0)
.output()
.unwrap();
assert_eq!(mismatch.status.code(), Some(3));
let mismatch: Value = serde_json::from_slice(&mismatch.stdout).unwrap();
assert_eq!(mismatch["reason"], "eslint_version_mismatch");
assert_eq!(mismatch["local_coherent"], false);
assert!(mismatch.get("syntax_precheck").is_none());
}

#[test]
fn configured_but_uninstalled_eslint_still_allows_candidate_precheck() {
let project = Project::new();
Expand Down
2 changes: 1 addition & 1 deletion docs/Codeguard-Command-Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The command catalog below defines responsibility, not availability. Current disp

Current slices include static discovery/init, selected native checks, partial aggregation, work sync/next, local attempts/leases, selected original-tool rechecks and candidate inspection. Current doctor takes `[path]`; Java doclint is selected through `lint java --checker javadoc`. Do not assume a public `lint rust`, generic fix, complete gate or MCP service exists because its target is described here. A callable recheck does not imply final verified closure.

The opt-in `codeguard-cli/wasm-precheck` build adds narrow `lint typescript <file>` and `lint java <file>` candidate observations when no explicit native execution context is provided. They use the same commands and return [TypeScript feedback 0.3.0](../schemas/eslint-local-feedback-v0.3.schema.json) or [Java feedback 0.1.0](../schemas/java-syntax-precheck-feedback-v0.1.schema.json), both with exit 3. The Java path covers only an ordinary single `.java` file; partial native context, Javadoc/Checkstyle selections and symlinks retain their existing paths. Neither candidate promotes an unvalidated grammar to native lint success. Default/published binaries retain the existing native-context behavior.
The opt-in `codeguard-cli/wasm-precheck` build adds narrow `lint typescript <file>` and `lint java <file>` paths when no explicit native execution context is provided. TypeScript first recognizes an ordinary project-local ESLint 10 entry and one flat config, then uses an executable Node from `PATH` to run the existing bounded native probe; when Node is unresolved, it reports setup instead of starting WASM. Without a local entry, it returns [TypeScript feedback 0.3.0](../schemas/eslint-local-feedback-v0.3.schema.json) or TSX 0.4.0 candidate feedback. Java returns [Java feedback 0.1.0](../schemas/java-syntax-precheck-feedback-v0.1.schema.json). These paths retain exit 3 and never turn an unvalidated grammar or local native observation into delivery approval. Default/published binaries retain the existing native-context behavior. See [native-first acceptance](../tests/acceptance/native-first-eslint-candidate.md).

`codeguard hook plan [--format=json]` is a current, read-only host-event bridge. It reads a bounded [1.0 request](../schemas/hook-trigger-request.schema.json) from stdin and returns a [1.1 candidate tier](../schemas/hook-trigger-plan-v1.1.schema.json) with exit 3, `execution=not_run` and `delivery_decision=not_evaluated`. A confirmed edit may request fast file checks for at most eight distinct paths, 512 bytes per path and 2 KiB combined; an over-budget event instead requests scope resolution with `fast_scope_budget_exceeded`, without silently dropping files. This is a signal to select a bounded batch check, not to resubmit the same list forever. It does not invoke a checker, take a Git snapshot or block any host. The earlier [1.0 response schema](../schemas/hook-trigger-plan.schema.json) remains available for historical readers. See the [CLI acceptance record](../tests/acceptance/hook-plan-cli-candidate.md).

Expand Down
Loading
Loading