From 8a96d60b0f4816f26d314783243b1bc4f4fb7c5c Mon Sep 17 00:00:00 2001 From: Teakowa Date: Thu, 24 Sep 2026 16:17:34 +0800 Subject: [PATCH 1/2] feat(protocol): negotiate artifact formats in lpp/compile Add optional acceptedArtifactFormats to lpp/compile in LPP 1.4. The provider returns the first listed format it can produce, refuses when none is supported, and behaves as in 1.3 when the field is absent. Sessions before 1.4 reject the field with -32602. The mock provider gains a compile-only x-demo/puzzle-summary-v1 format, and fixtures 45-49 cover selection, absence, refusal, the version gate, and invalid values. Fixes #39 --- README.md | 2 +- conformance/README.md | 4 +- .../45-compile-accepted-format-selection.json | 105 ++++++++++++++++++ .../v1/46-compile-accepted-format-absent.json | 100 +++++++++++++++++ ...7-compile-accepted-format-unsupported.json | 102 +++++++++++++++++ ...-compile-accepted-format-version-gate.json | 94 ++++++++++++++++ .../49-compile-accepted-format-invalid.json | 92 +++++++++++++++ conformance/mock-provider/src/main.rs | 91 ++++++++++----- conformance/mock-provider/src/puzzle.rs | 7 ++ docs/adr/0004-artifact-format-negotiation.md | 58 ++++++++++ docs/adr/README.md | 1 + docs/spec/README.md | 2 +- docs/spec/analysis-and-compilation.md | 41 ++++++- docs/spec/conformance.md | 10 +- docs/spec/errors-and-versioning.md | 12 +- docs/spec/project-loading.md | 28 ++--- docs/spec/types.md | 4 +- 17 files changed, 697 insertions(+), 56 deletions(-) create mode 100644 conformance/fixtures/v1/45-compile-accepted-format-selection.json create mode 100644 conformance/fixtures/v1/46-compile-accepted-format-absent.json create mode 100644 conformance/fixtures/v1/47-compile-accepted-format-unsupported.json create mode 100644 conformance/fixtures/v1/48-compile-accepted-format-version-gate.json create mode 100644 conformance/fixtures/v1/49-compile-accepted-format-invalid.json create mode 100644 docs/adr/0004-artifact-format-negotiation.md diff --git a/README.md b/README.md index 924730b..dca51d6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ its frontend, compiler, reconstruction, or Workshop integration internally. ## Status -- Protocol versions: **1.0**, additive **1.1** file-entry loading, additive **1.2** directory targets, and additive **1.3** source identity for entry-based compilation (specified in [`docs/spec/README.md`](docs/spec/README.md)). +- Protocol versions: **1.0**, additive **1.1** file-entry loading, additive **1.2** directory targets, and additive **1.3** source identity for entry-based compilation, and additive **1.4** artifact format negotiation for `lpp/compile` (specified in [`docs/spec/README.md`](docs/spec/README.md)). - Repository state: initial published contract and conformance suite. - Wright is a client/consumer of the protocol; LPP is not a dependency from the language implementation back into Wright tooling internals. diff --git a/conformance/README.md b/conformance/README.md index e7cc139..b4b998a 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, and LPP 1.3 source-identity revisions (see [`../docs/spec/README.md`](../spec/lpp-v1.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, and LPP 1.4 artifact-format negotiation revisions (see [`../docs/spec/README.md`](../spec/lpp-v1.md)). ## Layout @@ -10,7 +10,7 @@ mock-provider/ Reference provider for the "x-demo-lang" equation DSL (Rus 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, and LPP 1.3 scenarios. +* **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, and LPP 1.4 scenarios. * **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. diff --git a/conformance/fixtures/v1/45-compile-accepted-format-selection.json b/conformance/fixtures/v1/45-compile-accepted-format-selection.json new file mode 100644 index 0000000..16409d2 --- /dev/null +++ b/conformance/fixtures/v1/45-compile-accepted-format-selection.json @@ -0,0 +1,105 @@ +{ + "name": "compile-accepted-format-selection", + "description": "In an LPP 1.4 session, the provider returns the first accepted format it can produce, skipping formats it does not know.", + "scope": "semantics", + "providerArgs": [ + "--protocol-version", + "1.4" + ], + "steps": [ + { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { + "protocolVersion": "1.4" + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.4", + "serverInfo": { + "name": "lpp-mock-provider", + "version": "0.1.0" + }, + "languages": [ + { + "id": "x-demo-lang", + "extensions": [ + "xdl" + ] + } + ], + "capabilities": { + "check": true, + "compile": true, + "reconstruct": true, + "symbols": true, + "definition": true, + "references": true, + "rename": true, + "editValidation": true, + "projectLoading": true, + "sourceIdentity": true + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/puzzle.xdl": { + "uri": "file:///project/puzzle.xdl", + "languageId": "x-demo-lang", + "version": 3, + "text": "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}" + } + }, + "acceptedArtifactFormats": [ + "x-demo/unknown-v9", + "x-demo/puzzle-summary-v1", + "x-demo/puzzle-eval-v1" + ] + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "diagnostics": [ + { + "uri": "file:///project/puzzle.xdl", + "version": 3, + "diagnostics": [] + } + ], + "artifact": { + "format": "x-demo/puzzle-summary-v1", + "content": "{\"name\":\"clean\",\"value\":40}" + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/shutdown", + "params": {} + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 3, + "result": null + } + } + ], + "expectExitCode": 0 +} diff --git a/conformance/fixtures/v1/46-compile-accepted-format-absent.json b/conformance/fixtures/v1/46-compile-accepted-format-absent.json new file mode 100644 index 0000000..b06dacc --- /dev/null +++ b/conformance/fixtures/v1/46-compile-accepted-format-absent.json @@ -0,0 +1,100 @@ +{ + "name": "compile-accepted-format-absent", + "description": "In an LPP 1.4 session, a compile request without acceptedArtifactFormats returns the provider's default format exactly as in LPP 1.3.", + "scope": "semantics", + "providerArgs": [ + "--protocol-version", + "1.4" + ], + "steps": [ + { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { + "protocolVersion": "1.4" + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.4", + "serverInfo": { + "name": "lpp-mock-provider", + "version": "0.1.0" + }, + "languages": [ + { + "id": "x-demo-lang", + "extensions": [ + "xdl" + ] + } + ], + "capabilities": { + "check": true, + "compile": true, + "reconstruct": true, + "symbols": true, + "definition": true, + "references": true, + "rename": true, + "editValidation": true, + "projectLoading": true, + "sourceIdentity": true + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/puzzle.xdl": { + "uri": "file:///project/puzzle.xdl", + "languageId": "x-demo-lang", + "version": 3, + "text": "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}" + } + } + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "diagnostics": [ + { + "uri": "file:///project/puzzle.xdl", + "version": 3, + "diagnostics": [] + } + ], + "artifact": { + "format": "x-demo/puzzle-eval-v1", + "content": "{\"name\":\"clean\",\"ops\":[{\"arg\":2,\"name\":\"double\",\"op\":\"*\"},{\"arg\":1,\"name\":\"plus1\",\"op\":\"+\"}],\"solution\":[\"double\",\"double\"],\"start\":10,\"target\":40,\"value\":40}" + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/shutdown", + "params": {} + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 3, + "result": null + } + } + ], + "expectExitCode": 0 +} diff --git a/conformance/fixtures/v1/47-compile-accepted-format-unsupported.json b/conformance/fixtures/v1/47-compile-accepted-format-unsupported.json new file mode 100644 index 0000000..b0aa081 --- /dev/null +++ b/conformance/fixtures/v1/47-compile-accepted-format-unsupported.json @@ -0,0 +1,102 @@ +{ + "name": "compile-accepted-format-unsupported", + "description": "When no accepted format is supported and an artifact would be produced, the provider refuses instead of falling back to another format.", + "scope": "protocol", + "providerArgs": [ + "--protocol-version", + "1.4" + ], + "steps": [ + { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { + "protocolVersion": "1.4" + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.4", + "serverInfo": { + "name": "lpp-mock-provider", + "version": "0.1.0" + }, + "languages": [ + { + "id": "x-demo-lang", + "extensions": [ + "xdl" + ] + } + ], + "capabilities": { + "check": true, + "compile": true, + "reconstruct": true, + "symbols": true, + "definition": true, + "references": true, + "rename": true, + "editValidation": true, + "projectLoading": true, + "sourceIdentity": true + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/puzzle.xdl": { + "uri": "file:///project/puzzle.xdl", + "languageId": "x-demo-lang", + "version": 3, + "text": "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}" + } + }, + "acceptedArtifactFormats": [ + "x-demo/unknown-v9" + ] + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32000, + "message": "none of the accepted artifact formats is supported", + "data": { + "lpp": { + "kind": "refusal", + "details": { + "refusalCode": "compile.artifactFormatUnsupported" + } + } + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/shutdown", + "params": {} + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 3, + "result": null + } + } + ], + "expectExitCode": 0 +} diff --git a/conformance/fixtures/v1/48-compile-accepted-format-version-gate.json b/conformance/fixtures/v1/48-compile-accepted-format-version-gate.json new file mode 100644 index 0000000..1b550c2 --- /dev/null +++ b/conformance/fixtures/v1/48-compile-accepted-format-version-gate.json @@ -0,0 +1,94 @@ +{ + "name": "compile-accepted-format-version-gate", + "description": "acceptedArtifactFormats is rejected in an LPP 1.3 session instead of being ignored.", + "scope": "protocol", + "providerArgs": [ + "--protocol-version", + "1.3" + ], + "steps": [ + { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { + "protocolVersion": "1.3" + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.3", + "serverInfo": { + "name": "lpp-mock-provider", + "version": "0.1.0" + }, + "languages": [ + { + "id": "x-demo-lang", + "extensions": [ + "xdl" + ] + } + ], + "capabilities": { + "check": true, + "compile": true, + "reconstruct": true, + "symbols": true, + "definition": true, + "references": true, + "rename": true, + "editValidation": true, + "projectLoading": true, + "sourceIdentity": true + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/puzzle.xdl": { + "uri": "file:///project/puzzle.xdl", + "languageId": "x-demo-lang", + "version": 3, + "text": "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}" + } + }, + "acceptedArtifactFormats": [ + "x-demo/puzzle-eval-v1" + ] + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32602, + "message": "Invalid params" + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/shutdown", + "params": {} + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 3, + "result": null + } + } + ], + "expectExitCode": 0 +} diff --git a/conformance/fixtures/v1/49-compile-accepted-format-invalid.json b/conformance/fixtures/v1/49-compile-accepted-format-invalid.json new file mode 100644 index 0000000..ec35b11 --- /dev/null +++ b/conformance/fixtures/v1/49-compile-accepted-format-invalid.json @@ -0,0 +1,92 @@ +{ + "name": "compile-accepted-format-invalid", + "description": "An empty acceptedArtifactFormats list is invalid params in an LPP 1.4 session.", + "scope": "protocol", + "providerArgs": [ + "--protocol-version", + "1.4" + ], + "steps": [ + { + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "lpp/initialize", + "params": { + "protocolVersion": "1.4" + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "1.4", + "serverInfo": { + "name": "lpp-mock-provider", + "version": "0.1.0" + }, + "languages": [ + { + "id": "x-demo-lang", + "extensions": [ + "xdl" + ] + } + ], + "capabilities": { + "check": true, + "compile": true, + "reconstruct": true, + "symbols": true, + "definition": true, + "references": true, + "rename": true, + "editValidation": true, + "projectLoading": true, + "sourceIdentity": true + } + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "lpp/compile", + "params": { + "documents": { + "file:///project/puzzle.xdl": { + "uri": "file:///project/puzzle.xdl", + "languageId": "x-demo-lang", + "version": 3, + "text": "puzzle clean {\n target = 40\n start = 10\n ops {\n double: x => x * 2\n plus1: x => x + 1\n }\n solution = [ double, double ]\n}" + } + }, + "acceptedArtifactFormats": [] + } + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32602, + "message": "Invalid params" + } + } + }, + { + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "lpp/shutdown", + "params": {} + }, + "expectResponse": { + "jsonrpc": "2.0", + "id": 3, + "result": null + } + } + ], + "expectExitCode": 0 +} diff --git a/conformance/mock-provider/src/main.rs b/conformance/mock-provider/src/main.rs index 939e5e2..c064790 100644 --- a/conformance/mock-provider/src/main.rs +++ b/conformance/mock-provider/src/main.rs @@ -9,14 +9,16 @@ use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use puzzle::{ - ARTIFACT_FORMAT, KIND_OP, KIND_PUZZLE, ParseOutput, Range, SourceText, compile_artifact, - is_valid_identifier, parse_document, reconstruct_source, symbol_at, validate_edits, + ARTIFACT_FORMAT, COMPILE_ARTIFACT_FORMATS, KIND_OP, KIND_PUZZLE, ParseOutput, Range, + SUMMARY_ARTIFACT_FORMAT, SourceText, compile_artifact, is_valid_identifier, parse_document, + reconstruct_source, summary_artifact, symbol_at, validate_edits, }; const DEFAULT_PROTOCOL_VERSION: &str = "1.0"; const PROTOCOL_VERSION_1_1: &str = "1.1"; const PROTOCOL_VERSION_1_2: &str = "1.2"; const PROTOCOL_VERSION_1_3: &str = "1.3"; +const PROTOCOL_VERSION_1_4: &str = "1.4"; const SERVER_NAME: &str = "lpp-mock-provider"; const LANGUAGE_ID: &str = "x-demo-lang"; const LANGUAGE_EXTENSIONS: [&str; 1] = ["xdl"]; @@ -96,19 +98,22 @@ impl Capabilities { "rename": self.rename, "editValidation": self.edit_validation, }); - if matches!( - protocol_version, - PROTOCOL_VERSION_1_1 | PROTOCOL_VERSION_1_2 | PROTOCOL_VERSION_1_3 - ) { + if since(protocol_version, PROTOCOL_VERSION_1_1) { capabilities["projectLoading"] = json!(self.project_loading); } - if protocol_version == PROTOCOL_VERSION_1_3 { + if since(protocol_version, PROTOCOL_VERSION_1_3) { capabilities["sourceIdentity"] = json!(self.source_identity); } capabilities } } +/// True when `version` is `minimum` or a later 1.x version. +fn since(version: &str, minimum: &str) -> bool { + let minor = |v: &str| v.strip_prefix("1.").and_then(|m| m.parse::().ok()); + matches!((minor(version), minor(minimum)), (Some(v), Some(m)) if v >= m) +} + fn capability_of(method: &str) -> Option<&'static str> { Some(match method { "lpp/check" => "check", @@ -300,6 +305,7 @@ fn parse_args() -> (String, Capabilities) { && version != PROTOCOL_VERSION_1_1 && version != PROTOCOL_VERSION_1_2 && version != PROTOCOL_VERSION_1_3 + && version != PROTOCOL_VERSION_1_4 { eprintln!("lpp-mock-provider: unsupported protocol version '{version}'"); std::process::exit(2); @@ -332,6 +338,31 @@ fn parse_args() -> (String, Capabilities) { } impl Server { + fn since(&self, minimum: &str) -> bool { + self.protocol_version + .as_deref() + .is_some_and(|version| since(version, minimum)) + } + + /// Reads `acceptedArtifactFormats` from `lpp/compile` params. Valid only + /// in LPP 1.4 sessions, as a non-empty array of strings. + fn accepted_artifact_formats( + &self, + params: &Value, + ) -> Result>, HandlerError> { + let Some(field) = params.get("acceptedArtifactFormats") else { + return Ok(None); + }; + let invalid = || HandlerError::Std(-32602, "Invalid params"); + if !self.since(PROTOCOL_VERSION_1_4) { + return Err(invalid()); + } + match serde_json::from_value::>(field.clone()) { + Ok(formats) if !formats.is_empty() => Ok(Some(formats)), + _ => Err(invalid()), + } + } + fn handle_message(&mut self, line: &str) -> Option { let parsed: Value = match serde_json::from_str(line) { Ok(value) => value, @@ -488,6 +519,7 @@ impl Server { } fn compile(&self, params: Value) -> Result { + let accepted_formats = self.accepted_artifact_formats(¶ms)?; let params: DocsParams = parse_params(params)?; let (documents_set, entry_uri) = documents_for_request(self, params, "lpp/compile")?; if entry_uri.is_none() && documents_set.len() != 1 { @@ -519,11 +551,32 @@ impl Server { let artifact = if has_errors { Value::Null } else { + let format = match &accepted_formats { + None => ARTIFACT_FORMAT, + Some(accepted) => accepted + .iter() + .find_map(|accepted| { + COMPILE_ARTIFACT_FORMATS + .into_iter() + .find(|supported| supported == accepted) + }) + .ok_or_else(|| { + HandlerError::refusal( + "compile.artifactFormatUnsupported", + json!({}), + "none of the accepted artifact formats is supported", + ) + })?, + }; let puzzle = parsed.puzzle.as_ref().expect("no errors implies a puzzle"); let value = puzzle.simulate().expect("no errors implies resolvable ops"); - let content = serde_json::to_string(&compile_artifact(puzzle, value)) - .expect("artifact serializes"); - json!({ "format": ARTIFACT_FORMAT, "content": content }) + let artifact = if format == SUMMARY_ARTIFACT_FORMAT { + summary_artifact(puzzle, value) + } else { + compile_artifact(puzzle, value) + }; + let content = serde_json::to_string(&artifact).expect("artifact serializes"); + json!({ "format": format, "content": content }) }; let source_identity = entry_uri.as_ref().map(|uri| { let mut hasher = Sha256::new(); @@ -534,9 +587,7 @@ impl Server { "diagnostics": diagnostics, "artifact": artifact, }); - if self.protocol_version.as_deref() == Some(PROTOCOL_VERSION_1_3) - && self.caps.source_identity - { + if self.since(PROTOCOL_VERSION_1_3) && self.caps.source_identity { if let Some(source_identity) = source_identity { result["sourceIdentity"] = Value::String(source_identity); } @@ -875,12 +926,7 @@ fn documents_for_request( match (documents, entry) { (Some(documents), None) => Ok((documents, None)), (None, Some(entry)) => { - if !matches!( - server.protocol_version.as_deref(), - Some(PROTOCOL_VERSION_1_1) - | Some(PROTOCOL_VERSION_1_2) - | Some(PROTOCOL_VERSION_1_3) - ) { + if !server.since(PROTOCOL_VERSION_1_1) { return Err(HandlerError::Std(-32602, "Invalid params")); } if !server.caps.project_loading { @@ -912,12 +958,7 @@ fn project_target_kind( ) -> Result { match entry.kind.as_deref() { None | Some("file") => Ok(ProjectTargetKind::File), - Some("directory") - if matches!( - protocol_version, - PROTOCOL_VERSION_1_2 | PROTOCOL_VERSION_1_3 - ) => - { + Some("directory") if since(protocol_version, PROTOCOL_VERSION_1_2) => { Ok(ProjectTargetKind::Directory) } Some("directory") => Err(HandlerError::Lpp( diff --git a/conformance/mock-provider/src/puzzle.rs b/conformance/mock-provider/src/puzzle.rs index 5906c58..1bc38ca 100644 --- a/conformance/mock-provider/src/puzzle.rs +++ b/conformance/mock-provider/src/puzzle.rs @@ -977,6 +977,13 @@ pub(crate) fn is_valid_identifier(name: &str) -> bool { } pub(crate) const ARTIFACT_FORMAT: &str = "x-demo/puzzle-eval-v1"; +/// Compile-only format: `lpp/reconstruct` does not support it. +pub(crate) const SUMMARY_ARTIFACT_FORMAT: &str = "x-demo/puzzle-summary-v1"; +pub(crate) const COMPILE_ARTIFACT_FORMATS: [&str; 2] = [ARTIFACT_FORMAT, SUMMARY_ARTIFACT_FORMAT]; + +pub(crate) fn summary_artifact(puzzle: &Puzzle, value: i64) -> serde_json::Value { + serde_json::json!({ "name": puzzle.name, "value": value }) +} pub(crate) fn compile_artifact(puzzle: &Puzzle, value: i64) -> serde_json::Value { serde_json::json!({ diff --git a/docs/adr/0004-artifact-format-negotiation.md b/docs/adr/0004-artifact-format-negotiation.md new file mode 100644 index 0000000..24d024c --- /dev/null +++ b/docs/adr/0004-artifact-format-negotiation.md @@ -0,0 +1,58 @@ +# ADR 0004: Artifact Format Negotiation for `lpp/compile` + +- Status: Accepted +- Decision date: 2026-09-24 (UTC) +- Scope: LPP 1.4 `lpp/compile` requests + +## Context + +`lpp/compile` returns an opaque `format`/`content` artifact in a +provider-chosen format. The source-mapping decision in +[workshop-rs#271](https://github.com/wrightkit/workshop-rs/issues/271) +(workshop-rs ADR-0013) expects providers to be able to return a richer +Workshop artifact format, for example `workshop-rs/mapped-text-v1`, when the +client can consume it. LPP must let a client say what it accepts without +defining or validating any Workshop format. + +## Decision + +LPP 1.4 adds an optional `acceptedArtifactFormats` field to `lpp/compile` +requests: a non-empty, ordered array of format ids. + +- The provider returns the first listed format it can produce. Unknown ids + are skipped. +- If none can be produced and an artifact would otherwise be returned, the + provider refuses (`compile.artifactFormatUnsupported` in the reference + provider) instead of falling back to an unlisted format. A client that wants + the provider default lists it explicitly. +- Absent field: behavior is identical to LPP 1.3. +- The field is valid only in sessions that negotiated `"1.4"`; earlier + sessions reject it with `-32602`. A 1.4 provider MUST implement it, so no + capability id is added. + +The artifact stays an opaque envelope; format payload semantics remain owned +outside LPP. + +## Consequences + +- Clients get a deterministic result format and never receive a payload they + did not list. +- Providers that only have one format can serve 1.4 by matching that id or + refusing. +- A client wanting graceful fallback pays one extra list entry. + +## Alternatives considered + +### Silent fallback to the provider default + +Rejected because the client could receive a format it cannot consume and would +have to detect the mismatch after the fact. + +### A separate capability id per format or for negotiation + +Rejected as speculative: the version gate already lets clients require the +feature, and format ids are opaque strings. + +### Ignore the field in pre-1.4 sessions + +Rejected because a client could not tell whether negotiation took effect. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7f3fc44..77632ee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,7 @@ material process or wire-boundary decision. | [0001](0001-provider-owned-project-loading.md) | Provider-owned project loading from a client-selected entry target | Accepted | 2026-09-02 | 2026-09-12 | [Issue #16](https://github.com/wrightkit/language-provider-protocol/issues/16), [PR #17](https://github.com/wrightkit/language-provider-protocol/pull/17) | | [0002](0002-directory-project-targets.md) | Provider-owned directory project targets | Accepted | 2026-09-12 | 2026-09-14 | [PR #32](https://github.com/wrightkit/language-provider-protocol/pull/32) | | [0003](0003-owner-selected-source-identity.md) | Owner-selected source identity for entry compilation | Accepted | 2026-09-13 | 2026-09-14 | [PR #34](https://github.com/wrightkit/language-provider-protocol/pull/34) | +| [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) | ## Post-baseline audit diff --git a/docs/spec/README.md b/docs/spec/README.md index 704d3f9..9179dd0 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` | +| Protocol versions | `1.0`, `1.1`, `1.2`, `1.3`, `1.4` | | 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 d92680e..f61af01 100644 --- a/docs/spec/analysis-and-compilation.md +++ b/docs/spec/analysis-and-compilation.md @@ -49,8 +49,8 @@ analyze every document in the set and MUST report all diagnostics found. ## 10. lpp/compile -Compile a document set into a single Workshop artifact. In LPP 1.1, 1.2, and -1.3, an entry-based request compiles the provider-loaded source closure as one +Compile a document set into a single Workshop artifact. In LPP 1.1 through +1.4, an entry-based request compiles the provider-loaded source closure as one unit; the `compile.requiresSingleDocument` refusal applies only to a document-supplied request that contains more than one document. @@ -69,6 +69,12 @@ document-supplied request that contains more than one document. } ``` +* `acceptedArtifactFormats`: OPTIONAL, defined only in LPP 1.4. A non-empty + array of artifact format id strings, ordered from most to least preferred. + It states which formats the client can consume; see + [Section 10.3](#103-artifact-format-negotiation-lpp-14). The + field applies to both `documents` and `entry` requests. + ### 10.2 Result ```json @@ -81,7 +87,7 @@ document-supplied request that contains more than one document. ``` * `diagnostics`: same shape as the `lpp/check` result. -* `sourceIdentity`: defined only in LPP 1.3. In an LPP 1.3 session, the +* `sourceIdentity`: defined only in LPP 1.3 and 1.4. In an LPP 1.3 or 1.4 session, the provider MUST advertise the `sourceIdentity` capability. If it advertises `sourceIdentity: true`, an entry-based compile result MUST include a lower-case SHA-256 hex digest of the provider-selected primary source text. @@ -111,6 +117,35 @@ prefix format ids with a language or provider identifier (for example ecosystem decision owned outside this specification; LPP will not freeze one without concrete evidence. +### 10.3 Artifact format negotiation (LPP 1.4) + +`acceptedArtifactFormats` lets a client state which artifact formats it can +consume so a provider can return a richer format when the client supports it. +LPP does not define or validate any format's payload; the artifact remains the +opaque `format`/`content` envelope. + +* Absent field: the request behaves exactly as in LPP 1.3, and the provider + returns its default format. +* Present field: the provider MUST return an artifact whose `format` is the + first entry of `acceptedArtifactFormats` that the provider can produce. + Providers MUST NOT return a format that is not listed, and MUST NOT fall + back to a default format that is not listed. A client that wants the + provider's default as a fallback lists it explicitly. +* If no listed format can be produced and the provider would otherwise return a + non-null artifact, the provider MUST refuse with a refusal whose + `refusalCode` describes the requirement (for example + `compile.artifactFormatUnsupported`). When the artifact is `null` because of + error-severity diagnostics, the result is returned normally. +* Format ids are matched exactly and are not interpreted. Unknown ids MUST be + skipped, not rejected. +* Version gate: the field is valid only in a session that negotiated protocol + version `"1.4"`. In any earlier session, a request that contains it MUST be + rejected with JSON-RPC `-32602` (Invalid params); the provider MUST NOT + ignore the field. In a 1.4 session, a value that is not a non-empty array of + strings MUST also be rejected with `-32602`. +* A provider that negotiates `"1.4"` MUST implement this behavior. There is no + separate capability id. + ## 11. lpp/reconstruct Reconstruct source text from a Workshop artifact. This is the inverse of diff --git a/docs/spec/conformance.md b/docs/spec/conformance.md index b706286..48123ed 100644 --- a/docs/spec/conformance.md +++ b/docs/spec/conformance.md @@ -12,8 +12,9 @@ contract: reconstruct, symbols, definition, references, rename, edit validation, project loading, errors/refusals, protocol mismatch, malformed messages, and shutdown. The same directory covers LPP 1.0, its LPP 1.1 file-entry - revision, its LPP 1.2 directory-target revision, and its LPP 1.3 - source-identity revision. + revision, its LPP 1.2 directory-target revision, its LPP 1.3 + source-identity revision, and its LPP 1.4 artifact-format negotiation + revision. * `conformance/runner/`: a runner that replays fixtures against any provider binary and compares responses exactly. * `conformance/mock-provider/`: the reference provider for the demonstration @@ -34,8 +35,8 @@ Methods: | --- | --- | --- | --- | | `lpp/initialize` | none | `{ protocolVersion, clientInfo? }` | `{ protocolVersion, serverInfo, languages, capabilities }` | | `lpp/shutdown` | none | `{}` | `null` | -| `lpp/check` | `check`; plus `projectLoading` for an LPP 1.1/1.2/1.3 `entry` request | `{ documents, projectRoot? }` or `{ entry, projectRoot? }` | `{ documents: [{ uri, version, diagnostics }] }` | -| `lpp/compile` | `compile`; plus `projectLoading` for an LPP 1.1/1.2/1.3 `entry` request and `sourceIdentity` for an LPP 1.3 entry result | `{ documents, projectRoot? }` or `{ entry, projectRoot? }` | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, artifact }` | +| `lpp/check` | `check`; plus `projectLoading` for an LPP 1.1+ `entry` request | `{ documents, projectRoot? }` or `{ entry, projectRoot? }` | `{ 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?, acceptedArtifactFormats? }` or `{ entry, projectRoot?, acceptedArtifactFormats? }` (`acceptedArtifactFormats`: LPP 1.4) | `{ diagnostics: [{ uri, version, diagnostics }], sourceIdentity?, artifact }` | | `lpp/reconstruct` | `reconstruct` | `{ artifact }` | `{ source, uri? }` | | `lpp/symbols` | `symbols` | `{ documents, projectRoot? }` | `{ documents: [{ uri, version, symbols }] }` | | `lpp/definition` | `definition` | `{ document, position }` | `{ locations }` | @@ -55,6 +56,7 @@ provider-defined codes; other providers MAY use different codes. | `refusalCode` | Meaning | | --- | --- | | `compile.requiresSingleDocument` | Compile requires exactly one document. | +| `compile.artifactFormatUnsupported` | None of the accepted artifact formats is supported. | | `reconstruct.artifactFormatUnsupported` | The artifact format is not supported. | | `definition.noSymbolAtPosition` | No symbol at the given position. | | `references.noSymbolAtPosition` | No symbol at the given position. | diff --git a/docs/spec/errors-and-versioning.md b/docs/spec/errors-and-versioning.md index 786b480..f401328 100644 --- a/docs/spec/errors-and-versioning.md +++ b/docs/spec/errors-and-versioning.md @@ -79,7 +79,9 @@ clients MUST NOT parse `message`. * Protocol versions are strings of the form `MAJOR.MINOR` (for example `"1.0"`). LPP 1.0 is the first published version; LPP 1.1 adds file-entry project loading, LPP 1.2 adds directory targets, and LPP 1.3 adds the - optional `sourceIdentity` capability for entry-based compile results. + optional `sourceIdentity` capability for entry-based compile results, and + LPP 1.4 adds `acceptedArtifactFormats` artifact format negotiation to + `lpp/compile`. * `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 @@ -96,10 +98,12 @@ clients MUST NOT parse `message`. * The provider either accepts it (echoing the version in the result) or fails with `protocolVersionMismatch` listing `supportedProtocolVersions`. * A client that receives the mismatch MUST pick the highest mutually supported - version and restart the session, or terminate. LPP 1.1, 1.2, and 1.3 clients + version and restart the session, or terminate. LPP 1.1 through 1.4 clients MAY use the `projectLoading` capability; clients that need directory targets - MUST request `"1.2"` or `"1.3"`. Clients that need source identity MUST - request `"1.3"` and require `sourceIdentity: true` in the result capabilities. + MUST request `"1.2"` or later. Clients that need source identity MUST + request `"1.3"` or later and require `sourceIdentity: true` in the result + capabilities. Clients that send `acceptedArtifactFormats` MUST request + `"1.4"`. * A provider MUST support at least one of the versions it lists in `supportedProtocolVersions`. diff --git a/docs/spec/project-loading.md b/docs/spec/project-loading.md index 2be667e..e6cbc5f 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"`, or `"1.3"`. | +| `protocolVersion` | string | The protocol version the client wants to speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, or `"1.4"`. | | `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"`, or `"1.3"`. | +| `protocolVersion` | string | The protocol version the provider will speak: `"1.0"`, `"1.1"`, `"1.2"`, `"1.3"`, or `"1.4"`. | | `serverInfo` | object | `{ "name": string, "version": string }` identifying the provider. | | `languages` | array | One entry per source language the provider serves. | -| `capabilities` | object | One boolean field per capability. LPP 1.0 requires the eight fields listed below; LPP 1.1, 1.2, and 1.3 additionally require `projectLoading`; LPP 1.3 also defines `sourceIdentity`. | +| `capabilities` | object | One boolean field per capability. LPP 1.0 requires the eight fields listed below; LPP 1.1 through 1.4 additionally require `projectLoading`; LPP 1.3 and 1.4 also define `sourceIdentity`. | Each language entry: `{ "id": string, "extensions": [string] }`. `extensions` is the list of file extensions the provider associates with the language, @@ -75,8 +75,8 @@ extension. | `references` | `lpp/references` | Find references to the symbol at a position. | | `rename` | `lpp/rename` | Compute source edits for a semantic rename. | | `editValidation` | `lpp/validateEdits` | Validate a set of source edits against a document. | -| `projectLoading` | `lpp/check`, `lpp/compile` | Accept a client-selected entry or directory target and load its filesystem-backed source project. LPP 1.1, 1.2, and 1.3. | -| `sourceIdentity` | `lpp/compile` | Return the provider-selected primary source identity for an entry-based compile result. LPP 1.3. | +| `projectLoading` | `lpp/check`, `lpp/compile` | Accept a client-selected entry or directory target and load its filesystem-backed source project. LPP 1.1 through 1.4. | +| `sourceIdentity` | `lpp/compile` | Return the provider-selected primary source identity for an entry-based compile result. LPP 1.3 and 1.4. | * The provider MUST set each capability to `true` only if it fully implements the corresponding method(s). @@ -116,9 +116,9 @@ when practical: The client then decides whether to terminate the session or restart with a supported version. LPP 1.0 clients MUST send `"1.0"`; clients using file-entry -project loading MUST send `"1.1"`, `"1.2"`, or `"1.3"`; clients using directory -targets MUST send `"1.2"` or `"1.3"`. A client that requires the -`sourceIdentity` capability MUST request `"1.3"` and require the provider to +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. ## 8. Common request parameters @@ -128,15 +128,15 @@ Document-scoped methods share this parameter shape: | Field | Type | Methods | Description | | --- | --- | --- | --- | | `documents` | DocumentSet | `check`, `compile`, `symbols`, `rename` | The documents to operate on. | -| `entry` | Project entry | `check`, `compile` in LPP 1.1, 1.2, and 1.3 | Alternative to `documents`; asks the provider to load the source closure from the selected entry or directory target. | +| `entry` | Project entry | `check`, `compile` in LPP 1.1 through 1.4 | Alternative to `documents`; asks the provider to load the source closure from the selected entry or directory target. | | `document` | Document | `definition`, `references`, `validateEdits` | The single document to operate on. | | `projectRoot` | string, OPTIONAL | `check`, `compile`, `symbols`, `rename` | URI identifying the project the documents belong to. Purely informational in v1; providers MUST accept and MAY use it. | -### 8.1 Entry-based project requests (LPP 1.1, 1.2, and 1.3) +### 8.1 Entry-based project requests (LPP 1.1 through 1.4) -In LPP 1.1, 1.2, and 1.3, `lpp/check` and `lpp/compile` accept either `documents` or +In LPP 1.1 through 1.4, `lpp/check` and `lpp/compile` accept either `documents` or `entry`, but not both. An `entry` request is available only when the provider -accepted protocol version `1.1`, `1.2`, or `1.3` and advertised +accepted protocol version `1.1` or later and advertised `projectLoading: true`. The optional `projectRoot` field remains legal and is informational; the provider accepts it but determines the effective project root and source @@ -180,9 +180,9 @@ an LPP error of kind `projectLoadFailed`. The `details` object MUST contain `entryUri` and a provider-defined `reason`; a required-file failure SHOULD also include the affected `uri`. -### 8.2 Directory project requests (LPP 1.2 and 1.3) +### 8.2 Directory project requests (LPP 1.2 through 1.4) -LPP 1.2 and 1.3 extend the `entry` object with `kind: "directory"`: +LPP 1.2 through 1.4 extend the `entry` object with `kind: "directory"`: ```json { diff --git a/docs/spec/types.md b/docs/spec/types.md index 4dfbed3..5639722 100644 --- a/docs/spec/types.md +++ b/docs/spec/types.md @@ -146,8 +146,8 @@ provider-owned filesystem project load. The example below selects a file: filesystem snapshot for this request. It is echoed in every source result. It is not a filesystem content hash and does not provide cross-request stale detection. -* `kind`: OPTIONAL for LPP 1.1, LPP 1.2, and LPP 1.3. When omitted, it requests the - existing file-entry behavior. LPP 1.2 and 1.3 clients MUST use `"directory"` when +* `kind`: OPTIONAL for LPP 1.1 through 1.4. When omitted, it requests the + existing file-entry behavior. LPP 1.2 and later clients MUST use `"directory"` when the provider must discover the effective project entry from a directory; `"file"` may be used explicitly for file-entry behavior. From 025b6c81073485ab934863c03ead00ebfc569f02 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Fri, 25 Sep 2026 02:52:45 +0800 Subject: [PATCH 2/2] docs(spec): reconcile version-gated fields with unknown-field rule Clarify that providers ignore unknown fields except fields defined for a later version that they implement, which are rejected as specified, and that clients may rely on a version-gated field only in a session that negotiated that version. --- docs/spec/errors-and-versioning.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/spec/errors-and-versioning.md b/docs/spec/errors-and-versioning.md index f401328..055baa6 100644 --- a/docs/spec/errors-and-versioning.md +++ b/docs/spec/errors-and-versioning.md @@ -88,9 +88,15 @@ clients MUST NOT parse `message`. to interoperate. * `MINOR` changes are additive: new OPTIONAL request/result fields, new OPTIONAL methods, or new capability ids that providers may choose not to - implement. A provider MUST ignore unknown fields it does not understand, and - a client MUST NOT depend on fields the provider did not advertise via - capabilities. + implement. A provider MUST ignore unknown fields it does not understand, + except a field that the specification defines only for a later version than + the negotiated one and that the provider implements: such a field is + version-gated and MUST be rejected as its section specifies (for example + `acceptedArtifactFormats` in a session before LPP 1.4). A provider that does + not implement the later version never negotiates it, so a client can rely on + a version-gated field only in a session that negotiated the version defining + it. A client MUST NOT depend on any other field the provider did not + advertise via capabilities. ### 19.2 Negotiation