diff --git a/crates/codeguard-cli/src/eslint_lint_command.rs b/crates/codeguard-cli/src/eslint_lint_command.rs index 54c32e5a..118f8337 100644 --- a/crates/codeguard-cli/src/eslint_lint_command.rs +++ b/crates/codeguard-cli/src/eslint_lint_command.rs @@ -43,6 +43,22 @@ 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 args.json { + println!("{report}"); + } else { + print_feedback(&report); + } + return ExitCode::from(3); + } let report = crate::typescript_syntax_precheck::observe(&args, deadline); if args.json { println!("{report}"); diff --git a/crates/codeguard-cli/src/eslint_native_first_candidate.rs b/crates/codeguard-cli/src/eslint_native_first_candidate.rs new file mode 100644 index 00000000..8031833b --- /dev/null +++ b/crates/codeguard-cli/src/eslint_native_first_candidate.rs @@ -0,0 +1,65 @@ +//! 单文件检查前的项目本地 ESLint 候选识别;只读且不执行配置。 + +use codeguard_adapters::{EslintConfigState, inspect_eslint_local_candidate}; +use codeguard_core::ObservedPathKind; +use codeguard_runtime::read_bounded_regular_file; +use std::path::Path; + +/// 识别源码最近的项目根是否具备可进一步原生探测的本地 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; + }; + 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 local_root = root.join("node_modules"); + let package_root = local_root.join("eslint"); + if !regular_directory(&local_root) || !regular_directory(&package_root) { + return false; + } + 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; + } + 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"] + .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; + } + 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"; + } + false +} + +fn regular_file(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_file()) +} + +fn regular_directory(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.is_dir()) +} diff --git a/crates/codeguard-cli/src/lib.rs b/crates/codeguard-cli/src/lib.rs index 9add0eed..31f21055 100644 --- a/crates/codeguard-cli/src/lib.rs +++ b/crates/codeguard-cli/src/lib.rs @@ -18,6 +18,8 @@ mod eslint_effective_settings; mod eslint_lint_arguments; #[cfg(unix)] pub mod eslint_lint_command; +#[cfg(feature = "wasm-precheck")] +mod eslint_native_first_candidate; mod eslint_preparation; pub mod eslint_probe; pub mod eslint_probe_request; diff --git a/crates/codeguard-cli/tests/typescript_syntax_fallback_candidate.rs b/crates/codeguard-cli/tests/typescript_syntax_fallback_candidate.rs index 423f9999..1c1857b8 100644 --- a/crates/codeguard-cli/tests/typescript_syntax_fallback_candidate.rs +++ b/crates/codeguard-cli/tests/typescript_syntax_fallback_candidate.rs @@ -90,6 +90,59 @@ fn no_recovery_remains_incomplete_and_javascript_does_not_use_typescript_grammar assert!(js_report.get("syntax_precheck").is_none()); } +#[test] +fn observed_project_local_eslint_candidate_is_not_treated_as_missing_native_lint() { + let project = Project::new(); + fs::write(project.0.join("app.ts"), "const value: number = ;\n").unwrap(); + fs::write( + project.0.join("package.json"), + r#"{"devDependencies":{"eslint":"10.0.0"}}"#, + ) + .unwrap(); + fs::write(project.0.join("eslint.config.mjs"), "export default [];\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"), + "process.exit(99);\n", + ) + .unwrap(); + + let (status, report) = project.lint("app.ts"); + assert_eq!(status, 3); + assert_eq!(report["reason"], "eslint_execution_context_missing"); + assert!(report.get("syntax_precheck").is_none()); + assert!( + report["next_action"] + .as_str() + .unwrap() + .contains("项目本地 ESLint 候选") + ); + assert_eq!(report["delivery_decision"], "not_evaluated"); +} + +#[test] +fn configured_but_uninstalled_eslint_still_allows_candidate_precheck() { + let project = Project::new(); + fs::write(project.0.join("app.ts"), "const value: number = ;\n").unwrap(); + fs::write( + project.0.join("package.json"), + r#"{"devDependencies":{"eslint":"10.0.0"}}"#, + ) + .unwrap(); + fs::write(project.0.join("eslint.config.mjs"), "export default [];\n").unwrap(); + + let (status, report) = project.lint("app.ts"); + assert_eq!(status, 3); + assert_eq!(report["native"]["status"], "not_run"); + assert!(report.get("syntax_precheck").is_some()); + assert_eq!(report["delivery_decision"], "not_evaluated"); +} + #[test] fn tsx_uses_its_own_grammar_instead_of_reporting_valid_jsx_as_suspect() { let project = Project::new(); diff --git a/openspec/changes/introduce-rust-codeguard-cli/tasks.md b/openspec/changes/introduce-rust-codeguard-cli/tasks.md index cb9da0a2..7dbe486a 100644 --- a/openspec/changes/introduce-rust-codeguard-cli/tasks.md +++ b/openspec/changes/introduce-rust-codeguard-cli/tasks.md @@ -1033,7 +1033,7 @@ TDD缺自动同步先RED。相关普通:build9、work sync15、next6,共30 - [ ] 14.3 进程资源隔离。责任:runtime。规格:SP05。依赖:14.2。验收:验证超时、内存、输出、取消与崩溃;损坏 grammar 不阻塞其他模块、不遗留进程。进行中:Unix 候选 worker 复用受控进程组内核,输入 1 MiB、输出 64 KiB,父进程有共同截止时间和取消;崩溃后代清理、输出洪泛、运行中取消及随后正常解析均增加真实子进程反例。Linux 2 GiB `RLIMIT_AS`、3 GiB 分配负例与 worker 故障隔离在 [Ubuntu CI](https://github.com/full-stack-plugins/codeguard/actions/runs/36478461118) 真实通过;Wasmtime 虚拟预留缩小。macOS 本机硬限制实测不支持,候选仍不具内存隔离权威;见[局部验收](../../../tests/acceptance/syntax-worker-candidate.md)。其它目标平台、损坏 grammar 隔离及正式调度仍缺,不勾选。 - [ ] 14.4 语言覆盖与源码映射。责任:adapters。规格:SP06。依赖:14.2。验收:识别 ERROR/MISSING,合并恢复节点;测试 Unicode、换行、嵌入语言、新语法和未知版本,不将不支持当源码违规。进行中:Rust 已提取两类原始节点,adapters 核对 UTF-8/CRLF 字节锚点并转换 Unicode 标量列;结构祖先归组保留并列错误,预算截断保持不完整;[局部证据](../../../tests/acceptance/syntax-recovery-candidate.md)。级联归并语料、版本/嵌入语言及原生对照仍缺。 - [ ] 14.5 原生工具准备探测。责任:adapters。规格:SP01,SP03。依赖:现有配置发现。验收:识别项目本地工具、wrapper、版本和无效配置;PATH 缺失不等于未安装,不对坏配置重复建议安装。进行中:ESLint 本地包/入口及 Maven Wrapper 脚本/发行配置已有只读候选分类,`detect` 与 `check` 已输出固定路径观察的候选;`node_modules` 普通源码遍历被排除,链接和损坏配置保留阻塞;发现协议升级为 0.4.0,聚合协议升级为 0.31.0 并保留旧 schema;[局部证据](../../../tests/acceptance/native-tool-readiness-candidates.md)。显式工具、受控原生版本探针、原生优先调度、更多构建器和真实多平台验收尚缺,不勾选。 -- [ ] 14.6 逐模块原生优先调度。责任:CLI/core。规格:SP01。依赖:14.4,14.5。验收:原生可用先执行;原生违规不得被 WASM 洗白;多根分别选择并保留运行失败与兄弟结果。进行中:可选 CLI 特性在 `lint typescript`、`lint java` 显式单文件且完全缺少原生上下文时附加候选语法观察,部分原生上下文、符号链接、其它扩展名及 Javadoc/Checkstyle 选择保留原路径,见 [TypeScript 局部验收](../../../tests/acceptance/typescript-syntax-fallback-candidate.md)、[Java 局部验收](../../../tests/acceptance/java-syntax-fallback-candidate.md)。新增 `.tsx` 独立 grammar 的有效/无效 JSX 反例与候选报告方言绑定,见 [TSX 纠错验收](../../../tests/acceptance/tsx-grammar-candidate.md)。这只证明未给显式上下文,不证明原生工具缺失;本地工具自动选择、多模块调度、真实原生/初检混合保留仍缺,不勾选。 +- [ ] 14.6 逐模块原生优先调度。责任:CLI/core。规格:SP01。依赖:14.4,14.5。验收:原生可用先执行;原生违规不得被 WASM 洗白;多根分别选择并保留运行失败与兄弟结果。进行中:可选 CLI 特性在 `lint typescript`、`lint java` 显式单文件且完全缺少原生上下文时附加候选语法观察,部分原生上下文、符号链接、其它扩展名及 Javadoc/Checkstyle 选择保留原路径,见 [TypeScript 局部验收](../../../tests/acceptance/typescript-syntax-fallback-candidate.md)、[Java 局部验收](../../../tests/acceptance/java-syntax-fallback-candidate.md)。新增 `.tsx` 独立 grammar 的有效/无效 JSX 反例与候选报告方言绑定,见 [TSX 纠错验收](../../../tests/acceptance/tsx-grammar-candidate.md)。本地 ESLint 10 候选与配置可见时,单文件 TypeScript 路径现在先返回待原生探测而不抢先启动 WASM;只声明/配置但无本地入口仍可初检,见 [本地原生优先局部验收](../../../tests/acceptance/native-first-eslint-candidate.md)。这不证明本地原生工具已执行;可信 Node/参数自动选择、多模块调度、真实原生/初检混合保留仍缺,不勾选。 - [ ] 14.7 初检协议与 schema。责任:core/CLI。规格:SP02,SP11。依赖:14.4。验收:固定版本和状态枚举、输入/资产身份、范围及缺口;空文件集、部分解析和未知版本不能 clean;未知 major 拒绝。进行中:core 已有状态聚合及[局部证据](../../../tests/acceptance/syntax-precheck-status-candidate.md);adapters 候选报告 1.0.0 的封闭 schema 与严格读者拒绝重复键、未知版本、身份漂移与伪造 clean,见[候选报告证据](../../../tests/acceptance/syntax-precheck-report-candidate.md);可选 CLI `lint typescript` 与 `lint java` 单文件入口分别产生 TypeScript 0.3.0、TSX 0.4.0、Java 0.1.0 局部候选反馈及位置化疑似观察,见 [TypeScript](../../../tests/acceptance/typescript-syntax-fallback-candidate.md) 与 [Java](../../../tests/acceptance/java-syntax-fallback-candidate.md) 验收。正式全范围/嵌入区域/排除原因、持久化任务引用、所有消费者未知 major 拒绝与发布兼容仍缺,不勾选。 - [ ] 14.8 安装建议与必须确认策略。责任:core。规格:SP03。依赖:14.6,14.7。验收:可选原生缺失且完整 clean 仅推荐;疑似或已有必需义务要求恢复原生确认;无适配器给具体决策。进行中:core 已有纯决策函数,区分缺工具、坏配置、执行失败和无适配器;仅非空、完整、已验收且无恢复节点的 clean 才允许可选建议,伪造 clean 状态不能降级。见[局部验收](../../../tests/acceptance/syntax-setup-guidance-candidate.md)。真实工具准备来源、CLI/工作台任务投影及宿主反馈尚未接线,不勾选。 - [ ] 14.9 四类可读与 JSON 报告。责任:CLI。规格:SP07,SP11。依赖:14.7,14.8。验收:覆盖原生成功、缺原生且 clean、疑似语法、初检不可用;结果范围/行动/交付语义一致;不伪造任务 ID。进行中:启用可选 WASM 特性的 TypeScript 与 Java 单文件在无显式原生上下文时提供 JSON/human 候选疑似观察;无恢复节点仍为 incomplete,任务引用为空;Java 超大输入保留 worker 未完成,见 [TypeScript](../../../tests/acceptance/typescript-syntax-fallback-candidate.md) 与 [Java](../../../tests/acceptance/java-syntax-fallback-candidate.md) 局部验收。正式四类报告、多模块范围与宿主渲染仍缺,不勾选。 diff --git a/tests/acceptance/native-first-eslint-candidate.md b/tests/acceptance/native-first-eslint-candidate.md new file mode 100644 index 00000000..1d0a3128 --- /dev/null +++ b/tests/acceptance/native-first-eslint-candidate.md @@ -0,0 +1,9 @@ +# TypeScript 本地 ESLint 候选优先:局部验收(2026-09-29) + +对应 OpenSpec 14.6。启用 `wasm-precheck` 的单文件 `lint typescript` 在缺少显式原生参数时,先只读核对源码最近的 `package.json`、普通 `node_modules/eslint/package.json` 与 `bin/eslint.js`,以及唯一的普通 `eslint.config.js/mjs/cjs`。只有适配器将其分类为 `local_candidate_requires_native_probe` 时,命令不启动 WASM worker,返回未完成的原生准备反馈,并提示先核对 Node 路径、版本与原配置。旧 `.eslintrc`、TypeScript 配置及多个 flat config 不作为可直接选取的候选。候选的存在不等于原生可运行;本增量不自动执行本地入口,也不授予交付通过。 + +先新增真实 CLI 反例:项目本地 ESLint 10 包、入口和配置可见时,旧命令仍启动 WASM,断言因出现 `syntax_precheck` 而失败。接线后该用例通过。另一个反例保留相同声明和配置但不安装本地包,仍允许候选初检,且 `native=not_run`、`delivery_decision=not_evaluated`。`eslint_lint_cli` 现有显式原生路径与 `grammar_status_cli` 一并回归。 + +本次定向测试:`CARGO_NET_OFFLINE=true cargo test --locked -p codeguard-cli --features wasm-precheck --test typescript_syntax_fallback_candidate --test eslint_lint_cli --test grammar_status_cli`,共 16 通过、4 项需要显式真实 Node/ESLint 的用例忽略。候选探测不执行项目 JavaScript、不修改 `node_modules`,不把“未给显式参数”当作“原生工具不存在”。 + +仍缺:可信 Node 路径与原调用参数解析、受控版本探针、动态配置有效性确认、项目本地原生自动执行、多模块原生优先调度及真实宿主反馈。因此 14.5/14.6 均不勾选。