From 6a3a617bebc2122e986b70ba7bc2a2acd4c2ad68 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 13:13:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(execution):=20=E6=9E=84=E5=BB=BA=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E5=92=8C=E8=BF=90=E8=A1=8C=E9=A1=B9=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E8=A2=AB=20Git=20=E5=BF=BD=E7=95=A5=E7=9A=84?= =?UTF-8?q?=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.detect 和自动运行项发现共用的启发式扫描(最多 6 层)现在会按 Git 的忽略规则剪掉目录:.claude/worktrees 下的代理工作树、生成目录和本地副本 不再被当成候选根目录,构建窗口不会把每个模块列两遍。 - 规则来自工作区内途经的 .gitignore,以及根目录在工作区内的仓库的 info/exclude;gitfile 按 gitdir: 解析,链接工作树经 commondir 读主仓库 共享的 info/exclude;嵌套仓库另起一套规则;深层文件先匹配先决定, 支持 ! 重新包含。 - 不读工作区根以上的目录、core.excludesFile 和索引;读不到的忽略文件不加 规则(只会多看到目录),远程连接中断仍然让扫描失败。 - 指纹扫描和 Node 清单发现(不限深度的完整扫描)不变,仍遍历被忽略的目录。 - 契约 README、构建系统参考页、rust-core-api.md 写明检测深度和忽略规则; 协议更新记录加一行。 --- rust/Cargo.lock | 1 + rust/contracts/rust-core-api.md | 8 +- rust/crates/core/src/tests/build_systems.rs | 23 ++ rust/crates/core/src/tests/remote_sessions.rs | 9 +- rust/crates/execution/Cargo.toml | 1 + rust/crates/execution/src/workspace_scan.rs | 356 +++++++++++++++++- shared/plugins/contract/v1/README.md | 2 +- .../reference/domains/build-systems.md | 4 +- .../plugins/reference/protocol-changelog.md | 1 + 9 files changed, 390 insertions(+), 15 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0be4812d..5c36cb37 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1028,6 +1028,7 @@ dependencies = [ name = "lithe-execution" version = "0.1.0" dependencies = [ + "ignore", "lithe-diagnostics", "lithe-plugin-model", "lithe-protocol", diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index a4a0b190..56265b6c 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -3210,7 +3210,13 @@ revision and the diagnostic says so instead of listing modified files. Automatic run discovery, fingerprint traversal and Node manifest discovery use Core's shared scan policy. Known caches/build outputs are pruned before descending; other hidden source directories remain visible and -symlinks are not followed. Failure to read the workspace root is an error. An +symlinks are not followed. Automatic run discovery and `build.detect` search at +most 6 levels and also prune directories Git ignores, using the `.gitignore` +files they pass and the `info/exclude` of each repository whose root lies in the +workspace (a linked worktree reads the main repository's shared one); a nested +repository starts its own rules. Nothing above the workspace root is read, nor +`core.excludesFile` or the index. Fingerprint traversal and Node manifest +discovery keep ignored directories. Failure to read the workspace root is an error. An unreadable optional child or fingerprint input is recorded in `generator.unreadablePaths`; generation retains previous configurations and input hashes beneath those unknown paths. Inspection returns `incompleteProjectScan` diff --git a/rust/crates/core/src/tests/build_systems.rs b/rust/crates/core/src/tests/build_systems.rs index 4b2f5c19..b857031e 100644 --- a/rust/crates/core/src/tests/build_systems.rs +++ b/rust/crates/core/src/tests/build_systems.rs @@ -754,6 +754,29 @@ fn detection_applies_nesting_priority_and_rejections() { assert_eq!(gradle["markers"], json!(["android/settings.gradle.kts"])); } +/// A directory Git ignores is no candidate root: agent worktrees and local copies +/// repeat the project's own manifests, and would list every module twice. +#[test] +fn detection_skips_directories_git_ignores() { + let package = |name: &str| format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"); + let workspace = TemporaryWorkspace::new("build-detect-ignored"); + workspace + .write(".git/HEAD", "ref: refs/heads/main\n") + .write(".git/info/exclude", ".claude/worktrees/\n") + .write(".gitignore", "/scratch/\n") + .write("Cargo.toml", &package("app")) + .write("tools/Cargo.toml", &package("tools")) + .write("scratch/Cargo.toml", &package("scratch")) + .write(".claude/worktrees/copy/Cargo.toml", &package("app")); + + let detection = detect( + &workspace, + vec![fixture("build-static-file.plugin.json")], + json!({}), + ); + assert_eq!(instance_ids(&detection), ["cargo:.", "cargo:tools"]); +} + #[test] fn detection_returns_empty_for_non_directory_and_missing_workspace_roots() { let workspace = TemporaryWorkspace::new("build-detect-non-directory"); diff --git a/rust/crates/core/src/tests/remote_sessions.rs b/rust/crates/core/src/tests/remote_sessions.rs index 2c3c31d4..cbcfa3f6 100644 --- a/rust/crates/core/src/tests/remote_sessions.rs +++ b/rust/crates/core/src/tests/remote_sessions.rs @@ -747,6 +747,10 @@ fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { std::fs::create_dir_all(remote.join("service/api")).unwrap(); std::fs::write(remote.join("package.json"), "{}").unwrap(); std::fs::write(remote.join("service/tool-a.toml"), "").unwrap(); + // The ignore rules are the host's: the placeholder has no `.gitignore`. + std::fs::create_dir_all(remote.join("generated")).unwrap(); + std::fs::write(remote.join("generated/package.json"), "{}").unwrap(); + std::fs::write(remote.join(".gitignore"), "generated/\n").unwrap(); let workspace = serde_json::from_value( json!({"sessionId": host.id, "localRoot": local, "remoteRoot": remote}), ) @@ -768,7 +772,10 @@ fn shared_run_scan_reads_the_host_tree_and_stops_when_the_link_drops() { assert_eq!( files, [ - (std::path::PathBuf::new(), vec!["package.json".to_owned()]), + ( + std::path::PathBuf::new(), + vec![".gitignore".to_owned(), "package.json".to_owned()] + ), ("service".into(), vec!["tool-a.toml".to_owned()]), ("service/api".into(), Vec::new()), ] diff --git a/rust/crates/execution/Cargo.toml b/rust/crates/execution/Cargo.toml index 3124b192..5b20a75f 100644 --- a/rust/crates/execution/Cargo.toml +++ b/rust/crates/execution/Cargo.toml @@ -10,6 +10,7 @@ description = "Run configurations for Lithe: generation, resolution, launch envi test-support = [] [dependencies] +ignore = "=0.4.33" lithe-diagnostics = { path = "../diagnostics" } lithe-plugin-model = { path = "../plugin-model" } lithe-protocol = { path = "../protocol" } diff --git a/rust/crates/execution/src/workspace_scan.rs b/rust/crates/execution/src/workspace_scan.rs index b0edd616..5ce0ce20 100644 --- a/rust/crates/execution/src/workspace_scan.rs +++ b/rust/crates/execution/src/workspace_scan.rs @@ -2,17 +2,35 @@ //! //! Automatic discovery is best effort below the required workspace root. Declared //! build-system modules remain owned by their provider and bypass these defaults. +//! +//! Heuristic discovery also skips the directories Git ignores: agent worktrees, +//! generated trees and local checkouts repeat or imitate the project's own +//! manifests, and listing them as run or build roots shows every module twice. +//! The rules come from the `.gitignore` files the scan lists and the `info/exclude` +//! of each repository whose root it lists. Nothing above the workspace root is +//! read, so a workspace opened below its repository root uses only the files +//! inside it. A global `core.excludesFile` and the index are not read either, so a +//! tracked manifest below an ignored directory is not discovered. Fingerprint +//! scans keep every directory: they decide freshness, and reading an ignored +//! input there only costs an extra regeneration. +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use ignore::Match; use lithe_protocol::CoreError; use lithe_workspace::fs; use std::collections::BTreeSet; use std::io; use std::ops::ControlFlow; use std::path::{Path, PathBuf}; +use std::rc::Rc; /// Heuristic discovery budget; full fingerprint scans retain cancellation checks. const MAX_HEURISTIC_DIRECTORIES: usize = 4000; +/// Largest ignore file, gitfile or `commondir` file the scan reads. A larger one +/// contributes no rules, which only lets the scan see more directories. +const MAX_IGNORE_SOURCE_BYTES: u64 = 1 << 20; + const EXCLUDED_DIRECTORIES: &[&str] = &[ ".git", ".worktree", @@ -75,7 +93,8 @@ pub struct WorkspaceScan { } /// Traverses automatic discovery inputs. A depth limit belongs only to heuristic -/// service detection; fingerprints and explicit host file lists use no depth limit. +/// service detection, which also skips Git-ignored directories; fingerprints and +/// explicit host file lists use no depth limit and keep them. pub fn scan_run_workspace( root: &Path, max_depth: Option, @@ -93,15 +112,39 @@ fn read_directory(path: &Path) -> io::Result> { .collect() } +// Reads through the workspace adapter, like the directory listing, so a remote +// project's ignore files come from the remote host. +fn read_ignore_source(path: &Path) -> io::Result { + if fs::metadata(path)?.len() > MAX_IGNORE_SOURCE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ignore source exceeds the scan limit", + )); + } + // A pattern that is not UTF-8 cannot match a name the scan keeps: names that + // are not UTF-8 are reported unreadable instead of being traversed. + Ok(String::from_utf8_lossy(&fs::read(path)?).into_owned()) +} + /// Same traversal as [`scan_run_workspace`] with an injected directory reader, /// so tests can fail or remove a directory between reads. pub fn scan_with_reader( root: &Path, max_depth: Option, read: &impl Fn(&Path) -> io::Result>, +) -> Result { + scan_with_readers(root, max_depth, read, &read_ignore_source) +} + +// Tests also inject the reader of ignore files, gitfiles and `commondir`. +fn scan_with_readers( + root: &Path, + max_depth: Option, + read: &impl Fn(&Path) -> io::Result>, + read_text: &impl Fn(&Path) -> io::Result, ) -> Result { let mut directories = Vec::new(); - let unreadable_paths = visit_with_reader(root, max_depth, read, |directory| { + let unreadable_paths = visit_with_readers(root, max_depth, read, read_text, |directory| { directories.push(directory); ControlFlow::Continue(()) })?; @@ -112,22 +155,148 @@ pub fn scan_with_reader( }) } +/// The ignore rules in force in one directory: its own `.gitignore` first, then +/// its ancestors' up to the repository's `info/exclude`. As in Git, the first file +/// with a matching pattern decides, so a lower file can re-include a directory. +struct IgnoreRules { + matcher: Gitignore, + parent: Option>, +} + +fn is_ignored_directory(rules: Option<&Rc>, path: &Path) -> bool { + let mut current = rules; + while let Some(rules) = current { + match rules.matcher.matched(path, true) { + Match::Ignore(_) => return true, + Match::Whitelist(_) => return false, + Match::None => current = rules.parent.as_ref(), + } + } + false +} + +/// Rules from one ignore file, with patterns relative to `directory`. `None` when +/// the file has no pattern the matcher accepts. +fn ignore_matcher(directory: &Path, text: &str) -> Option { + let mut builder = GitignoreBuilder::new(directory); + for (index, line) in text.lines().enumerate() { + // UTF-8 BOM is an encoding marker in Git's ignore files, not a glob. + let line = if index == 0 { + line.strip_prefix('\u{feff}').unwrap_or(line) + } else { + line + }; + // Git skips a pattern it cannot parse and keeps the rest of the file. + let _ = builder.add_line(None, line); + } + builder.build().ok().filter(|matcher| !matcher.is_empty()) +} + +/// An ignore source that cannot be read adds no rules, so the scan sees more, +/// never less. A broken remote transport still fails the scan, as it does for a +/// directory, because it says nothing about the file. +fn read_rules_text( + path: &Path, + read_text: &impl Fn(&Path) -> io::Result, +) -> Result, CoreError> { + match read_text(path) { + Ok(text) => Ok(Some(text)), + Err(error) if fs::is_transport_failure(&error) => Err(CoreError::from(error)), + Err(_) => Ok(None), + } +} + +/// The `info/exclude` of the repository whose worktree is `worktree`. A gitfile +/// (linked worktree or submodule) points at its gitdir; a linked worktree's gitdir +/// names, in `commondir`, the main gitdir that holds the shared `info/exclude`. +fn repository_exclude( + worktree: &Path, + gitdir_is_directory: bool, + read_text: &impl Fn(&Path) -> io::Result, +) -> Result, CoreError> { + let marker = worktree.join(".git"); + let gitdir = if gitdir_is_directory { + marker + } else { + let Some(text) = read_rules_text(&marker, read_text)? else { + return Ok(None); + }; + let Some(target) = text + .lines() + .next() + .and_then(|line| line.strip_prefix("gitdir:")) + .map(str::trim) + .filter(|target| !target.is_empty()) + else { + return Ok(None); + }; + // An absolute target replaces the worktree path; a relative one is + // relative to the gitfile's directory, as Git resolves it. + worktree.join(target) + }; + let common = match read_rules_text(&gitdir.join("commondir"), read_text)? { + Some(text) => match text.lines().next().map(str::trim) { + Some(common) if !common.is_empty() => gitdir.join(common), + _ => gitdir, + }, + None => gitdir, + }; + read_rules_text(&common.join("info").join("exclude"), read_text) +} + +/// The rules for the directory being listed. A directory with its own `.git` +/// starts a repository that Git governs only by its own ignore files, so the +/// enclosing repository's rules stop there. +fn directory_rules( + directory: &Path, + inherited: Option>, + repository: Option, + has_gitignore: bool, + read_text: &impl Fn(&Path) -> io::Result, +) -> Result>, CoreError> { + let mut rules = match repository { + Some(gitdir_is_directory) => repository_exclude(directory, gitdir_is_directory, read_text)? + .and_then(|text| ignore_matcher(directory, &text)) + .map(|matcher| { + Rc::new(IgnoreRules { + matcher, + parent: None, + }) + }), + None => inherited, + }; + if has_gitignore { + if let Some(matcher) = read_rules_text(&directory.join(".gitignore"), read_text)? + .and_then(|text| ignore_matcher(directory, &text)) + { + rules = Some(Rc::new(IgnoreRules { + matcher, + parent: rules, + })); + } + } + Ok(rules) +} + // Consumers decide whether to retain each directory and when enough inputs have // been found. Early stopping must not change full fingerprint scan semantics. -fn visit_with_reader( +fn visit_with_readers( root: &Path, max_depth: Option, read: &impl Fn(&Path) -> io::Result>, + read_text: &impl Fn(&Path) -> io::Result, mut visit: impl FnMut(ScannedDirectory) -> ControlFlow<()>, ) -> Result, CoreError> { + let heuristic = max_depth.is_some(); let mut unreadable_paths = BTreeSet::new(); let mut directory_count = 0; - let mut pending = vec![(root.to_owned(), 0)]; - while let Some((path, depth)) = pending.pop() { + let mut pending: Vec<(PathBuf, usize, Option>)> = + vec![(root.to_owned(), 0, None)]; + while let Some((path, depth, inherited)) = pending.pop() { lithe_protocol::cancellation::check()?; - if max_depth.is_some() && directory_count >= MAX_HEURISTIC_DIRECTORIES { + if heuristic && directory_count >= MAX_HEURISTIC_DIRECTORIES { unreadable_paths.insert(relative(root, &path)); - unreadable_paths.extend(pending.iter().map(|(path, _)| relative(root, path))); + unreadable_paths.extend(pending.iter().map(|(path, _, _)| relative(root, path))); break; } let entries = match read(&path) { @@ -144,7 +313,10 @@ fn visit_with_reader( } }; let mut files = BTreeSet::new(); - let mut children = Vec::new(); + let mut subdirectories = Vec::new(); + // Whether this directory holds a `.git` entry, and if so whether it is a + // gitdir (true) or a gitfile pointing at one (false). + let mut repository = None; for (child, directory, symlink) in entries { lithe_protocol::cancellation::check()?; let Some(name) = child.file_name().and_then(|name| name.to_str()) else { @@ -154,16 +326,35 @@ fn visit_with_reader( if symlink { continue; } + if name == ".git" { + repository = Some(directory); + } if directory { if !is_run_scan_excluded_directory(name) && max_depth.is_none_or(|limit| depth < limit) { - children.push((child, depth + 1)); + subdirectories.push(child); } } else { files.insert(name.to_owned()); } } + let rules = if heuristic { + directory_rules( + &path, + inherited, + repository, + files.contains(".gitignore"), + read_text, + )? + } else { + None + }; + let mut children = subdirectories + .into_iter() + .filter(|child| !is_ignored_directory(rules.as_ref(), child)) + .map(|child| (child, depth + 1, rules.clone())) + .collect::>(); children.sort_by(|left, right| right.0.cmp(&left.0)); pending.extend(children); directory_count += 1; @@ -201,7 +392,7 @@ mod tests { Ok(vec![(path.join("main.txt"), false, false)]) } }; - let unknown = visit_with_reader(root, None, &read, |directory| { + let unknown = visit_with_readers(root, None, &read, &read_ignore_source, |directory| { if directory.files.contains("main.txt") { ControlFlow::Break(()) } else { @@ -262,4 +453,149 @@ mod tests { BTreeSet::from(["main.txt".into()]) ); } + + /// A tree in memory: each directory lists its entries, a trailing `/` marking + /// a directory, and `texts` holds the files the scan reads as rules, keyed by + /// their path with `..` resolved. + struct Tree { + directories: Vec<(&'static str, Vec<&'static str>)>, + texts: Vec<(&'static str, &'static str)>, + } + + fn resolved(path: &Path) -> String { + let mut parts = Vec::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + parts.pop(); + } + std::path::Component::Normal(part) => parts.push(part.to_string_lossy()), + _ => {} + } + } + parts.join("/") + } + + impl Tree { + /// The directories a scan of `root` lists, relative to it. + fn scan(&self, root: &str, max_depth: Option) -> BTreeSet { + let read = |path: &Path| -> io::Result> { + let key = resolved(path); + let (_, entries) = self + .directories + .iter() + .find(|(directory, _)| *directory == key) + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?; + Ok(entries + .iter() + .map(|entry| match entry.strip_suffix('/') { + Some(name) => (path.join(name), true, false), + None => (path.join(entry), false, false), + }) + .collect()) + }; + let read_text = |path: &Path| -> io::Result { + let key = resolved(path); + self.texts + .iter() + .find(|(file, _)| *file == key) + .map(|(_, text)| (*text).to_owned()) + .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound)) + }; + let root = Path::new(root); + let scan = scan_with_readers(root, max_depth, &read, &read_text).unwrap(); + assert!(scan.unreadable_paths.is_empty()); + scan.directories + .iter() + .map(|directory| relative(root, &directory.path)) + .collect() + } + } + + fn set(paths: [&str; N]) -> BTreeSet { + paths.into_iter().map(str::to_owned).collect() + } + + #[test] + fn heuristic_discovery_skips_what_git_ignores_and_full_scans_keep_it() { + let tree = Tree { + directories: vec![ + ( + "fixture", + vec![".git/", ".gitignore", ".claude/", "generated/", "src/"], + ), + ("fixture/.claude", vec!["settings/", "worktrees/"]), + ("fixture/.claude/settings", vec![]), + ("fixture/.claude/worktrees", vec!["copy/"]), + ("fixture/.claude/worktrees/copy", vec![".git", "go.mod"]), + ("fixture/generated", vec!["go.mod"]), + ("fixture/src", vec![".gitignore", "cache/", "generated/"]), + ("fixture/src/cache", vec![]), + ("fixture/src/generated", vec!["go.mod"]), + ], + texts: vec![ + ("fixture/.gitignore", "\u{feff}generated/\n"), + // Agent tools keep their worktrees out of Git here, not in + // `.gitignore`; a pattern Git cannot parse leaves the rest in force. + ( + "fixture/.git/info/exclude", + "# local\n[\n.claude/worktrees/\n", + ), + // A lower file re-includes what the root ignores and adds its own. + ("fixture/src/.gitignore", "!generated/\ncache/\n"), + ], + }; + assert_eq!( + tree.scan("fixture", Some(6)), + set(["", ".claude", ".claude/settings", "src", "src/generated"]) + ); + assert_eq!( + tree.scan("fixture", None), + set([ + "", + ".claude", + ".claude/settings", + ".claude/worktrees", + ".claude/worktrees/copy", + "generated", + "src", + "src/cache", + "src/generated", + ]) + ); + } + + #[test] + fn a_linked_worktree_uses_the_shared_exclude_and_a_nested_repository_its_own_rules() { + let tree = Tree { + directories: vec![ + ("work", vec![".git", "lib/", "logs/"]), + ("work/logs", vec![]), + ("work/lib", vec![".git/", ".gitignore", "logs/", "tmp/"]), + ("work/lib/logs", vec![]), + ("work/lib/tmp", vec![]), + ], + texts: vec![ + ("work/.git", "gitdir: ../main/.git/worktrees/work\n"), + ("main/.git/worktrees/work/commondir", "../..\n"), + ("main/.git/info/exclude", "logs/\n"), + ("work/lib/.gitignore", "tmp/\n"), + ], + }; + // The enclosing `logs/` stops at `lib`, a repository of its own. + assert_eq!(tree.scan("work", Some(6)), set(["", "lib", "lib/logs"])); + } + + #[test] + fn an_ignore_file_that_cannot_be_read_hides_nothing() { + let tree = Tree { + directories: vec![ + ("fixture", vec![".git", ".gitignore", "generated/"]), + ("fixture/generated", vec![]), + ], + // Neither the `.gitignore` nor the gitfile can be read. + texts: vec![], + }; + assert_eq!(tree.scan("fixture", Some(6)), set(["", "generated"])); + } } diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index 62fb578c..d25c5d99 100644 --- a/shared/plugins/contract/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -1386,7 +1386,7 @@ declaration are relative, use `/`, and stay inside their base directory | Field | Meaning | | --- | --- | -| `detection` | `markers` are file names; a directory holding one is a candidate root. `nesting: "outermost"` folds deeper candidates into the outer instance (Gradle builds, Maven reactors); `"separate"` (default) keeps each (Cargo packages). `priority` (0–100, default 50) decides between plugins declaring the same ID; ties go to the smaller plugin ID. | +| `detection` | `markers` are file names; a directory holding one is a candidate root. Core searches at most 6 levels below the workspace root and skips known caches and build outputs, plus directories Git ignores: the rules come from the `.gitignore` files inside the workspace and the `info/exclude` of each repository whose root lies in it, not from directories above the workspace root, `core.excludesFile` or the index, so a tracked manifest below an ignored directory is not a candidate. `nesting: "outermost"` folds deeper candidates into the outer instance (Gradle builds, Maven reactors); `"separate"` (default) keeps each (Cargo packages). `priority` (0–100, default 50) decides between plugins declaring the same ID; ties go to the smaller plugin ID. | | `invocation` | `executables` in preference order: `projectFile` (a wrapper inside the root, with `requires` files; always runs project code), `environment` (a path below a variable listed in `environmentInputs`), `command` (a bare name found on `PATH`), `runtime` (a declared runtime dependency itself). `platforms` limits a candidate. `globalArguments` precede every command. `version` probes the executable with literal `arguments` and a `pattern` whose first group is the version; `minimumVersion` needs a probe. | | `runtimeDependencies` | `node` and `go` are host-managed: only `id` and `minimumVersion`, discovered with the user's runtime settings; `build.invocationPlan` returns the probe arguments Core reads their version with, so hosts keep no per-runtime probe. Any other runtime without `executables` names the [tool definition](toolchains/README.md) with the same ID, so its `id` must be a valid tool-definition ID and it declares only `minimumVersion`: `build.invocationPlan` returns it with `hostManaged: false`, `version: null`, and the single candidate `{ "kind": "toolchain", "toolchainID": , "root": }`, whose resolution checks `minimumVersion`, and `build.checkToolVersion` for it is `plugin.build.unavailable` because Core reads no version. Any other runtime describes its own `executables` and `version` probe, so the runtime list is open; a runtime whose only candidate is a `toolchain` candidate may omit the probe, since the definition checks the version. | | `options` | Build dimensions the user chooses: `boolean`, `singleSelect`, `multiSelect`, `text`, `file`, `directory`. Each contributes `arguments` and `environment` (with `${value}`) when it has a value. Choices are declared, come from the model (`choicesFromModel`), or are free (`allowCustom`). | diff --git a/shared/plugins/reference/domains/build-systems.md b/shared/plugins/reference/domains/build-systems.md index 5934223c..501c9fdf 100644 --- a/shared/plugins/reference/domains/build-systems.md +++ b/shared/plugins/reference/domains/build-systems.md @@ -27,7 +27,7 @@ | 字段 | 决定什么 | | --- | --- | -| `detection` | 用 `markers` 里的文件名识别候选根目录;`nesting` 决定深层候选并入外层实例(`outermost`,如 Gradle、Maven)还是各自独立(`separate`,默认,如 Cargo);`priority` 决定多个插件声明同一个 ID 时谁优先 | +| `detection` | 用 `markers` 里的文件名识别候选根目录。Core 最多向下找 6 层,跳过已知的缓存和构建输出目录,以及被 Git 忽略的目录(规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下即使有已跟踪的清单文件也不算候选);`nesting` 决定深层候选并入外层实例(`outermost`,如 Gradle、Maven)还是各自独立(`separate`,默认,如 Cargo);`priority` 决定多个插件声明同一个 ID 时谁优先 | | `invocation` | 可执行文件候选(`executables`,按偏好排序)、`globalArguments`、版本探测和 `minimumVersion` | | `runtimeDependencies` | 构建工具依赖的运行时。`node`、`go` 由宿主管理;其他运行时要么命名同 ID 的[工具定义](toolchains.md),要么自己声明 `executables` 和版本探测 | | `options` | 用户选择的构建维度:`boolean`、`singleSelect`、`multiSelect`、`text`、`file`、`directory`;每个选项在有值时贡献 `arguments` 和 `environment` | @@ -124,7 +124,7 @@ IDs of build systems, tasks, and run entries start with a lowercase letter and u | Field | What it decides | | --- | --- | -| `detection` | Candidate roots are directories holding one of the `markers` file names; `nesting` folds deeper candidates into the outer instance (`outermost`, such as Gradle and Maven) or keeps each one (`separate`, the default, such as Cargo); `priority` decides between plugins declaring the same ID | +| `detection` | Candidate roots are directories holding one of the `markers` file names. Core searches at most 6 levels down and skips known caches and build outputs, plus directories Git ignores (rules from the `.gitignore` files inside the workspace and the `info/exclude` of each repository rooted there; directories above the workspace root, `core.excludesFile` and the index are not read, so a tracked manifest below an ignored directory is no candidate); `nesting` folds deeper candidates into the outer instance (`outermost`, such as Gradle and Maven) or keeps each one (`separate`, the default, such as Cargo); `priority` decides between plugins declaring the same ID | | `invocation` | Executable candidates (`executables`, in preference order), `globalArguments`, the version probe, and `minimumVersion` | | `runtimeDependencies` | Runtimes the build tool needs. `node` and `go` are host-managed; any other runtime either names the [tool definition](toolchains.md) with the same ID or declares its own `executables` and version probe | | `options` | Build dimensions the user chooses: `boolean`, `singleSelect`, `multiSelect`, `text`, `file`, `directory`; each contributes `arguments` and `environment` when it has a value | diff --git a/shared/plugins/reference/protocol-changelog.md b/shared/plugins/reference/protocol-changelog.md index fe5d80f3..9c9b6b2a 100644 --- a/shared/plugins/reference/protocol-changelog.md +++ b/shared/plugins/reference/protocol-changelog.md @@ -133,6 +133,7 @@ | PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | | --- | --- | --- | --- | --- | +| [#33](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/33) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)和自动运行项发现不再把被 Git 忽略的目录当作候选根目录。规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`(链接工作树读主仓库共享的那份),嵌套仓库另起一套规则;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下已跟踪的清单文件也不再被发现。契约 README 同时写明检测最多向下 6 层。指纹扫描和 Node 清单发现仍然遍历被忽略的目录。 | | [#28](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/28) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的说明修正 | 清单 schema 删除构建系统 `model`、`test.results`、`dependencies` 里不可达的 `source: "hostIntegration"` 分支,以及语言服务器 `hostIntegration` 下的 `capabilities`、`resources`。`hostIntegration` 字段仍是保留字段:构建系统和语言服务器写了任何值,都以 `plugin.integration.unsupported` 拒绝,字段路径不变;`model`、`test.results`、`dependencies` 写 `source: "hostIntegration"` 时按未知来源以 `plugin.manifest.invalid` 拒绝(原先同时写了 `hostIntegration` 名字时报 `plugin.integration.unsupported`)。README 与指南同步删掉这些来源和“社区插件”说法。能力表移到 `rust/crates/plugin-model/src/capabilities.rs`,插件函数协议移到 `rust/crates/plugin-rpc/src/`,跨插件调用规则移到 `rust/crates/plugin-function-relay/`,内容不变。 | ### 2026-11-13