From 4edfec7ec1978b1aeabfd7583d72a41cbbe44d2b Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 13:40:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?test(remote):=20=E5=90=AF=E5=8A=A8=E9=94=81?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=87=8A=E6=94=BE=E5=90=8E=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E5=8A=A0=E9=94=81=E6=97=B6=E5=85=81=E8=AE=B8=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同一测试二进制里的其它测试会启动子进程。fork 出的子进程在 exec 之前持有锁文件描述符的副本,因此也持有锁,释放后立即用当前时刻做期限重新加锁会偶发 TimedOut。 - 释放后的重新加锁改为最多等待 5 秒;独占和超时两条断言不变。 --- rust/crates/remote/src/server/daemon.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rust/crates/remote/src/server/daemon.rs b/rust/crates/remote/src/server/daemon.rs index 875b8e0f..2374766d 100644 --- a/rust/crates/remote/src/server/daemon.rs +++ b/rust/crates/remote/src/server/daemon.rs @@ -467,7 +467,10 @@ mod identity_tests { io::ErrorKind::TimedOut ); drop(owner); - let _next = startup_lock(&path, Instant::now()).unwrap(); + // A test elsewhere in this binary can fork while `owner` is open; the + // child holds a copy of the descriptor, and so the lock, until it + // execs. Allow a bounded wait for that instead of failing at once. + let _next = startup_lock(&path, Instant::now() + Duration::from_secs(5)).unwrap(); } #[test] From 9fa94ce7b33863931ca51e9725a878c37493311a Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 13:40:33 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(build):=20=E6=9E=84=E5=BB=BA=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E8=B7=B3=E8=BF=87=E5=B7=A5=E4=BD=9C=E5=8C=BA=E7=9A=84?= =?UTF-8?q?=E5=BF=BD=E7=95=A5=E8=B7=AF=E5=BE=84=EF=BC=8C=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E6=89=80=E6=9C=89=E4=BB=A5=20.=20=E5=BC=80?= =?UTF-8?q?=E5=A4=B4=E7=9A=84=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 构建选项新增 ignoredPaths:.gitignore 语法,相对工作区根,只对目录生效,最后一条匹配的规则说了算。它可以写在团队文档 .lithe/build/options.json 或本机构建选项里,本机的整份替换团队的;两处都没有时用 Core 的默认值 [".*"],即所有名字以 . 开头的目录(如 .claude/worktrees)。 - 这些规则先于 Git 规则判断,所以 !pattern 能放回被 Git 忽略的目录;共享扫描策略排除的缓存和构建输出仍然跳过。只作用于 build.detect,自动运行项发现、指纹扫描和 Node 清单发现不变。 - lithe-execution 新增 IgnoredPaths 和 scan_run_workspace_ignoring / scan_ignoring;build.detect 新增可选 ignoredPaths 请求字段,省略或为 null 时用默认值,[] 表示不额外忽略。 - build.resolveStoredOptions 返回 ignoredPaths: { patterns, source },source 为 local、team 或 default。 - 新增宿主命令 build.editIgnoredPaths { document, local, patterns }:替换本机列表,null 删除本机列表,回到团队或默认值;patterns 字段必须给出。 - 新增错误码 plugin.build.invalidIgnoredPath,字段为 ignoredPaths[<序号>],指向第一条不被接受的规则:空、首尾有空白、注释、单独的 !、控制字符、无法解析的 glob、超过 1024 字节,或第 257 条起。文档里存了不可用的列表时,整份文档仍报 plugin.build.invalidOptionsDocument。 - 同步更新 build-project-options-v1 schema、rust-core-api.md、契约 README 的 detection 说明、构建系统参考页(中英)和协议更新记录。 --- .../build-project-options-v1.schema.json | 10 + rust/contracts/rust-core-api.md | 14 +- rust/crates/build/src/detect.rs | 12 +- rust/crates/build/src/ignored_paths.rs | 58 +++++ rust/crates/build/src/lib.rs | 3 +- rust/crates/build/src/project_options.rs | 20 +- rust/crates/build/src/stored_options.rs | 65 +++++- rust/crates/core/src/protocol/command.rs | 3 + .../core/src/runtime/dispatcher/build.rs | 3 + .../core/src/tests/build_project_options.rs | 91 ++++++++ rust/crates/core/src/tests/build_systems.rs | 53 +++++ rust/crates/execution/src/detectors/mod.rs | 2 +- rust/crates/execution/src/detectors/scan.rs | 12 +- rust/crates/execution/src/lib.rs | 4 +- rust/crates/execution/src/workspace_scan.rs | 213 ++++++++++++++++-- shared/plugins/contract/v1/README.md | 2 +- .../reference/domains/build-systems.md | 4 +- .../plugins/reference/protocol-changelog.md | 1 + 18 files changed, 536 insertions(+), 34 deletions(-) create mode 100644 rust/crates/build/src/ignored_paths.rs diff --git a/rust/contracts/build-project-options-v1.schema.json b/rust/contracts/build-project-options-v1.schema.json index 43827ace..16cddd5b 100644 --- a/rust/contracts/build-project-options-v1.schema.json +++ b/rust/contracts/build-project-options-v1.schema.json @@ -12,6 +12,16 @@ "version": { "const": 1 }, + "ignoredPaths": { + "description": "Directories build detection skips: .gitignore patterns relative to the workspace root, the last match deciding. Absent, the layer below applies (the team document for a local one, Core's default [\".*\"] for the team's). Core also rejects comments, padded entries, a lone '!', control characters, unparsable globs and entries over 1024 bytes.", + "type": "array", + "maxItems": 256, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + } + }, "systems": { "type": "object", "maxProperties": 32, diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 169764d2..bf599066 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -464,6 +464,7 @@ or removing a command fails until the table changes in the same commit. | `build.projectOptions` | Validate a native team's persisted Build options | | `build.resolveStoredOptions` | Merge checked team and machine-local Build preferences without I/O | | `build.editStoredOption` | Edit one declared option in a machine-local Build document without I/O | +| `build.editIgnoredPaths` | Replace or remove the machine-local ignored paths of build detection without I/O | | `build.invocationPlan` | List a build system's executable candidates, runtime dependencies, and version probe for one platform | | `build.checkToolVersion` | Decide whether a build tool's or runtime's version output meets the declared minimum | | `build.resolveOptions` | Check stored build options against the declaration and return effective values, choices, and the fingerprint | @@ -3215,7 +3216,9 @@ 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 +`core.excludesFile` or the index. `build.detect` additionally prunes the +workspace's ignored paths (see the Build table below), which decide before Git's +rules. 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 @@ -4338,7 +4341,7 @@ provider tasks, tests and run preparation through the existing workspace session. Prepared applications use the existing native Run host. Windows does not yet advertise Provider lifecycle support. -Except `build.detect`, `build.projectOptions` and `build.resolveStoredOptions`, every command accepts +Except `build.detect`, `build.projectOptions`, `build.resolveStoredOptions` and `build.editIgnoredPaths`, every command accepts `{ manifest, buildSystemID, request }`, revalidates the manifest, and answers `plugin.build.unavailable` for an undeclared build system or operation. Results use the `plugins.declarative.*` envelope under the key named below; @@ -4370,10 +4373,11 @@ and the directories are absolute host paths used only as substitution text. | Command | Request | Result | | --- | --- | --- | -| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } }, instanceOptions?: { : { : { : value } } }, instanceDefaults?: { : { : value } }, emptySelections?: { : { : [optionID] } } }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. | -| `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1 and systems; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | -| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { : { : [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. | +| `build.detect` | `{ workspaceRoot, plugins: [{ manifest }], options?: { : { : value } }, instanceOptions?: { : { : { : value } } }, instanceDefaults?: { : { : value } }, emptySelections?: { : { : [optionID] } }, ignoredPaths?: [pattern] }` | `detection`: `{ instances, rejected: [{ index, error }], shadowed: [{ pluginID, buildSystemID, by }] }`; each instance is `{ id, pluginID, buildSystemID, displayName, root, markers, modelSource, optionFingerprint, options, droppedOptions }` plus `model` or `modelError` for `static` and `hostIntegration` sources; a `provider` source has neither. `options` contains accepted stored values after Core scope/root merging, including explicit empty selections; `droppedOptions` names rejected stored values without rewriting the source document. The scan does not enter a directory `ignoredPaths` names; omitted (or `null`), it is Core's default `[".*"]`, every directory whose name starts with a dot, and `[]` ignores nothing beyond the shared scan policy. Hosts pass `storage.ignoredPaths.patterns` from `build.resolveStoredOptions`. An unusable list rejects with `plugin.build.invalidIgnoredPath` (see `build.editIgnoredPaths`). | +| `build.projectOptions` | `{ document }`, host-read persisted v1 Build options, without a manifest | `document`: validated storage with version 1, systems and optional `ignoredPaths`; options, instance defaults, root values, explicit empty selections and saved custom choices are retained. Missing system fields normalize to empty maps; malformed, unsupported or oversized documents reject with `plugin.build.invalidOptionsDocument`. This pure operation never reads or writes a file and does not replace declared option resolution. | +| `build.resolveStoredOptions` | `{ document, local: { version: 1, document, emptySelections?: { : { : [optionID] } } } }`, without a manifest | `storage: { document, local, emptySelections, ignoredPaths: { patterns, source } }`: checked team/local merge; local values override matching keys, unrelated systems/roots/choices stay intact. `ignoredPaths` is the list `build.detect` uses: a local list replaces the team's whole (`source: "local"`), then the team's (`"team"`), then Core's default `[".*"]` (`"default"`). No filesystem I/O. Local documents are machine/workspace preferences, not team files or plugin-native configuration. Their storage syntax is [build-local-options-v1.schema.json](build-local-options-v1.schema.json), with additional cross-field membership and byte limits checked by Core. | | `build.editStoredOption` | `{ document, local, root, optionID, value, modelValues? }` in the standard manifest/build-system envelope | `edit: { local, options }`: changes one declared local preference. `null` removes the local override and restores team/declaration inheritance. Invalid or partially accepted edits reject before persistence. Workspace multi-selections preserve sibling-only values; explicit per-root empty markers apply only to workspace multi-select declarations. Platforms compare both native source revisions, atomically write only the local document and read back. | +| `build.editIgnoredPaths` | `{ document, local, patterns: [pattern] \| null }`, without a manifest; `patterns` is required | `edit: { local, ignoredPaths: { patterns, source } }`: the local document with its `ignoredPaths` replaced, or removed for `null` so the team list or the default applies again; `[]` is kept as an explicit choice to ignore nothing. A pattern uses `.gitignore` syntax relative to the workspace root and names directories only: as in one ignore file the last matching pattern decides, `!pattern` keeps a directory even if Git ignores it (the shared scan policy's caches and build outputs stay pruned), a pattern with a `/` before its end is anchored at the root and any other matches a directory name at any depth. At most 256 patterns of at most 1024 bytes; an empty or whitespace-padded entry, a comment, a lone `!`, a control character or an unparsable glob rejects with `plugin.build.invalidIgnoredPath` at `ignoredPaths[]`, the first entry not accepted (`ignoredPaths` when they cannot be compiled together). The team document is only read; an unusable list stored in either document makes it `plugin.build.invalidOptionsDocument`. Platforms publish the local document as for `build.editStoredOption`. | | `build.invocationPlan` | `{ root, platform: "macos" \| "linux" \| "windows" }` | `invocation`: `{ buildSystemID, root, executables, environmentInputs, runtimeDependencies, version, minimumVersion }` with project-file paths joined to `root`; every runtime dependency with a known probe, host-managed ones included, carries `version: { arguments }` | | `build.checkToolVersion` | `{ runtime?, output }` | `version`: `{ satisfied, version? }`; `runtime` checks that runtime dependency instead of the tool | | `build.resolveModelInputs` | `{revision, sources: [{manifest, instances: [{buildSystemID, root, state, model?, options?}]}]}` from installed enabled current-workspace sources | `modelInputs`: version 1 public snapshot with exact optional producer slots, state, normalized projects/targets/content roots, configuration fingerprint and explicitly shared options; no tasks/run/settings/private functions; see the [plugin contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/plugins/contract/v1/README.md#optional-read-only-model-inputs) | diff --git a/rust/crates/build/src/detect.rs b/rust/crates/build/src/detect.rs index ef84f703..d0227859 100644 --- a/rust/crates/build/src/detect.rs +++ b/rust/crates/build/src/detect.rs @@ -2,11 +2,14 @@ //! //! Detection is one bounded walk shared by every enabled plugin: a plugin //! names marker files and a nesting rule, never code that walks the tree. +//! The walk skips the workspace's ignored paths, a Build option that by +//! default names every directory whose name starts with a dot. //! Instances whose model needs no process (a static file or a host //! integration) come back with their model, so opening a workspace shows //! projects without running a build tool. use super::decode::Workspace; +use super::ignored_paths; use super::manifest::{BuildSystemDeclaration, ModelSource, Nesting, OptionKind, OptionScope}; use super::options; use super::outputs::{model_value, read_model, source_marker}; @@ -40,6 +43,10 @@ pub(crate) struct DetectRequest { /// needed by sibling models. Unknown or changed option kinds ignore markers. #[serde(default)] pub(crate) empty_selections: BTreeMap>>, + /// Directories the walk does not enter, as `build.resolveStoredOptions` + /// reports them; omitted, the default of every dot-named directory. + #[serde(default)] + pub(crate) ignored_paths: Option>, } #[derive(Debug, Deserialize)] @@ -167,6 +174,7 @@ fn scan_directory_root( /// declare one build-system ID, the higher priority answers for it. pub(crate) fn detect(request: DetectRequest) -> Validation { let workspace = Workspace::new(&request.workspace_root)?; + let ignored = ignored_paths::compile(&workspace.path, request.ignored_paths.as_deref())?; let mut rejected = Vec::new(); let mut manifests = Vec::new(); for (index, plugin) in request.plugins.into_iter().enumerate() { @@ -218,7 +226,9 @@ pub(crate) fn detect(request: DetectRequest) -> Validation]`), or the whole list when every entry +/// is a pattern but they cannot be compiled together. +fn invalid(index: usize, count: usize) -> PluginRejection { + let field = if index < count { + format!("ignoredPaths[{index}]") + } else { + "ignoredPaths".to_owned() + }; + PluginRejection::at( + "plugin.build.invalidIgnoredPath", + field, + "The ignored path is not a .gitignore pattern", + ) +} + +/// Compiles the patterns for the workspace at `root`, or the default when +/// `patterns` is `None`. A list holds at most 256 patterns of at most 1024 +/// bytes each. +pub(super) fn compile(root: &Path, patterns: Option<&[String]>) -> Validation { + let Some(patterns) = patterns else { + return compile(root, Some(&DEFAULT.map(str::to_owned))); + }; + let accepted = patterns + .iter() + .take(MAX_PATTERNS) + .take_while(|pattern| pattern.len() <= MAX_PATTERN_BYTES) + .count(); + let ignored = IgnoredPaths::new(root, &patterns[..accepted]) + .map_err(|index| invalid(index, patterns.len()))?; + if accepted < patterns.len() { + return Err(invalid(accepted, patterns.len())); + } + Ok(ignored) +} + +/// Checks a list before it is stored; storage is never relative to one root. +pub(super) fn check(patterns: &[String]) -> Validation<()> { + compile(Path::new("/"), Some(patterns)).map(|_| ()) +} diff --git a/rust/crates/build/src/lib.rs b/rust/crates/build/src/lib.rs index f93d41c0..c17154a0 100644 --- a/rust/crates/build/src/lib.rs +++ b/rust/crates/build/src/lib.rs @@ -20,6 +20,7 @@ mod configuration; mod debug_preparation; mod decode; mod detect; +mod ignored_paths; mod invocation; mod manifest; mod model; @@ -44,7 +45,7 @@ pub use manifest::{ }; pub use project_options::project_options_command; pub use run_start::run_decode_start_command; -pub use stored_options::resolve_stored_options_command; +pub use stored_options::{edit_ignored_paths_command, resolve_stored_options_command}; pub use test_outcome::resolve_test_run_outcome; pub use test_outcome_merge::merge_test_outcomes; diff --git a/rust/crates/build/src/project_options.rs b/rust/crates/build/src/project_options.rs index c238e5b1..8531c41b 100644 --- a/rust/crates/build/src/project_options.rs +++ b/rust/crates/build/src/project_options.rs @@ -1,7 +1,10 @@ //! Validates the existing team Build options document for native stores. //! This is storage syntax only: declarations still own option types, scope, //! defaults and contributions, and platforms own file access and publication. +//! Besides per-system values, a document may carry the workspace's ignored +//! paths for build detection (see `ignored_paths`). +use super::ignored_paths; use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; use lithe_protocol::CoreError; use serde::{Deserialize, Serialize}; @@ -21,6 +24,14 @@ struct Document { // document validator does. Booleans must never become the version number. version: f64, systems: BTreeMap, + /// Absent leaves ignored paths to the layer below: the team document for + /// a local one, the default for the team's. + #[serde( + default, + rename = "ignoredPaths", + skip_serializing_if = "Option::is_none" + )] + ignored_paths: Option>, } #[derive(Default, Deserialize, Serialize)] @@ -99,7 +110,14 @@ pub(super) fn validate(value: Value) -> Validation { return Err(invalid()); } } - Ok(json!({"version":1,"systems":document.systems})) + if let Some(patterns) = &document.ignored_paths { + ignored_paths::check(patterns).map_err(|_| invalid())?; + } + let mut normalized = json!({"version":1,"systems":document.systems}); + if let Some(patterns) = document.ignored_paths { + normalized["ignoredPaths"] = json!(patterns); + } + Ok(normalized) } /// Validates storage without dropping values belonging to inactive plugins or diff --git a/rust/crates/build/src/stored_options.rs b/rust/crates/build/src/stored_options.rs index c030926e..282c2664 100644 --- a/rust/crates/build/src/stored_options.rs +++ b/rust/crates/build/src/stored_options.rs @@ -2,10 +2,10 @@ //! Platforms own reads, conflict checks and atomic publication; no tool is run. use super::manifest::{BuildSystemDeclaration, OptionKind, OptionScope}; -use super::{options, project_options}; +use super::{ignored_paths, options, project_options}; use lithe_plugin_model::{decode_payload, outcome, PluginRejection, Validation}; use lithe_protocol::CoreError; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -42,6 +42,20 @@ pub(super) struct EditRequest { model_values: Option>>, } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct EditIgnoredPathsRequest { + document: Value, + local: Value, + /// Required: `null` removes the local list rather than meaning "unchanged". + #[serde(deserialize_with = "required")] + patterns: Option>, +} + +fn required<'de, D: Deserializer<'de>>(input: D) -> Result>, D::Error> { + Option::deserialize(input) +} + fn invalid() -> PluginRejection { PluginRejection::new( "plugin.build.invalidOptionsDocument", @@ -113,9 +127,22 @@ fn merge(team: &Value, local: &Local) -> Value { .extend(values.as_object().expect("normalized values").clone()); } } + if let Some(patterns) = local.document.get("ignoredPaths") { + document["ignoredPaths"] = patterns.clone(); + } document } +/// The ignored paths detection uses, and which layer set them. +fn effective_ignored_paths(team: &Value, local: &Local) -> Value { + let (patterns, source) = match (local.document.get("ignoredPaths"), team.get("ignoredPaths")) { + (Some(patterns), _) => (patterns.clone(), "local"), + (None, Some(patterns)) => (patterns.clone(), "team"), + (None, None) => (json!(ignored_paths::DEFAULT), "default"), + }; + json!({"patterns":patterns,"source":source}) +} + fn effective( system: &BuildSystemDeclaration, document: &Value, @@ -155,7 +182,12 @@ pub fn resolve_stored_options_command(payload: Value) -> Result Va json!({"local":local,"options":options::describe(system,&resolved,request.model_values.as_ref())}), ) } + +/// `build.editIgnoredPaths`: replaces or removes the local ignored-path list. +/// The team document is only read, to report what applies after the edit. +pub fn edit_ignored_paths_command(payload: Value) -> Result { + let request: EditIgnoredPathsRequest = decode_payload(payload, "build.editIgnoredPaths")?; + Ok(outcome((|| { + let team = project_options::validate(request.document)?; + let mut local = validate_local(request.local)?; + let document = local + .document + .as_object_mut() + .expect("validated local document"); + match request.patterns { + Some(patterns) => { + ignored_paths::check(&patterns)?; + document.insert("ignoredPaths".to_owned(), json!(patterns)); + } + None => { + document.remove("ignoredPaths"); + } + } + // Revalidate the aggregate size bound after the insertion. + let local = validate_local(serde_json::to_value(local).map_err(|_| invalid())?)?; + let ignored = effective_ignored_paths(&team, &local); + Ok(("edit", json!({"local":local,"ignoredPaths":ignored}))) + })())) +} diff --git a/rust/crates/core/src/protocol/command.rs b/rust/crates/core/src/protocol/command.rs index 9e95d7e1..7d3082e3 100644 --- a/rust/crates/core/src/protocol/command.rs +++ b/rust/crates/core/src/protocol/command.rs @@ -399,6 +399,8 @@ pub enum BuildCommand { ResolveStoredOptions, /// Edits one declared option in a machine-local Build document without I/O. EditStoredOption, + /// Replaces or removes the machine-local ignored paths for build detection (`build.editIgnoredPaths`). + EditIgnoredPaths, /// Lists a build system's executable candidates and version probes (`build.invocationPlan`). InvocationPlan, /// Compares a build tool's or runtime's version output with its minimum (`build.checkToolVersion`). @@ -1065,6 +1067,7 @@ impl CoreCommand { "build.projectOptions" => Some(Self::Build(BuildCommand::ProjectOptions)), "build.resolveStoredOptions" => Some(Self::Build(BuildCommand::ResolveStoredOptions)), "build.editStoredOption" => Some(Self::Build(BuildCommand::EditStoredOption)), + "build.editIgnoredPaths" => Some(Self::Build(BuildCommand::EditIgnoredPaths)), "build.invocationPlan" => Some(Self::Build(BuildCommand::InvocationPlan)), "build.checkToolVersion" => Some(Self::Build(BuildCommand::CheckToolVersion)), "build.resolveOptions" => Some(Self::Build(BuildCommand::ResolveOptions)), diff --git a/rust/crates/core/src/runtime/dispatcher/build.rs b/rust/crates/core/src/runtime/dispatcher/build.rs index 4d84150c..22469e7a 100644 --- a/rust/crates/core/src/runtime/dispatcher/build.rs +++ b/rust/crates/core/src/runtime/dispatcher/build.rs @@ -23,6 +23,9 @@ pub(super) fn dispatch(command: BuildCommand, id: Option, payload: Value payload, crate::build::build_edit_stored_option_command::, ), + BuildCommand::EditIgnoredPaths => { + plugin_response(id, payload, crate::build::edit_ignored_paths_command) + } BuildCommand::InvocationPlan => plugin_response( id, payload, diff --git a/rust/crates/core/src/tests/build_project_options.rs b/rust/crates/core/src/tests/build_project_options.rs index 87bbb777..581306b2 100644 --- a/rust/crates/core/src/tests/build_project_options.rs +++ b/rust/crates/core/src/tests/build_project_options.rs @@ -181,3 +181,94 @@ fn native_build_options_preserve_the_existing_document_bounds() { ); } } + +/// Ignored paths for build detection layer like the other Build options: the +/// local list replaces the team's, and without either Core's default applies. +#[test] +fn ignored_paths_resolve_by_layer_and_local_edits_replace_or_remove_the_list() { + let empty_local = + json!({"version":1,"document":{"version":1,"systems":{}},"emptySelections":{}}); + let team = json!({"version":1,"systems":{},"ignoredPaths":[".*","!.config"]}); + let resolve = |team: &Value, local: &Value| { + crate::build::resolve_stored_options_command(json!({"document":team,"local":local})) + .unwrap() + }; + let edit = |team: &Value, local: &Value, patterns: Value| { + crate::build::edit_ignored_paths_command( + json!({"document":team,"local":local,"patterns":patterns}), + ) + .unwrap() + }; + + let plain = json!({"version":1,"systems":{}}); + assert_eq!( + resolve(&plain, &empty_local)["storage"]["ignoredPaths"], + json!({"patterns":[".*"],"source":"default"}) + ); + let teams = resolve(&team, &empty_local); + assert_eq!( + teams["storage"]["ignoredPaths"], + json!({"patterns":[".*","!.config"],"source":"team"}) + ); + assert_eq!( + teams["storage"]["document"]["ignoredPaths"], + json!([".*", "!.config"]) + ); + + let edited = edit(&team, &empty_local, json!(["build/", "\\#literal"])); + assert_eq!(edited["status"], "ok", "{edited}"); + let local = &edited["edit"]["local"]; + assert_eq!( + local["document"]["ignoredPaths"], + json!(["build/", "\\#literal"]) + ); + assert_eq!( + edited["edit"]["ignoredPaths"], + json!({"patterns":["build/","\\#literal"],"source":"local"}) + ); + assert_eq!( + resolve(&team, local)["storage"]["ignoredPaths"]["source"], + "local" + ); + // An empty local list is an explicit choice to ignore nothing. + let nothing = edit(&team, &empty_local, json!([])); + assert_eq!( + nothing["edit"]["ignoredPaths"], + json!({"patterns":[],"source":"local"}) + ); + + let removed = edit(&team, local, Value::Null); + assert_eq!(removed["edit"]["local"], empty_local); + assert_eq!(removed["edit"]["ignoredPaths"]["source"], "team"); + + for (patterns, field) in [ + (json!([".*", ""]), "ignoredPaths[1]"), + (json!(["# comment"]), "ignoredPaths[0]"), + (json!(["ok", "[z-a]"]), "ignoredPaths[1]"), + (json!(["ok", "x".repeat(1025), "!"]), "ignoredPaths[1]"), + (json!(vec!["a"; 257]), "ignoredPaths[256]"), + ] { + let rejected = edit(&team, &empty_local, patterns); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + assert_eq!(rejected["error"]["code"], "plugin.build.invalidIgnoredPath"); + assert_eq!(rejected["error"]["field"], field); + } + // `null` resets the list, so the field must be present rather than + // defaulting to a reset. + assert!( + crate::build::edit_ignored_paths_command(json!({"document":team,"local":empty_local})) + .is_err() + ); + + // A list Core cannot use makes a stored document invalid as a whole, like + // any other malformed value, instead of silently falling back. + let broken = json!({"version":1,"systems":{},"ignoredPaths":["!"]}); + assert_eq!( + project_options_command(json!({"document":broken})).unwrap()["error"]["code"], + "plugin.build.invalidOptionsDocument" + ); + assert_eq!( + resolve(&plain, &json!({"version":1,"document":broken}))["error"]["code"], + "plugin.build.invalidOptionsDocument" + ); +} diff --git a/rust/crates/core/src/tests/build_systems.rs b/rust/crates/core/src/tests/build_systems.rs index b857031e..f20dd782 100644 --- a/rust/crates/core/src/tests/build_systems.rs +++ b/rust/crates/core/src/tests/build_systems.rs @@ -777,6 +777,59 @@ fn detection_skips_directories_git_ignores() { assert_eq!(instance_ids(&detection), ["cargo:.", "cargo:tools"]); } +/// Dot-named directories hold tool state and copies of the workspace, so +/// detection skips them unless the workspace's ignored paths say otherwise; +/// those paths decide before Git, so `!` brings back a directory Git ignores. +#[test] +fn detection_skips_the_ignored_paths_and_by_default_every_dot_directory() { + let package = |name: &str| format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"); + let workspace = TemporaryWorkspace::new("build-detect-ignored-paths"); + workspace + .write(".git/HEAD", "ref: refs/heads/main\n") + .write(".gitignore", "/generated/\n") + .write("Cargo.toml", &package("app")) + .write("tools/Cargo.toml", &package("tools")) + .write("generated/Cargo.toml", &package("generated")) + .write(".cache/copy/Cargo.toml", &package("app")) + .write(".config/plugin/Cargo.toml", &package("plugin")); + let detect_ignoring = |patterns: Value| { + build_detect_command::(json!({ + "workspaceRoot": workspace.root(), + "plugins": [{"manifest": fixture("build-static-file.plugin.json")}], + "ignoredPaths": patterns, + })) + .expect("payload is well formed") + }; + + let omitted = detect( + &workspace, + vec![fixture("build-static-file.plugin.json")], + json!({}), + ); + assert_eq!(instance_ids(&omitted), ["cargo:.", "cargo:tools"]); + assert_eq!(detect_ignoring(Value::Null)["detection"], omitted); + assert_eq!( + instance_ids(&detect_ignoring(json!([]))["detection"]), + [ + "cargo:.", + "cargo:.cache/copy", + "cargo:.config/plugin", + "cargo:tools" + ] + ); + assert_eq!( + instance_ids( + &detect_ignoring(json!([".*", "!.config", "!generated", "/tools"]))["detection"] + ), + ["cargo:.", "cargo:.config/plugin", "cargo:generated"] + ); + + let rejected = detect_ignoring(json!([".*", "# comment"])); + assert_eq!(rejected["status"], "rejected", "{rejected}"); + assert_eq!(rejected["error"]["code"], "plugin.build.invalidIgnoredPath"); + assert_eq!(rejected["error"]["field"], "ignoredPaths[1]"); +} + #[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/execution/src/detectors/mod.rs b/rust/crates/execution/src/detectors/mod.rs index 0bf4bdec..fccbb37f 100644 --- a/rust/crates/execution/src/detectors/mod.rs +++ b/rust/crates/execution/src/detectors/mod.rs @@ -24,7 +24,7 @@ use lithe_protocol::CoreError; use std::collections::BTreeMap; use std::path::Path; -pub use scan::{scan, DirectoryContext}; +pub use scan::{scan, scan_ignoring, DirectoryContext}; /// One runnable thing a detector found. /// diff --git a/rust/crates/execution/src/detectors/scan.rs b/rust/crates/execution/src/detectors/scan.rs index f02ddea5..e2d26501 100644 --- a/rust/crates/execution/src/detectors/scan.rs +++ b/rust/crates/execution/src/detectors/scan.rs @@ -1,5 +1,6 @@ //! Bounded workspace traversal shared by all run-configuration detectors. +use crate::workspace_scan::{scan_run_workspace_ignoring, IgnoredPaths}; use lithe_protocol::{CoreError, ErrorCode}; use lithe_workspace::fs; use std::collections::BTreeSet; @@ -77,7 +78,16 @@ impl DirectoryContext { /// Walks the project, pruning uninteresting subtrees *before* descending rather /// than filtering afterwards, so a `node_modules` tree is never read at all. pub fn scan(root: &Path) -> Result, CoreError> { - let snapshot = super::super::workspace_scan::scan_run_workspace(root, Some(MAX_DEPTH))?; + scan_ignoring(root, &IgnoredPaths::default()) +} + +/// [`scan`] that also prunes the directories `ignored` names, for build +/// detection, whose ignored paths are a Build option of the workspace. +pub fn scan_ignoring( + root: &Path, + ignored: &IgnoredPaths, +) -> Result, CoreError> { + let snapshot = scan_run_workspace_ignoring(root, MAX_DEPTH, ignored)?; snapshot .directories .into_iter() diff --git a/rust/crates/execution/src/lib.rs b/rust/crates/execution/src/lib.rs index e12152d7..a53e6e84 100644 --- a/rust/crates/execution/src/lib.rs +++ b/rust/crates/execution/src/lib.rs @@ -19,11 +19,11 @@ mod types; pub use argument_text::{format_argument_text, ArgumentTextRequest}; pub use configuration::*; pub use context_selection::{select_context_configuration, ContextSelectionRequest}; -pub use detectors::{scan, DirectoryContext}; +pub use detectors::{scan, scan_ignoring, DirectoryContext}; pub use title_action::{decide_title_action, TitleActionRequest}; pub mod workspace_scan; -pub use workspace_scan::is_run_scan_excluded_directory; +pub use workspace_scan::{is_run_scan_excluded_directory, IgnoredPaths}; mod environment; pub use environment::{ diff --git a/rust/crates/execution/src/workspace_scan.rs b/rust/crates/execution/src/workspace_scan.rs index 5ce0ce20..2205c04b 100644 --- a/rust/crates/execution/src/workspace_scan.rs +++ b/rust/crates/execution/src/workspace_scan.rs @@ -13,6 +13,10 @@ //! 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. +//! +//! A heuristic scan can also be given [`IgnoredPaths`], the workspace's own +//! patterns (Build options keep them for build detection). They decide before +//! Git's rules, so `!pattern` brings back a directory Git ignores. use ignore::gitignore::{Gitignore, GitignoreBuilder}; use ignore::Match; @@ -102,6 +106,69 @@ pub fn scan_run_workspace( scan_with_reader(root, max_depth, &read_directory) } +/// A heuristic scan that also skips the directories `ignored` names. Only a +/// heuristic scan takes them, so the depth limit is required here. +pub fn scan_run_workspace_ignoring( + root: &Path, + max_depth: usize, + ignored: &IgnoredPaths, +) -> Result { + scan_with_readers( + root, + Some(max_depth), + &read_directory, + &read_ignore_source, + ignored, + ) +} + +/// Workspace-relative patterns in `.gitignore` syntax, one pattern per entry, +/// that a heuristic scan skips. They only ever name directories, since the scan +/// prunes a directory before listing it, so a file pattern such as `*.txt` +/// skips nothing. As in one ignore file, the last matching pattern decides, and +/// a pattern with a `/` before its end is anchored at the workspace root while +/// any other pattern matches a directory name at any depth (`.*` is every +/// directory whose name starts with a dot). +#[derive(Default)] +pub struct IgnoredPaths { + matcher: Option, +} + +impl IgnoredPaths { + /// Patterns relative to `root`. `Err` carries the index of the first one + /// that is not a pattern: empty, padded with whitespace, holding a control + /// character, a comment (`#`), a lone `!`, or a glob Git cannot parse; it is + /// `patterns.len()` when each parses but they cannot be compiled together. + pub fn new(root: &Path, patterns: &[String]) -> Result { + let mut builder = GitignoreBuilder::new(root); + for (index, pattern) in patterns.iter().enumerate() { + let bare = pattern.strip_prefix('!').unwrap_or(pattern); + if bare.is_empty() + || bare.trim() != bare + || pattern.starts_with('#') + || pattern.chars().any(char::is_control) + || builder.add_line(None, pattern).is_err() + { + return Err(index); + } + } + let matcher = builder.build().map_err(|_| patterns.len())?; + Ok(Self { + matcher: (!matcher.is_empty()).then_some(matcher), + }) + } + + /// Whether the patterns decide about `directory`: `Some(true)` skips it, + /// `Some(false)` keeps it whatever Git says, `None` leaves it to Git. + fn decides(&self, directory: &Path) -> Option { + match self.matcher.as_ref()?.matched(directory, true) { + Match::Ignore(_) => Some(true), + Match::Whitelist(_) => Some(false), + Match::None => None, + } + } +} + fn read_directory(path: &Path) -> io::Result> { fs::read_dir(path)? .map(|entry| { @@ -133,7 +200,13 @@ pub fn scan_with_reader( max_depth: Option, read: &impl Fn(&Path) -> io::Result>, ) -> Result { - scan_with_readers(root, max_depth, read, &read_ignore_source) + scan_with_readers( + root, + max_depth, + read, + &read_ignore_source, + &IgnoredPaths::default(), + ) } // Tests also inject the reader of ignore files, gitfiles and `commondir`. @@ -142,12 +215,14 @@ fn scan_with_readers( max_depth: Option, read: &impl Fn(&Path) -> io::Result>, read_text: &impl Fn(&Path) -> io::Result, + ignored: &IgnoredPaths, ) -> Result { let mut directories = Vec::new(); - let unreadable_paths = visit_with_readers(root, max_depth, read, read_text, |directory| { - directories.push(directory); - ControlFlow::Continue(()) - })?; + let unreadable_paths = + visit_with_readers(root, max_depth, read, read_text, ignored, |directory| { + directories.push(directory); + ControlFlow::Continue(()) + })?; directories.sort_by(|left, right| left.path.cmp(&right.path)); Ok(WorkspaceScan { directories, @@ -163,7 +238,14 @@ struct IgnoreRules { parent: Option>, } -fn is_ignored_directory(rules: Option<&Rc>, path: &Path) -> bool { +fn is_ignored_directory( + ignored: &IgnoredPaths, + rules: Option<&Rc>, + path: &Path, +) -> bool { + if let Some(decision) = ignored.decides(path) { + return decision; + } let mut current = rules; while let Some(rules) = current { match rules.matcher.matched(path, true) { @@ -285,6 +367,7 @@ fn visit_with_readers( max_depth: Option, read: &impl Fn(&Path) -> io::Result>, read_text: &impl Fn(&Path) -> io::Result, + ignored: &IgnoredPaths, mut visit: impl FnMut(ScannedDirectory) -> ControlFlow<()>, ) -> Result, CoreError> { let heuristic = max_depth.is_some(); @@ -352,7 +435,7 @@ fn visit_with_readers( }; let mut children = subdirectories .into_iter() - .filter(|child| !is_ignored_directory(rules.as_ref(), child)) + .filter(|child| !is_ignored_directory(ignored, rules.as_ref(), child)) .map(|child| (child, depth + 1, rules.clone())) .collect::>(); children.sort_by(|left, right| right.0.cmp(&left.0)); @@ -392,13 +475,20 @@ mod tests { Ok(vec![(path.join("main.txt"), false, false)]) } }; - let unknown = visit_with_readers(root, None, &read, &read_ignore_source, |directory| { - if directory.files.contains("main.txt") { - ControlFlow::Break(()) - } else { - ControlFlow::Continue(()) - } - }) + let unknown = visit_with_readers( + root, + None, + &read, + &read_ignore_source, + &IgnoredPaths::default(), + |directory| { + if directory.files.contains("main.txt") { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }, + ) .unwrap(); assert_eq!(unknown, BTreeSet::from(["a-blocked".into()])); let full = scan_with_reader(root, None, &read).unwrap(); @@ -479,6 +569,26 @@ mod tests { impl Tree { /// The directories a scan of `root` lists, relative to it. fn scan(&self, root: &str, max_depth: Option) -> BTreeSet { + self.scan_with(root, max_depth, &IgnoredPaths::default()) + } + + /// The directories a heuristic scan of `root` lists when it also skips + /// `patterns`. + fn scan_ignoring(&self, root: &str, patterns: &[&str]) -> BTreeSet { + let patterns: Vec = patterns + .iter() + .map(|pattern| (*pattern).to_owned()) + .collect(); + let ignored = IgnoredPaths::new(Path::new(root), &patterns).unwrap(); + self.scan_with(root, Some(6), &ignored) + } + + fn scan_with( + &self, + root: &str, + max_depth: Option, + ignored: &IgnoredPaths, + ) -> BTreeSet { let read = |path: &Path| -> io::Result> { let key = resolved(path); let (_, entries) = self @@ -503,7 +613,7 @@ mod tests { .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(); + let scan = scan_with_readers(root, max_depth, &read, &read_text, ignored).unwrap(); assert!(scan.unreadable_paths.is_empty()); scan.directories .iter() @@ -539,7 +649,7 @@ mod tests { // `.gitignore`; a pattern Git cannot parse leaves the rest in force. ( "fixture/.git/info/exclude", - "# local\n[\n.claude/worktrees/\n", + "# local\n[z-a]\n.claude/worktrees/\n", ), // A lower file re-includes what the root ignores and adds its own. ("fixture/src/.gitignore", "!generated/\ncache/\n"), @@ -598,4 +708,75 @@ mod tests { }; assert_eq!(tree.scan("fixture", Some(6)), set(["", "generated"])); } + + #[test] + fn ignored_paths_decide_before_git_and_the_last_matching_pattern_wins() { + let tree = Tree { + directories: vec![ + ( + "fixture", + vec![ + ".git/", + ".gitignore", + ".claude/", + ".config/", + "generated/", + "tools/", + "src/", + ], + ), + ("fixture/.claude", vec!["worktrees/"]), + ("fixture/.claude/worktrees", vec![]), + ("fixture/.config", vec![]), + ("fixture/generated", vec![]), + ("fixture/tools", vec!["fixtures/"]), + ("fixture/tools/fixtures", vec![]), + ("fixture/src", vec!["tools/", ".hidden/"]), + ("fixture/src/tools", vec![]), + ("fixture/src/.hidden", vec![]), + ], + texts: vec![("fixture/.gitignore", "generated/\n")], + }; + // `.*` skips every dot directory at any depth; Git still skips `generated`. + assert_eq!( + tree.scan_ignoring("fixture", &[".*"]), + set(["", "src", "src/tools", "tools", "tools/fixtures"]) + ); + // A later `!` keeps one dot directory and overrides Git, and a pattern + // with a leading `/` matches only below the root. + assert_eq!( + tree.scan_ignoring("fixture", &[".*", "!.config", "!generated", "/tools"]), + set(["", ".config", "generated", "src", "src/tools"]) + ); + // No pattern leaves only Git's rules. + assert_eq!( + tree.scan_ignoring("fixture", &[]), + tree.scan("fixture", Some(6)) + ); + } + + #[test] + fn an_ignored_path_list_names_the_first_entry_that_is_not_a_pattern() { + let root = Path::new("fixture"); + let check = |list: &[&str]| { + let patterns: Vec = list.iter().map(|pattern| (*pattern).to_owned()).collect(); + IgnoredPaths::new(root, &patterns).err() + }; + assert_eq!( + check(&[".*", "!.config", "build/", "a/**/b", "\\#literal"]), + None + ); + for (list, index) in [ + (&["ok", ""][..], 1), + (&[" padded"][..], 0), + (&["trailing "][..], 0), + (&["# comment"][..], 0), + (&["!"][..], 0), + (&["! spaced"][..], 0), + (&["line\nbreak"][..], 0), + (&["ok", "[z-a]"][..], 1), + ] { + assert_eq!(check(list), Some(index), "{list:?}"); + } + } } diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index d25c5d99..70f6fee2 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. 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. | +| `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. Detection also skips the workspace's ignored paths, a Build option the user edits (`.gitignore` patterns stored in the team or machine-local Build options): by default every directory whose name starts with a dot, such as `.claude/worktrees`, and the user can list more or bring one back with `!`. `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 501c9fdf..683cd4fc 100644 --- a/shared/plugins/reference/domains/build-systems.md +++ b/shared/plugins/reference/domains/build-systems.md @@ -27,7 +27,7 @@ | 字段 | 决定什么 | | --- | --- | -| `detection` | 用 `markers` 里的文件名识别候选根目录。Core 最多向下找 6 层,跳过已知的缓存和构建输出目录,以及被 Git 忽略的目录(规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下即使有已跟踪的清单文件也不算候选);`nesting` 决定深层候选并入外层实例(`outermost`,如 Gradle、Maven)还是各自独立(`separate`,默认,如 Cargo);`priority` 决定多个插件声明同一个 ID 时谁优先 | +| `detection` | 用 `markers` 里的文件名识别候选根目录。Core 最多向下找 6 层,跳过已知的缓存和构建输出目录,以及被 Git 忽略的目录(规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下即使有已跟踪的清单文件也不算候选)。检测还会跳过工作区的忽略路径:这是用户在构建选项里编辑的 `.gitignore` 规则,存放在团队或本机的构建选项中,默认是所有名字以 `.` 开头的目录(如 `.claude/worktrees`),用户可以再添加,也可以用 `!` 放回某个目录;`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. 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 | +| `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). Detection also skips the workspace's ignored paths, `.gitignore` patterns the user edits in the team or machine-local Build options: by default every directory whose name starts with a dot, such as `.claude/worktrees`, and the user can add more or bring one back with `!`; `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 9c9b6b2a..acbde07e 100644 --- a/shared/plugins/reference/protocol-changelog.md +++ b/shared/plugins/reference/protocol-changelog.md @@ -133,6 +133,7 @@ | PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | | --- | --- | --- | --- | --- | +| [#35](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/35) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)还会跳过工作区的忽略路径:`.gitignore` 语法、相对工作区根、只对目录生效,存在团队构建选项 `.lithe/build/options.json` 或本机构建选项的 `ignoredPaths` 中,本机的整份替换团队的;都没有时默认 `[".*"]`,即所有名字以 `.` 开头的目录(如 `.claude/worktrees`)。这些规则先于 Git 规则判断,`!` 可以放回 Git 忽略的目录,共享扫描策略排除的缓存和构建输出仍然跳过。新增宿主命令 `build.editIgnoredPaths` 和错误码 `plugin.build.invalidIgnoredPath`(字段 `ignoredPaths[<序号>]`);`build.resolveStoredOptions` 返回 `ignoredPaths: { patterns, source }`。自动运行项发现、指纹扫描和 Node 清单发现不受影响。 | | [#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/`,内容不变。 |