From b3472dd2160a7714b687da27ed98bb45c2e4d842 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Wed, 28 Oct 2026 14:28:04 +0800 Subject: [PATCH 1/3] feat(spec): let a provider declare the inputs a compile read LPP 1.7 adds the independently negotiated compileInputs capability. An entry-based lpp/compile result then lists every file and directory listing the compile read, as sorted absolute file URIs, outside the opaque artifact. The reference mock provider implements it, fixtures 69-70 cover the advertised and unadvertised sessions, and the runner rejects the field without the negotiated capability. ADR 0007 records the field shape and the rejected alternatives. Fixes #105 --- README.md | 2 +- conformance/README.md | 4 +- .../fixtures/v1/69-compile-inputs.json | 15 ++ .../v1/70-compile-inputs-unavailable.json | 15 ++ conformance/fixtures/v1/sessions/1.7.json | 31 +++ conformance/mock-provider/src/methods.rs | 17 +- conformance/mock-provider/src/project.rs | 25 ++- conformance/mock-provider/src/server.rs | 7 +- conformance/runner/src/invariants.rs | 185 ++++++++++++++++++ conformance/runner/src/main.rs | 1 + conformance/runner/src/matcher.rs | 2 + conformance/runner/src/session.rs | 38 ++++ docs/adr/0007-declared-compile-inputs.md | 94 +++++++++ docs/adr/README.md | 1 + docs/spec/README.md | 2 +- docs/spec/analysis-and-compilation.md | 14 ++ docs/spec/conformance.md | 11 +- docs/spec/errors-and-versioning.md | 8 +- docs/spec/project-loading.md | 11 +- 19 files changed, 455 insertions(+), 28 deletions(-) create mode 100644 conformance/fixtures/v1/69-compile-inputs.json create mode 100644 conformance/fixtures/v1/70-compile-inputs-unavailable.json create mode 100644 conformance/fixtures/v1/sessions/1.7.json create mode 100644 conformance/runner/src/invariants.rs create mode 100644 docs/adr/0007-declared-compile-inputs.md diff --git a/README.md b/README.md index ddac7a9..f63dd77 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ its internal AST. [`opy-rs`](https://github.com/wrightkit/opy-rs) ships `opy-provider`, the LPP provider Wright uses for OverPy. The spec covers protocol versions 1.0 -to 1.6. +to 1.7. ## Implementing a provider diff --git a/conformance/README.md b/conformance/README.md index 9ba5e92..89044be 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -1,6 +1,6 @@ # LPP v1 Conformance Suite -This directory contains the test suite and fixtures for the Language Provider Protocol v1 wire contract, including the LPP 1.1 file-entry, LPP 1.2 directory-target project-loading, LPP 1.3 source-identity, LPP 1.4 artifact-format negotiation, LPP 1.5 name-lookup, and LPP 1.6 canonical-id-resolution revisions (see [`../docs/spec/README.md`](../docs/spec/README.md)). +This directory contains the test suite and fixtures for the Language Provider Protocol v1 wire contract, including the LPP 1.1 file-entry, LPP 1.2 directory-target project-loading, LPP 1.3 source-identity, LPP 1.4 artifact-format negotiation, LPP 1.5 name-lookup, LPP 1.6 canonical-id-resolution, and LPP 1.7 compile-inputs revisions (see [`../docs/spec/README.md`](../docs/spec/README.md)). ## Layout @@ -12,7 +12,7 @@ mock-provider/ Reference provider for the "x-demo-lang" equation DSL (Ru runner/ Conformance runner that replays fixtures against any provider binary ``` -* **Fixtures** (`fixtures/v1/`): one JSON file per scenario. Each scenario defines a session with request/response steps, optional CLI flags, and the expected exit code. Responses are compared after JSON parsing so key order does not matter. The directory contains LPP 1.0, LPP 1.1, LPP 1.2, LPP 1.3, LPP 1.4, LPP 1.5, and LPP 1.6 scenarios. `fixtures/v1/sessions/` holds the standard initialize/shutdown handshake once per protocol version; a scenario whose steps don't themselves exercise the handshake sets `"session": ""` and the runner splices those steps around its own. +* **Fixtures** (`fixtures/v1/`): one JSON file per scenario. Each scenario defines a session with request/response steps, optional CLI flags, and the expected exit code. Responses are compared after JSON parsing so key order does not matter. The directory contains LPP 1.0, LPP 1.1, LPP 1.2, LPP 1.3, LPP 1.4, LPP 1.5, LPP 1.6, and LPP 1.7 scenarios. `fixtures/v1/sessions/` holds the standard initialize/shutdown handshake once per protocol version; a scenario whose steps don't themselves exercise the handshake sets `"session": ""` and the runner splices those steps around its own. * **Mock provider** (`mock-provider/`): a small Rust binary implementing the full LPP v1 surface for a demonstration language distinct from OPY and OSTW. It runs over stdio so clients (like the Wright LPP client in wrightkit/wright#142) can test against it directly. * **Runner** (`runner/`): spawns a fresh provider process per scenario, feeds requests over stdin, validates stdout responses against expectations, and checks the process exit code. Expected responses compare verbatim except at leaves a fixture marks provider-supplied (see the scenario format below). diff --git a/conformance/fixtures/v1/69-compile-inputs.json b/conformance/fixtures/v1/69-compile-inputs.json new file mode 100644 index 0000000..b863df0 --- /dev/null +++ b/conformance/fixtures/v1/69-compile-inputs.json @@ -0,0 +1,15 @@ +{ + "name": "compile-inputs", + "description": "LPP 1.7 advertises compile inputs and returns every file and directory listing the entry-based compile read; a file the provider never reads is not an input.", + "scope": "semantics", + "session": "1.7", + "providerArgs": ["--protocol-version", "1.7"], + "projectFiles": { + "entry.xdl": "puzzle entry {\n target = 2\n start = 1\n ops {\n plus1: x => x + 1\n }\n solution = [ plus1 ]\n}\n", + "notes.txt": "not a source file\n", + "support.xdl": "puzzle support {\n target = 2\n start = 1\n ops {\n plus1: x => x + 1\n }\n solution = [ plus1 ]\n}\n" + }, + "steps": [ + {"request":{"id":2,"method":"lpp/compile","params":{"entry":{"uri":"${PROJECT_URI}","languageId":"x-demo-lang","version":7,"kind":"directory"}}},"expectResponse":{"result":{"diagnostics":[{"uri":"${PROJECT_URI}/entry.xdl","version":7,"diagnostics":[]},{"uri":"${PROJECT_URI}/support.xdl","version":7,"diagnostics":[]}],"compileInputs":["${PROJECT_URI}","${PROJECT_URI}/entry.xdl","${PROJECT_URI}/support.xdl"],"sourceIdentity":"a92a4daf6631cbb574095cef0296d3d8217459a48e7899f98d832bb63f510822","artifact":{"format":"x-demo/puzzle-eval-v1","content":"{\"name\":\"entry\",\"ops\":[{\"arg\":1,\"name\":\"plus1\",\"op\":\"+\"}],\"solution\":[\"plus1\"],\"start\":1,\"target\":2,\"value\":2}"}}}} + ] +} diff --git a/conformance/fixtures/v1/70-compile-inputs-unavailable.json b/conformance/fixtures/v1/70-compile-inputs-unavailable.json new file mode 100644 index 0000000..c051f15 --- /dev/null +++ b/conformance/fixtures/v1/70-compile-inputs-unavailable.json @@ -0,0 +1,15 @@ +{ + "name": "compile-inputs-unavailable", + "description": "An LPP 1.7 provider may decline compile inputs independently; entry compilation remains available and the result omits the field.", + "scope": "protocol", + "providerArgs": ["--protocol-version", "1.7", "--without", "compileInputs"], + "projectFiles": { + "entry.xdl": "puzzle entry {\n target = 2\n start = 1\n ops {\n plus1: x => x + 1\n }\n solution = [ plus1 ]\n}\n", + "support.xdl": "puzzle support {\n target = 2\n start = 1\n ops {\n plus1: x => x + 1\n }\n solution = [ plus1 ]\n}\n" + }, + "steps": [ + {"request":{"id":1,"method":"lpp/initialize","params":{"protocolVersion":"1.7"}},"expectResponse":{"result":{"protocolVersion":"1.7","serverInfo":{"name":{"$provider":"nonEmptyString"},"version":{"$provider":"nonEmptyString"}},"languages":{"$provider":"languageList"},"capabilities":{"check":true,"compile":true,"reconstruct":true,"symbols":true,"definition":true,"references":true,"rename":true,"editValidation":true,"projectLoading":true,"sourceIdentity":true,"lookup":true,"resolveIds":true,"compileInputs":false}}}}, + {"request":{"id":2,"method":"lpp/compile","params":{"entry":{"uri":"${PROJECT_URI}","languageId":"x-demo-lang","version":7,"kind":"directory"}}},"expectResponse":{"result":{"diagnostics":[{"uri":"${PROJECT_URI}/entry.xdl","version":7,"diagnostics":[]},{"uri":"${PROJECT_URI}/support.xdl","version":7,"diagnostics":[]}],"sourceIdentity":"a92a4daf6631cbb574095cef0296d3d8217459a48e7899f98d832bb63f510822","artifact":{"format":"x-demo/puzzle-eval-v1","content":"{\"name\":\"entry\",\"ops\":[{\"arg\":1,\"name\":\"plus1\",\"op\":\"+\"}],\"solution\":[\"plus1\"],\"start\":1,\"target\":2,\"value\":2}"}}}}, + {"request":{"id":3,"method":"lpp/shutdown","params":{}},"expectResponse":{"result":null}} + ] +} diff --git a/conformance/fixtures/v1/sessions/1.7.json b/conformance/fixtures/v1/sessions/1.7.json new file mode 100644 index 0000000..a33edb0 --- /dev/null +++ b/conformance/fixtures/v1/sessions/1.7.json @@ -0,0 +1,31 @@ +{ + "initialize": { + "request": { "id": "init", "method": "lpp/initialize", "params": { "protocolVersion": "1.7" } }, + "expectResponse": { + "result": { + "protocolVersion": "1.7", + "serverInfo": { "name": { "$provider": "nonEmptyString" }, "version": { "$provider": "nonEmptyString" } }, + "languages": { "$provider": "languageList" }, + "capabilities": { + "check": { "$provider": "boolean" }, + "compile": { "$provider": "boolean" }, + "reconstruct": { "$provider": "boolean" }, + "symbols": { "$provider": "boolean" }, + "definition": { "$provider": "boolean" }, + "references": { "$provider": "boolean" }, + "rename": { "$provider": "boolean" }, + "editValidation": { "$provider": "boolean" }, + "projectLoading": { "$provider": "boolean" }, + "sourceIdentity": { "$provider": "boolean" }, + "lookup": { "$provider": "boolean" }, + "resolveIds": { "$provider": "boolean" }, + "compileInputs": { "$provider": "boolean" } + } + } + } + }, + "shutdown": { + "request": { "id": "shutdown", "method": "lpp/shutdown", "params": {} }, + "expectResponse": { "result": null } + } +} diff --git a/conformance/mock-provider/src/methods.rs b/conformance/mock-provider/src/methods.rs index 4b01ddf..220a11b 100644 --- a/conformance/mock-provider/src/methods.rs +++ b/conformance/mock-provider/src/methods.rs @@ -88,17 +88,17 @@ impl Server { fn compile(&self, params: Value) -> Result { let accepted = accepted_artifact_formats(self, ¶ms)?; - let (documents, entry_uri) = + let (documents, loaded) = documents_for_request(self, parse_params(params)?, "lpp/compile")?; - if entry_uri.is_none() && documents.len() != 1 { + if loaded.is_none() && documents.len() != 1 { return Err(HandlerError::refusal( "compile.requiresSingleDocument", json!({}), "compile requires exactly one document", )); } - let entry = match &entry_uri { - Some(uri) => &documents[uri], + let entry = match &loaded { + Some(loaded) => &documents[&loaded.uri], None => documents.values().next().expect("len == 1"), }; check_document(entry)?; @@ -127,9 +127,12 @@ impl Server { json!({ "format": format, "content": content }) }; let mut result = json!({ "diagnostics": diagnostics, "artifact": artifact }); - if self.capability("sourceIdentity") { - if let Some(uri) = &entry_uri { - result["sourceIdentity"] = json!(sha256_hex(&documents[uri].text)); + if let Some(loaded) = &loaded { + if self.capability("sourceIdentity") { + result["sourceIdentity"] = json!(sha256_hex(&entry.text)); + } + if self.capability("compileInputs") { + result["compileInputs"] = json!(loaded.inputs); } } Ok(result) diff --git a/conformance/mock-provider/src/project.rs b/conformance/mock-provider/src/project.rs index 45eaaca..1b6e890 100644 --- a/conformance/mock-provider/src/project.rs +++ b/conformance/mock-provider/src/project.rs @@ -8,11 +8,16 @@ use crate::rpc::HandlerError; use crate::server::{LANGUAGE_ID, Server}; use crate::wire::{DocsParams, Document, ProjectEntry}; +pub(crate) struct LoadedEntry { + pub(crate) uri: String, + pub(crate) inputs: Vec, +} + pub(crate) fn documents_for_request( server: &Server, params: DocsParams, method: &str, -) -> Result<(HashMap, Option), HandlerError> { +) -> Result<(HashMap, Option), HandlerError> { match (params.documents, params.entry) { (Some(documents), None) => Ok((documents, None)), (None, Some(entry)) => { @@ -26,12 +31,12 @@ pub(crate) fn documents_for_request( "capability 'projectLoading' is not available", )); } - let (documents, entry_uri) = Loader { + let (documents, loaded) = Loader { server, entry: &entry, } .run()?; - Ok((documents, Some(entry_uri))) + Ok((documents, Some(loaded))) } _ => Err(HandlerError::invalid_params()), } @@ -65,7 +70,7 @@ impl Loader<'_> { HandlerError::lpp("projectLoadFailed", details, message) } - fn run(self) -> Result<(HashMap, String), HandlerError> { + fn run(self) -> Result<(HashMap, LoadedEntry), HandlerError> { let entry = self.entry; if entry.version < 0 { return Err(self.invalid( @@ -234,6 +239,16 @@ impl Loader<'_> { }, ); } - Ok((documents, canonical_entry_uri)) + let mut inputs: Vec = documents.keys().cloned().collect(); + inputs + .push(path_to_file_uri(&root).expect("the entry has a URI, so its directory has one")); + inputs.sort(); + Ok(( + documents, + LoadedEntry { + uri: canonical_entry_uri, + inputs, + }, + )) } } diff --git a/conformance/mock-provider/src/server.rs b/conformance/mock-provider/src/server.rs index 27ca6ca..5322222 100644 --- a/conformance/mock-provider/src/server.rs +++ b/conformance/mock-provider/src/server.rs @@ -6,7 +6,8 @@ use crate::methods::METHODS; use crate::rpc::{lpp_error, ok, parse_params, std_error}; use crate::wire::InitParams; -pub(crate) const PROTOCOL_VERSIONS: [&str; 7] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"]; +pub(crate) const PROTOCOL_VERSIONS: [&str; 8] = + ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]; pub(crate) const DEFAULT_PROTOCOL_VERSION: &str = PROTOCOL_VERSIONS[0]; pub(crate) const LANGUAGE_ID: &str = "x-demo-lang"; @@ -67,6 +68,10 @@ const CAPABILITIES: &[Capability] = &[ name: "resolveIds", min_minor: 6, }, + Capability { + name: "compileInputs", + min_minor: 7, + }, ]; fn minor_of(version: &str) -> Option { diff --git a/conformance/runner/src/invariants.rs b/conformance/runner/src/invariants.rs new file mode 100644 index 0000000..e55f137 --- /dev/null +++ b/conformance/runner/src/invariants.rs @@ -0,0 +1,185 @@ +//! Wire invariants checked on every actual response, independent of what a +//! fixture expects, because a fixture with provider-supplied capabilities +//! cannot know which optional fields the provider must or must not return. + +use serde_json::Value; + +#[derive(Default)] +pub(crate) struct Invariants { + compile_inputs: bool, +} + +impl Invariants { + pub(crate) fn check(&mut self, request: &Value, response: &Value) -> Result<(), String> { + let Some(result) = response.get("result") else { + return Ok(()); + }; + match request.get("method").and_then(Value::as_str) { + Some("lpp/initialize") => { + let minor = result + .get("protocolVersion") + .and_then(Value::as_str) + .and_then(|version| version.strip_prefix("1.")) + .and_then(|minor| minor.parse::().ok()); + self.compile_inputs = minor.is_some_and(|minor| minor >= 7) + && result.pointer("/capabilities/compileInputs") == Some(&Value::Bool(true)); + Ok(()) + } + Some("lpp/compile") => { + let entry_based = request.pointer("/params/entry").is_some(); + check_compile_inputs(self.compile_inputs, entry_based, result) + } + _ => Ok(()), + } + } +} + +fn check_compile_inputs(negotiated: bool, entry_based: bool, result: &Value) -> Result<(), String> { + let Some(inputs) = result.get("compileInputs") else { + if negotiated && entry_based { + return Err("entry-based compile result omits 'compileInputs' although the session negotiated the 'compileInputs' capability".into()); + } + return Ok(()); + }; + if !negotiated { + return Err("compile result includes 'compileInputs' without the negotiated 'compileInputs' capability".into()); + } + if !entry_based { + return Err("'compileInputs' is defined only for entry-based compile results".into()); + } + let uris = inputs + .as_array() + .and_then(|uris| uris.iter().map(Value::as_str).collect::>>()) + .filter(|uris| uris.iter().all(|uri| uri.starts_with("file:///"))) + .ok_or("'compileInputs' must be an array of absolute file URIs")?; + if !uris.windows(2).all(|pair| pair[0] < pair[1]) { + return Err("'compileInputs' must be sorted and free of duplicates".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn initialize(version: &str, compile_inputs: bool) -> Invariants { + let mut invariants = Invariants::default(); + invariants + .check( + &json!({"method": "lpp/initialize"}), + &json!({"result": {"protocolVersion": version, "capabilities": {"compileInputs": compile_inputs}}}), + ) + .expect("initialize carries no compile invariant"); + invariants + } + + fn compile(invariants: &mut Invariants, params: Value, result: Value) -> Result<(), String> { + invariants.check( + &json!({"method": "lpp/compile", "params": params}), + &json!({"result": result}), + ) + } + + fn entry() -> Value { + json!({"entry": {"uri": "file:///p", "languageId": "x", "version": 1}}) + } + + fn with_inputs(inputs: Value) -> Value { + json!({"diagnostics": [], "artifact": null, "compileInputs": inputs}) + } + + #[test] + fn rejects_inputs_without_the_capability() { + for mut invariants in [ + Invariants::default(), + initialize("1.7", false), + initialize("1.6", true), + ] { + let error = compile(&mut invariants, entry(), with_inputs(json!(["file:///p"]))) + .expect_err("field without capability"); + assert!(error.contains("without the negotiated"), "got: {error}"); + } + } + + #[test] + fn accepts_inputs_with_the_capability() { + let mut invariants = initialize("1.7", true); + assert!( + compile( + &mut invariants, + entry(), + with_inputs(json!(["file:///p", "file:///p/a.xdl", "file:///p/b.xdl"])) + ) + .is_ok() + ); + assert!(compile(&mut invariants, entry(), with_inputs(json!([]))).is_ok()); + } + + #[test] + fn accepts_omission_without_the_capability() { + let mut invariants = initialize("1.7", false); + assert!( + compile( + &mut invariants, + entry(), + json!({"diagnostics": [], "artifact": null}) + ) + .is_ok() + ); + } + + #[test] + fn requires_inputs_for_entry_compiles_once_negotiated() { + let mut invariants = initialize("1.7", true); + assert!( + compile( + &mut invariants, + entry(), + json!({"diagnostics": [], "artifact": null}) + ) + .is_err() + ); + let documents = json!({"documents": {}}); + assert!( + compile( + &mut invariants, + documents.clone(), + json!({"diagnostics": [], "artifact": null}) + ) + .is_ok() + ); + assert!(compile(&mut invariants, documents, with_inputs(json!([]))).is_err()); + } + + #[test] + fn rejects_malformed_inputs() { + let mut invariants = initialize("1.7", true); + for inputs in [ + json!("file:///p"), + json!([1]), + json!(["relative/a.xdl"]), + json!(["file:///p/b.xdl", "file:///p/a.xdl"]), + json!(["file:///p/a.xdl", "file:///p/a.xdl"]), + ] { + assert!( + compile(&mut invariants, entry(), with_inputs(inputs.clone())).is_err(), + "should reject {inputs}" + ); + } + } + + #[test] + fn error_responses_are_not_checked() { + let mut invariants = Invariants::default(); + assert!( + invariants + .check( + &json!({"method": "lpp/compile", "params": entry()}), + &json!({"error": {}}) + ) + .is_ok() + ); + } +} diff --git a/conformance/runner/src/main.rs b/conformance/runner/src/main.rs index 3a01594..cfbf491 100644 --- a/conformance/runner/src/main.rs +++ b/conformance/runner/src/main.rs @@ -1,3 +1,4 @@ +mod invariants; mod matcher; mod project_fixture; mod scenario; diff --git a/conformance/runner/src/matcher.rs b/conformance/runner/src/matcher.rs index 82e3a4c..8c1ddb7 100644 --- a/conformance/runner/src/matcher.rs +++ b/conformance/runner/src/matcher.rs @@ -22,6 +22,7 @@ const CAPABILITY_IDS: &[&str] = &[ "sourceIdentity", "lookup", "resolveIds", + "compileInputs", ]; fn declared_capability_ids(root: &Value) -> Option<&'static [&'static str]> { @@ -31,6 +32,7 @@ fn declared_capability_ids(root: &Value) -> Option<&'static [&'static str]> { "1.3" | "1.4" => 10, "1.5" => 11, "1.6" => 12, + "1.7" => 13, _ => return None, }; Some(&CAPABILITY_IDS[..count]) diff --git a/conformance/runner/src/session.rs b/conformance/runner/src/session.rs index 0e9e836..388307e 100644 --- a/conformance/runner/src/session.rs +++ b/conformance/runner/src/session.rs @@ -6,6 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::Value; +use crate::invariants::Invariants; use crate::project_fixture::{ProjectFixture, materialize_project, substitute_project_uri}; use crate::scenario::{Scenario, Step, StepRequest}; @@ -40,6 +41,7 @@ pub(crate) fn run_scenario( let _ = done_tx.send(()); }); + let mut invariants = Invariants::default(); for (i, step) in scenario.steps.iter().enumerate() { let request_line = step_request_line(step, project.as_ref()); if writeln!(stdin, "{request_line}") @@ -75,6 +77,11 @@ pub(crate) fn run_scenario( return Err(format!("step {i}: response is not valid JSON: {e}")); } }; + let request: Value = serde_json::from_str(&request_line).unwrap_or(Value::Null); + if let Err(detail) = invariants.check(&request, &actual) { + kill(&mut child); + return Err(format!("step {i}: wire invariant violated: {detail}")); + } let expected = substitute_project_uri(&step.expect_response, project.as_ref()); if let Err(detail) = crate::matcher::matches(&expected, &actual) { kill(&mut child); @@ -279,6 +286,37 @@ mod tests { assert!(error.contains("unexpected line"), "got: {error}"); } + #[test] + fn compile_inputs_without_capability_fails_even_when_expected() { + let dir = scratch("compile-inputs"); + let script = dir.join("provider.sh"); + fs::write( + &script, + "#!/bin/sh\nread line\necho '{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"1.7\",\"capabilities\":{\"compileInputs\":false}}}'\nread line\necho '{\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"diagnostics\":[],\"artifact\":null,\"compileInputs\":[]}}'\nexit 0\n", + ) + .unwrap(); + let initialize = json!({"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"1.7","capabilities":{"compileInputs":false}}}); + let compiled = json!({"jsonrpc":"2.0","id":2,"result":{"diagnostics":[],"artifact":null,"compileInputs":[]}}); + let steps = vec![ + Step { + request: StepRequest::Request( + json!({"jsonrpc":"2.0","id":1,"method":"lpp/initialize","params":{"protocolVersion":"1.7"}}), + ), + expect_response: initialize, + }, + Step { + request: StepRequest::Request( + json!({"jsonrpc":"2.0","id":2,"method":"lpp/compile","params":{"entry":{"uri":"file:///p","languageId":"x","version":1}}}), + ), + expect_response: compiled, + }, + ]; + let error = run_scenario(&scenario(&script, vec![], steps), "/bin/sh", 0) + .expect_err("invariant rejects the field"); + fs::remove_dir_all(&dir).ok(); + assert!(error.contains("wire invariant violated"), "got: {error}"); + } + #[test] fn raw_line_substitutes_project_uri() { let dir = scratch("rawline"); diff --git a/docs/adr/0007-declared-compile-inputs.md b/docs/adr/0007-declared-compile-inputs.md new file mode 100644 index 0000000..31294f0 --- /dev/null +++ b/docs/adr/0007-declared-compile-inputs.md @@ -0,0 +1,94 @@ +# ADR 0007: Provider-Declared Compile Inputs + +- Status: Accepted +- Decision date: 2026-10-28 (UTC) +- Backfilled: 2026-10-28 (UTC) +- Scope: LPP 1.7 entry-based `lpp/compile` results + +## Context + +A client that caches a compile result, such as `wright serve` +([wrightkit/wright#1005](https://github.com/wrightkit/wright/issues/1005)), +must know when the result is stale. The provider owns source loading +([ADR 0001](0001-provider-owned-project-loading.md)), so only the provider +knows which files a compile read. Without that knowledge the client hashes the +whole project directory on every request. + +The OPY provider already carries a `files` table inside its artifact. That +table is inside the opaque artifact payload (spec Section 10.2), and released +OPY providers omit compile-time `.js` inputs, so a client cannot trust it. On +2026-10-28 the owner chose a provider-neutral LPP declaration over an OPY +version check +([Issue #105](https://github.com/wrightkit/language-provider-protocol/issues/105)). + +## Decision + +LPP 1.7 adds an independently negotiated `compileInputs` capability. When a +provider advertises `compileInputs: true`, every entry-based compile result +MUST include `compileInputs`: a sorted, duplicate-free array of absolute +`file:` URIs. The array lists every file whose content the compile read and +every directory whose listing it read, including non-source files and paths +that were probed but missing. The provider guarantees that a change to a path +that is neither listed nor an entry of a listed directory cannot change the +result. + +The field is gated like `sourceIdentity` ([ADR 0003](0003-owner-selected-source-identity.md)): +a client that needs it requests `"1.7"` and requires the capability. If the +capability is false, the field is omitted. Document-supplied compile results +and results in sessions before LPP 1.7 never include it. + +The conformance runner checks this rule on every actual compile response, +independent of the fixture: it rejects the field without the negotiated +capability, on a document-supplied result, or with a malformed shape, and it +rejects an entry-based result that omits the field after the capability was +negotiated. + +The normative contract is in +[specification sections 7.3, 7.4, 10.2, and 19](../spec/README.md); the +evidence is [fixtures 69–70](../../conformance/fixtures/v1/). + +## Consequences + +- A client can hash only the declared inputs and keep a cached result while + none of them change. A client that sees no capability keeps whole-tree + hashing. +- The artifact stays opaque. The declaration is a result field, not artifact + content. +- The provider must track every read the compile makes, including directory + listings and failed probes. A provider that cannot do this advertises the + capability as false. +- The declaration is a set of paths, not a content hash. The client still + hashes the listed files and listings itself. + +## Alternatives considered + +### Completeness flag for the artifact `files` table + +A boolean result field stating that the artifact's own file table is complete. +Smallest provider change for OPY. Rejected because the client must read the +artifact payload to learn the inputs, which breaks the opaque-artifact +boundary, and because a provider without such a table gains nothing. + +### Input URI list without directory listings + +Simpler to produce. Rejected because a provider that discovers sources by +listing a directory changes its result when a file is added there. Without the +directory in the set, the guarantee would be false for directory targets. + +### Content digests per input + +The provider returns a hash per input. Rejected because the client must hash +the files itself to detect later edits; provider digests add a protocol-level +hash and ordering model with no consumer need. + +### Provider version check in the client + +Rejected by the owner: it is a name and version heuristic, specific to one +provider, and outside the protocol contract. + +## Historical evidence + +- [Issue #105](https://github.com/wrightkit/language-provider-protocol/issues/105) + states the need and constraints. +- [wrightkit/wright#1005](https://github.com/wrightkit/wright/issues/1005) + is the consumer that needs the declaration. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0e6d376..bad7414 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,6 +21,7 @@ material process or wire-boundary decision. | [0004](0004-artifact-format-negotiation.md) | Client-stated artifact format negotiation for `lpp/compile` | Accepted | 2026-09-24 | 2026-09-24 | [Issue #39](https://github.com/wrightkit/language-provider-protocol/issues/39), [workshop-rs#271](https://github.com/wrightkit/workshop-rs/issues/271) | | [0005](0005-name-lookup.md) | Provider-owned name lookup via `lpp/lookup` | Accepted | 2026-10-05 | 2026-10-05 | [Issue #45](https://github.com/wrightkit/language-provider-protocol/issues/45), [wright#482](https://github.com/wrightkit/wright/issues/482) | | [0006](0006-canonical-id-resolution.md) | Provider-owned canonical id resolution via `lpp/resolveIds` | Accepted | 2026-10-11 | 2026-10-11 | [Issue #55](https://github.com/wrightkit/language-provider-protocol/issues/55) | +| [0007](0007-declared-compile-inputs.md) | Provider-declared compile inputs for entry-based `lpp/compile` | Accepted | 2026-10-28 | 2026-10-28 | [Issue #105](https://github.com/wrightkit/language-provider-protocol/issues/105), [wright#1005](https://github.com/wrightkit/wright/issues/1005) | ## Post-baseline audit diff --git a/docs/spec/README.md b/docs/spec/README.md index 97904a5..26d8da9 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| Protocol versions | `1.0`, `1.1`, `1.2`, `1.3`, `1.4`, `1.5`, `1.6` | +| Protocol versions | `1.0`, `1.1`, `1.2`, `1.3`, `1.4`, `1.5`, `1.6`, `1.7` | | Status | Normative for protocol major version 1 | | Transport | JSON-RPC 2.0 over stdio, newline-delimited framing | | Conformance | `conformance/fixtures/v1/` + `conformance/runner` + `conformance/mock-provider` | diff --git a/docs/spec/analysis-and-compilation.md b/docs/spec/analysis-and-compilation.md index de3ec5e..e418264 100644 --- a/docs/spec/analysis-and-compilation.md +++ b/docs/spec/analysis-and-compilation.md @@ -97,6 +97,20 @@ document-supplied request that contains more than one document. The provider owns effective entry selection. If the capability is false, the field MUST be omitted. Document-supplied requests MAY omit it. LPP 1.1 and 1.2 compile results MUST NOT include this field. +* `compileInputs`: defined only in LPP 1.7 and later; independently optional. + If the provider advertises `compileInputs: true`, every entry-based compile + result MUST include it, whether or not `artifact` is `null`. It is an array + of absolute `file:` URIs, sorted by code-point order and without duplicates. + It lists every file whose content the compile read and every directory whose + listing the compile read, including files that are not source documents + (for example compile-time scripts) and paths the provider tried to read but + found missing. The provider guarantees that creating, editing, or deleting + a path that is neither listed nor an entry of a listed directory cannot + change the result. The field lives outside the opaque artifact, so a client + never reads the artifact payload to learn the inputs. If the capability is + false, the field MUST be omitted. Document-supplied compile results MUST NOT include it, and LPP 1.6 + and earlier compile results MUST NOT include it. See + [ADR 0007](../adr/0007-declared-compile-inputs.md). * `artifact`: the compiled Workshop artifact, or `null`. * The `artifact` MUST be `null` whenever any error-severity diagnostic is reported. The provider MAY return `null` artifact in other failure cases. diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index af29be9..2b37d10 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -16,10 +16,13 @@ contract: scenario's `session` field. The same directory covers LPP 1.0, its LPP 1.1 file-entry revision, its LPP 1.2 directory-target revision, its LPP 1.3 source-identity revision, its LPP 1.4 artifact-format negotiation - revision, its LPP 1.5 name-lookup revision, and its LPP 1.6 - canonical-id-resolution revision. + revision, its LPP 1.5 name-lookup revision, its LPP 1.6 + canonical-id-resolution revision, and its LPP 1.7 compile-inputs revision. * `conformance/runner/`: a runner that replays fixtures against any provider - binary and compares responses. + binary and compares responses. It also rejects any `lpp/compile` result + that violates the `compileInputs` rules of + [Section 10.2](analysis-and-compilation.md#102-result), whatever the + fixture expects. * `conformance/mock-provider/`: the reference provider for the demonstration language `x-demo-lang` (a puzzle/equation DSL). The mock is spawnable as a stdio binary so client-side integration can use it @@ -81,7 +84,7 @@ Methods: | `lpp/initialize` | none | `{ protocolVersion, clientInfo? }` | `{ protocolVersion, serverInfo, languages, capabilities }` | | `lpp/shutdown` | none | `{}` | `null` | | `lpp/check` | `check`; plus `projectLoading` for an LPP 1.1+ `entry` request | `{ documents, projectRoot?, locale? }` or `{ entry, projectRoot?, locale? }` | `{ documents: [{ uri, version, diagnostics }] }` | -| `lpp/compile` | `compile`; plus `projectLoading` for an LPP 1.1+ `entry` request and `sourceIdentity` for an LPP 1.3+ entry result | `{ documents, projectRoot?, locale?, acceptedArtifactFormats? }` or `{ entry, projectRoot?, locale?, acceptedArtifactFormats? }` (`acceptedArtifactFormats`: LPP 1.4+) | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, artifact }` | +| `lpp/compile` | `compile`; plus `projectLoading` for an LPP 1.1+ `entry` request `sourceIdentity` for an LPP 1.3+ entry result, and `compileInputs` for an LPP 1.7+ entry result | `{ documents, projectRoot?, locale?, acceptedArtifactFormats? }` or `{ entry, projectRoot?, locale?, acceptedArtifactFormats? }` (`acceptedArtifactFormats`: LPP 1.4+) | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, compileInputs?, artifact }` | | `lpp/reconstruct` | `reconstruct` | `{ artifact }` | `{ source, uri? }` | | `lpp/symbols` | `symbols` | `{ documents, projectRoot? }` | `{ documents: [{ uri, version, symbols }] }` | | `lpp/definition` | `definition` | `{ document, position }` | `{ locations }` | diff --git a/docs/spec/errors-and-versioning.md b/docs/spec/errors-and-versioning.md index e5a89e6..636aff9 100644 --- a/docs/spec/errors-and-versioning.md +++ b/docs/spec/errors-and-versioning.md @@ -90,8 +90,9 @@ clients MUST NOT parse `message`. optional `sourceIdentity` capability for entry-based compile results, and LPP 1.4 adds `acceptedArtifactFormats` artifact format negotiation to `lpp/compile`. LPP 1.5 adds the optional `lookup` capability and the - `lpp/lookup` method, and LPP 1.6 adds the optional `resolveIds` - capability and the `lpp/resolveIds` method. + `lpp/lookup` method, LPP 1.6 adds the optional `resolveIds` + capability and the `lpp/resolveIds` method, and LPP 1.7 adds the optional + `compileInputs` capability for entry-based compile results. * `MAJOR` changes are breaking: message shapes, method semantics, or framing may change. A breaking change always produces a new MAJOR version, and clients and providers speaking different MAJOR versions are never expected @@ -127,7 +128,8 @@ clients MUST NOT parse `message`. `"1.4"` or later. Clients that need name lookup MUST request `"1.5"` or later and require `lookup: true` in the result capabilities. Clients that need canonical id resolution MUST request `"1.6"` or later and require - `resolveIds: true`. + `resolveIds: true`. Clients that need the compile inputs MUST request + `"1.7"` or later and require `compileInputs: true`. * A provider MUST list in `supportedProtocolVersions` exactly the versions it supports: every listed version MUST be negotiable, and no supported version may be omitted. diff --git a/docs/spec/project-loading.md b/docs/spec/project-loading.md index d70f32b..df373ba 100644 --- a/docs/spec/project-loading.md +++ b/docs/spec/project-loading.md @@ -23,7 +23,7 @@ Initialization and capability negotiation. The client MUST send | Field | Type | Description | | --- | --- | --- | -| `protocolVersion` | string | The protocol version the client wants to speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, or `"1.6"`. | +| `protocolVersion` | string | The protocol version the client wants to speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, `"1.6"`, or `"1.7"`. | | `clientInfo` | object, OPTIONAL | `{ "name": string, "version": string }` identifying the client. | ### 7.2 Result @@ -50,10 +50,10 @@ Initialization and capability negotiation. The client MUST send | Field | Type | Description | | --- | --- | --- | -| `protocolVersion` | string | The protocol version the provider will speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, or `"1.6"`. | +| `protocolVersion` | string | The protocol version the provider will speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, `"1.4"`, `"1.5"`, `"1.6"`, or `"1.7"`. | | `serverInfo` | object | `{ "name": string, "version": string }` identifying the provider. | | `languages` | array | REQUIRED non-empty array with one entry per source language the provider serves; each entry is exactly `{ "id": , "extensions": [] }`. | -| `capabilities` | object | The capabilities the provider advertises, as one boolean field per capability id. The object itself is required; its keys are drawn from the capability ids declared for the negotiated version (Section 7.3): LPP 1.0 declares the eight listed below, LPP 1.1 adds `projectLoading`, LPP 1.3 adds `sourceIdentity`, and LPP 1.5 adds `lookup`, and LPP 1.6 adds `resolveIds`. A provider MAY advertise any subset — an absent id means not offered, equivalent to `false`. | +| `capabilities` | object | The capabilities the provider advertises, as one boolean field per capability id. The object itself is required; its keys are drawn from the capability ids declared for the negotiated version (Section 7.3): LPP 1.0 declares the eight listed below, LPP 1.1 adds `projectLoading`, LPP 1.3 adds `sourceIdentity`, LPP 1.5 adds `lookup`, LPP 1.6 adds `resolveIds`, and LPP 1.7 adds `compileInputs`. A provider MAY advertise any subset — an absent id means not offered, equivalent to `false`. | Each language entry: `{ "id": string, "extensions": [string] }`. `extensions` is the list of file extensions the provider associates with the language, @@ -79,6 +79,7 @@ extension. | `sourceIdentity` | `lpp/compile` | Return the provider-selected primary source identity for an entry-based compile result. LPP 1.3 and later. | | `lookup` | `lpp/lookup` | Resolve free-text name guesses to language spellings and signature facts. LPP 1.5 and later. | | `resolveIds` | `lpp/resolveIds` | Map canonical Workshop ids to language spellings. LPP 1.6 and later. | +| `compileInputs` | `lpp/compile` | Declare the complete set of filesystem inputs an entry-based compile read. LPP 1.7 and later. | * The provider MUST set each capability to `true` only if it fully implements the corresponding method(s). @@ -121,7 +122,9 @@ supported version. LPP 1.0 clients MUST send `"1.0"`; clients using file-entry project loading MUST send `"1.1"` or later; clients using directory targets MUST send `"1.2"` or later. A client that requires the `sourceIdentity` capability MUST request `"1.3"` or later and require the provider to -advertise `sourceIdentity: true` before using entry-based compile results. +advertise `sourceIdentity: true` before using entry-based compile results. A +client that requires the `compileInputs` capability MUST request `"1.7"` or +later and require the provider to advertise `compileInputs: true`. ## 8. Common request parameters From cbb41e9a651ba772f3211c2b8262e467a880161d Mon Sep 17 00:00:00 2001 From: Teakowa Date: Wed, 28 Oct 2026 14:43:16 +0800 Subject: [PATCH 2/3] fix(spec): define compile inputs by observable path state State the compileInputs guarantee as a function of the listed paths: existence and kind, symlink targets, file content, and directory entry names and kinds. An edit to an unlisted file inside a listed directory no longer counts as a possible change. Require every followed symbolic link to be listed with its resolved path. The mock now declares traversed paths before resolution, and a stdio test covers a symlinked source. Restore the list punctuation in the conformance method table. Refs #105 --- .../fixtures/v1/69-compile-inputs.json | 2 +- conformance/mock-provider/src/project.rs | 32 ++++++++++++++---- conformance/mock-provider/tests/stdio.rs | 33 +++++++++++++++++++ docs/adr/0007-declared-compile-inputs.md | 30 +++++++++++------ docs/spec/analysis-and-compilation.md | 28 ++++++++++++---- docs/spec/conformance.md | 2 +- 6 files changed, 102 insertions(+), 25 deletions(-) diff --git a/conformance/fixtures/v1/69-compile-inputs.json b/conformance/fixtures/v1/69-compile-inputs.json index b863df0..b2b5532 100644 --- a/conformance/fixtures/v1/69-compile-inputs.json +++ b/conformance/fixtures/v1/69-compile-inputs.json @@ -1,6 +1,6 @@ { "name": "compile-inputs", - "description": "LPP 1.7 advertises compile inputs and returns every file and directory listing the entry-based compile read; a file the provider never reads is not an input.", + "description": "LPP 1.7 advertises compile inputs and returns every path the entry-based compile accessed. The project directory is listed because the compile listed its entries; notes.txt is not listed because its content is never read, so editing it cannot change the result.", "scope": "semantics", "session": "1.7", "providerArgs": ["--protocol-version", "1.7"], diff --git a/conformance/mock-provider/src/project.rs b/conformance/mock-provider/src/project.rs index 1b6e890..930af6b 100644 --- a/conformance/mock-provider/src/project.rs +++ b/conformance/mock-provider/src/project.rs @@ -1,5 +1,5 @@ -use std::collections::HashMap; -use std::path::PathBuf; +use std::collections::{BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; use lpp_conformance_common::{file_uri_to_path, path_to_file_uri}; use serde_json::json; @@ -107,6 +107,7 @@ impl Loader<'_> { )); }; + let mut traversed = vec![path.clone()]; let path = std::fs::canonicalize(&path).map_err(|_| { let (reason, message) = match target { Target::File => ("entryNotFound", "project entry could not be loaded"), @@ -138,6 +139,7 @@ impl Loader<'_> { )); } let selected = path.join("entry.xdl"); + traversed.push(selected.clone()); if !selected.is_file() { return Err(self.fail( "defaultEntryNotFound", @@ -205,6 +207,8 @@ impl Loader<'_> { } paths.sort(); + traversed.push(root.clone()); + traversed.extend(paths.iter().cloned()); let mut documents = HashMap::new(); for path in paths { let unresolved_uri = path_to_file_uri(&path); @@ -239,10 +243,13 @@ impl Loader<'_> { }, ); } - let mut inputs: Vec = documents.keys().cloned().collect(); - inputs - .push(path_to_file_uri(&root).expect("the entry has a URI, so its directory has one")); - inputs.sort(); + let inputs = documents + .keys() + .cloned() + .chain(declared_paths(&traversed).filter_map(|path| path_to_file_uri(&path))) + .collect::>() + .into_iter() + .collect(); Ok(( documents, LoadedEntry { @@ -252,3 +259,16 @@ impl Loader<'_> { )) } } + +/// Each traversed path plus every symbolic link among its ancestors, so a +/// retargeted link shows up as a changed declared input. +fn declared_paths(traversed: &[PathBuf]) -> impl Iterator + '_ { + traversed.iter().flat_map(|path| { + path.ancestors() + .skip(1) + .filter(|ancestor| ancestor.is_symlink()) + .chain(std::iter::once(path.as_path())) + .map(Path::to_path_buf) + .collect::>() + }) +} diff --git a/conformance/mock-provider/tests/stdio.rs b/conformance/mock-provider/tests/stdio.rs index f54d7ae..11c6118 100644 --- a/conformance/mock-provider/tests/stdio.rs +++ b/conformance/mock-provider/tests/stdio.rs @@ -150,3 +150,36 @@ fn project_load_failure_names_the_broken_source_uri() { let uri = lpp["details"]["uri"].as_str().unwrap_or_default(); assert!(uri.ends_with("/broken.xdl"), "details.uri: {uri}"); } + +#[cfg(unix)] +#[test] +fn compile_inputs_declare_followed_symlinks() { + let base = project_dir("compile-inputs-symlink"); + let root = base.join("proj"); + std::fs::create_dir_all(base.join("lib")).unwrap(); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("entry.xdl"), VALID).unwrap(); + std::fs::write(base.join("lib/a.xdl"), VALID).unwrap(); + std::os::unix::fs::symlink("../lib/a.xdl", root.join("support.xdl")).unwrap(); + let root = root.canonicalize().unwrap(); + let init = + r#"{"jsonrpc":"2.0","id":1,"method":"lpp/initialize","params":{"protocolVersion":"1.7"}}"#; + let request = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"lpp/compile","params":{{"entry":{{"uri":"file://{}","languageId":"x-demo-lang","version":1,"kind":"directory"}}}}}}"#, + root.display() + ); + let responses = run_session_args( + &["--protocol-version", "1.7"], + format!("{init}\n{request}\n").as_bytes(), + ); + std::fs::remove_dir_all(&base).ok(); + let inputs = &responses[1]["result"]["compileInputs"]; + let link = format!("file://{}/support.xdl", root.display()); + let target = format!("file://{}/lib/a.xdl", root.parent().unwrap().display()); + for uri in [&link, &target] { + assert!( + inputs.as_array().unwrap().contains(&serde_json::json!(uri)), + "{uri} missing from {inputs}" + ); + } +} diff --git a/docs/adr/0007-declared-compile-inputs.md b/docs/adr/0007-declared-compile-inputs.md index 31294f0..5d9f201 100644 --- a/docs/adr/0007-declared-compile-inputs.md +++ b/docs/adr/0007-declared-compile-inputs.md @@ -26,11 +26,14 @@ version check LPP 1.7 adds an independently negotiated `compileInputs` capability. When a provider advertises `compileInputs: true`, every entry-based compile result MUST include `compileInputs`: a sorted, duplicate-free array of absolute -`file:` URIs. The array lists every file whose content the compile read and -every directory whose listing it read, including non-source files and paths -that were probed but missing. The provider guarantees that a change to a path -that is neither listed nor an entry of a listed directory cannot change the -result. +`file:` URIs. The array lists every path the compile accessed, in the form it +was accessed: files whose content it read (including non-source files), +directories whose entries it listed, paths it probed but found missing, and +every symbolic link it followed, together with the link's resolved path. The +result depends only on the existence and kind of each listed path, the target +of each listed link, the content of each listed file, and the entry names and +kinds of each listed directory. A change that leaves these unchanged cannot +change the result. The field is gated like `sourceIdentity` ([ADR 0003](0003-owner-selected-source-identity.md)): a client that needs it requests `"1.7"` and requires the capability. If the @@ -49,16 +52,18 @@ evidence is [fixtures 69–70](../../conformance/fixtures/v1/). ## Consequences -- A client can hash only the declared inputs and keep a cached result while - none of them change. A client that sees no capability keeps whole-tree +- A client can record the observations of the declared inputs and keep a + cached result while none of them change. Editing an unlisted file inside a + listed directory does not invalidate the result. A client that sees no capability keeps whole-tree hashing. - The artifact stays opaque. The declaration is a result field, not artifact content. -- The provider must track every read the compile makes, including directory - listings and failed probes. A provider that cannot do this advertises the +- The provider must track every access the compile makes, including directory + listings, failed probes, and followed symbolic links. A provider that cannot do this advertises the capability as false. - The declaration is a set of paths, not a content hash. The client still - hashes the listed files and listings itself. + hashes the listed files and observes the listed directories and links + itself. ## Alternatives considered @@ -75,6 +80,11 @@ Simpler to produce. Rejected because a provider that discovers sources by listing a directory changes its result when a file is added there. Without the directory in the set, the guarantee would be false for directory targets. +### Canonical paths only + +Each input as its resolved path. Rejected because retargeting a symbolic link +then changes the result while no declared input changes. + ### Content digests per input The provider returns a hash per input. Rejected because the client must hash diff --git a/docs/spec/analysis-and-compilation.md b/docs/spec/analysis-and-compilation.md index e418264..837066f 100644 --- a/docs/spec/analysis-and-compilation.md +++ b/docs/spec/analysis-and-compilation.md @@ -101,13 +101,27 @@ document-supplied request that contains more than one document. If the provider advertises `compileInputs: true`, every entry-based compile result MUST include it, whether or not `artifact` is `null`. It is an array of absolute `file:` URIs, sorted by code-point order and without duplicates. - It lists every file whose content the compile read and every directory whose - listing the compile read, including files that are not source documents - (for example compile-time scripts) and paths the provider tried to read but - found missing. The provider guarantees that creating, editing, or deleting - a path that is neither listed nor an entry of a listed directory cannot - change the result. The field lives outside the opaque artifact, so a client - never reads the artifact payload to learn the inputs. If the capability is + It lists every path the compile accessed, in the form it was accessed and + before any symbolic link is resolved: every file whose content the compile + read, including files that are not source documents (for example + compile-time scripts); every directory whose entries the compile listed; + every path the provider tried to access but found missing; and every + symbolic link the provider followed, including a link among the ancestors + of an accessed path. When the provider follows a link, it also lists the + path the link resolves to. + The result depends only on these observations of the listed paths: + * for each listed path: whether it exists, and its kind (file, directory, + or symbolic link), observed without following a final symbolic link; + * for each listed symbolic link: its target; + * for each listed file: its content; + * for each listed directory: the names and kinds of its entries, observed + without following symbolic links. + + A client compares these observations to decide whether a cached result is + stale. A change that leaves all of them unchanged cannot change the result; + for example, editing a file that is not listed has no effect even if it is + an entry of a listed directory. The field lives outside the opaque artifact, + so a client never reads the artifact payload to learn the inputs. If the capability is false, the field MUST be omitted. Document-supplied compile results MUST NOT include it, and LPP 1.6 and earlier compile results MUST NOT include it. See [ADR 0007](../adr/0007-declared-compile-inputs.md). diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index 2b37d10..5bcf1d4 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -84,7 +84,7 @@ Methods: | `lpp/initialize` | none | `{ protocolVersion, clientInfo? }` | `{ protocolVersion, serverInfo, languages, capabilities }` | | `lpp/shutdown` | none | `{}` | `null` | | `lpp/check` | `check`; plus `projectLoading` for an LPP 1.1+ `entry` request | `{ documents, projectRoot?, locale? }` or `{ entry, projectRoot?, locale? }` | `{ documents: [{ uri, version, diagnostics }] }` | -| `lpp/compile` | `compile`; plus `projectLoading` for an LPP 1.1+ `entry` request `sourceIdentity` for an LPP 1.3+ entry result, and `compileInputs` for an LPP 1.7+ entry result | `{ documents, projectRoot?, locale?, acceptedArtifactFormats? }` or `{ entry, projectRoot?, locale?, acceptedArtifactFormats? }` (`acceptedArtifactFormats`: LPP 1.4+) | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, compileInputs?, artifact }` | +| `lpp/compile` | `compile`; plus `projectLoading` for an LPP 1.1+ `entry` request, `sourceIdentity` for an LPP 1.3+ entry result, and `compileInputs` for an LPP 1.7+ entry result | `{ documents, projectRoot?, locale?, acceptedArtifactFormats? }` or `{ entry, projectRoot?, locale?, acceptedArtifactFormats? }` (`acceptedArtifactFormats`: LPP 1.4+) | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, compileInputs?, artifact }` | | `lpp/reconstruct` | `reconstruct` | `{ artifact }` | `{ source, uri? }` | | `lpp/symbols` | `symbols` | `{ documents, projectRoot? }` | `{ documents: [{ uri, version, symbols }] }` | | `lpp/definition` | `definition` | `{ document, position }` | `{ locations }` | From f0159e8d0701dc23104bf1d227954cf0321f44ed Mon Sep 17 00:00:00 2001 From: Teakowa Date: Wed, 28 Oct 2026 14:46:10 +0800 Subject: [PATCH 3/3] fix(mock-provider): declare every link followed while resolving inputs Resolve each traversed path component by component and record every symbolic link on the way, including links in a chain and linked directory components. A stdio test covers a two-link chain and a linked directory. Refs #105 --- conformance/mock-provider/src/project.rs | 41 +++++++++++++++++++----- conformance/mock-provider/tests/stdio.rs | 37 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/conformance/mock-provider/src/project.rs b/conformance/mock-provider/src/project.rs index 930af6b..967f480 100644 --- a/conformance/mock-provider/src/project.rs +++ b/conformance/mock-provider/src/project.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeSet, HashMap}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use lpp_conformance_common::{file_uri_to_path, path_to_file_uri}; use serde_json::json; @@ -260,15 +260,40 @@ impl Loader<'_> { } } -/// Each traversed path plus every symbolic link among its ancestors, so a +/// Each traversed path plus every symbolic link followed while resolving it, +/// including links in a chain and linked directory components, so a /// retargeted link shows up as a changed declared input. fn declared_paths(traversed: &[PathBuf]) -> impl Iterator + '_ { traversed.iter().flat_map(|path| { - path.ancestors() - .skip(1) - .filter(|ancestor| ancestor.is_symlink()) - .chain(std::iter::once(path.as_path())) - .map(Path::to_path_buf) - .collect::>() + let mut declared = vec![path.clone()]; + let mut hops = 0; + resolve(path, &mut declared, &mut hops); + declared }) } + +const MAX_LINK_HOPS: u32 = 40; + +fn resolve(path: &Path, links: &mut Vec, hops: &mut u32) -> PathBuf { + let mut resolved = PathBuf::from("/"); + for component in path.components() { + match component { + Component::Normal(name) => { + let next = resolved.join(name); + match std::fs::read_link(&next) { + Ok(target) if *hops < MAX_LINK_HOPS => { + *hops += 1; + links.push(next); + resolved = resolve(&resolved.join(target), links, hops); + } + _ => resolved = next, + } + } + Component::ParentDir => { + resolved.pop(); + } + Component::RootDir | Component::CurDir | Component::Prefix(_) => {} + } + } + resolved +} diff --git a/conformance/mock-provider/tests/stdio.rs b/conformance/mock-provider/tests/stdio.rs index 11c6118..26df95f 100644 --- a/conformance/mock-provider/tests/stdio.rs +++ b/conformance/mock-provider/tests/stdio.rs @@ -183,3 +183,40 @@ fn compile_inputs_declare_followed_symlinks() { ); } } + +#[cfg(unix)] +#[test] +fn compile_inputs_declare_links_followed_during_resolution() { + let base = project_dir("compile-inputs-link-chain"); + let root = base.join("proj"); + std::fs::create_dir_all(base.join("lib")).unwrap(); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("entry.xdl"), VALID).unwrap(); + std::fs::write(base.join("lib/a.xdl"), VALID).unwrap(); + std::fs::write(base.join("lib/c.xdl"), VALID).unwrap(); + std::os::unix::fs::symlink("a.xdl", base.join("lib/mid.xdl")).unwrap(); + std::os::unix::fs::symlink("../lib/mid.xdl", root.join("chain.xdl")).unwrap(); + std::os::unix::fs::symlink("lib", base.join("ld")).unwrap(); + std::os::unix::fs::symlink("../ld/c.xdl", root.join("linked-dir.xdl")).unwrap(); + let base = base.canonicalize().unwrap(); + let root = base.join("proj"); + let init = + r#"{"jsonrpc":"2.0","id":1,"method":"lpp/initialize","params":{"protocolVersion":"1.7"}}"#; + let request = format!( + r#"{{"jsonrpc":"2.0","id":2,"method":"lpp/compile","params":{{"entry":{{"uri":"file://{}","languageId":"x-demo-lang","version":1,"kind":"directory"}}}}}}"#, + root.display() + ); + let responses = run_session_args( + &["--protocol-version", "1.7"], + format!("{init}\n{request}\n").as_bytes(), + ); + std::fs::remove_dir_all(&base).ok(); + let inputs = &responses[1]["result"]["compileInputs"]; + for path in ["proj/chain.xdl", "lib/mid.xdl", "proj/linked-dir.xdl", "ld"] { + let uri = format!("file://{}/{path}", base.display()); + assert!( + inputs.as_array().unwrap().contains(&serde_json::json!(uri)), + "{uri} missing from {inputs}" + ); + } +}