diff --git a/README.md b/README.md index 71a7023..462de53 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ SDK 的源码都在本仓库的 `sdk/` 下,发布在[本仓库的 Releases](ht | [`README.md`](contract/v1/README.md) | 字段、API 等级、能力版本、权限和错误码的完整说明 | | [`plugin-manifest.schema.json`](contract/v1/plugin-manifest.schema.json) | `plugin.json` 的 JSON Schema | | [`plugin-index.schema.json`](contract/v1/plugin-index.schema.json) | `lithe-plugin-index.json` 的 JSON Schema | +| [`toolchains.md`](contract/v1/toolchains.md) | 插件提供的工具链:工具定义、版本管理器提供方、查询与回复校验、目录固定值和四层解析。目前只有 macOS 设置页以只读方式接入,构建、运行和语言服务器还不使用解析结果 | +| [`proposals/toolchains/`](contract/v1/proposals/toolchains/README.md) | 工具链的完整设计草案,包括还没实现的部分;里面的示例只用于评审,不能直接安装 | | [`fixtures/`](contract/v1/fixtures) | 标准用例:合法和非法的清单、命令规划、构建规划、插件函数协议、市场索引,以及 Lithe 对每个用例的预期结果 | fixture 按演示的能力命名,例如 `build-command-stdout.plugin.json` 演示“解码命令的标准输出”。它们借 Cargo、Go、Gradle、Maven 等真实工具作为输入,但不是这些插件本身;要参考完整插件,看[示例插件](#示例插件)。 diff --git a/contract.lock.json b/contract.lock.json index acfc13c..5e5ae60 100644 --- a/contract.lock.json +++ b/contract.lock.json @@ -3,12 +3,12 @@ "source": { "repository": "https://github.com/1lck/Lithe-IDEA", "directory": "shared/contracts/plugins/v1", - "revision": "9175bd73431aec5086f1fe4c532be54fc49ba4e8" + "revision": "589116e30d3bdbf72c9695f8ff18a952c7da33ac" }, "files": [ { "path": "README.md", - "sha256": "b2864095465511324cfde17968184fb87ac9191711c18a68fd48c5db91149f35" + "sha256": "0f32eb07a05f6e929696b51c87052eab6c369489c89b47d1b3fc8dc82ac3f6e6" }, { "path": "fixtures/backend-workspace.plugin.json", @@ -118,9 +118,17 @@ "path": "fixtures/debug-adapter.plugin.json", "sha256": "81038febbde31e0554451789106f8fc255e87038c8185602ce0c7e918f90ff78" }, + { + "path": "fixtures/editor-actions.plugin.json", + "sha256": "ca86471b63a15e6f12102777c59c300a6da7690ac13e02835e43abf41f26eb0b" + }, { "path": "fixtures/function-protocol.json", - "sha256": "c00b99c31f4c2b5d2cb7644d123cdec69d8bc439a5f043e5b655db6351545cff" + "sha256": "e880024b108b2d11c5c2b93c39ea642460989eaf530bc1f85254f404780dfc3b" + }, + { + "path": "fixtures/grammars/http.monarch.json", + "sha256": "2fb9c325feb966d0d6569a77a2a8493b0e43e13b7cd64f9b30548ff826f9cdea" }, { "path": "fixtures/hello-lithe.plugin.json", @@ -128,7 +136,11 @@ }, { "path": "fixtures/invalid-manifests.json", - "sha256": "3bbd1ed851b916d1e006d0403bb9eb2d5cd122e65070da91890aa6b7154b0454" + "sha256": "3859c99928158e44fc1f9f25caff5289ed3c2f8e3af200108ab17051915246c2" + }, + { + "path": "fixtures/language-grammar.plugin.json", + "sha256": "9c55a3ea5f075328fca87c845f04f680e35fb283a39283c742fb5d435be05faa" }, { "path": "fixtures/language-host-integration.plugin.json", @@ -214,6 +226,14 @@ "path": "fixtures/settings-v2.plugin.json", "sha256": "83d4c7faf7bfbb9c474639bfc0ef42dae8cd7d370d388f8527589f2afed12981" }, + { + "path": "fixtures/tool-definition.plugin.json", + "sha256": "06b94a1886edcaa8ade3ae1462d01aa6e54e7ec3104ae28d0b796f2a303fd90a" + }, + { + "path": "fixtures/toolchain-provider.plugin.json", + "sha256": "87a7c83ecf5e1983953fd29705237ff9c1b9480d22416aec7e85293a2342600a" + }, { "path": "fixtures/vcs-provider.plugin.json", "sha256": "7d0c85b6e0531a28e877050c2da15470d0bfe5ccd4c98215e7824d4445d977a9" @@ -232,7 +252,63 @@ }, { "path": "plugin-manifest.schema.json", - "sha256": "3065b75d2a2e3913e6ac002acfe50f9da179e703603b06753c9bfd171ffe8905" + "sha256": "424d5e6a9f0c216eb79add116b90d75f6634f7b31c51629a730ec155ada27016" + }, + { + "path": "proposals/toolchains/README.md", + "sha256": "eeec7c53075056649d103af78fa9c5b2e0a28bc60c8049d4ae2a9d3ec15e17e7" + }, + { + "path": "proposals/toolchains/fixtures/java-tool-definition.json", + "sha256": "23408937de3c5d77712657e8753d23d7cd07d91e73d5c3d0788d3ae283f56134" + }, + { + "path": "proposals/toolchains/fixtures/local.json", + "sha256": "0d24ed31d1b330cd45147274747206dada3dc660deae66eb72acdf359e2092ba" + }, + { + "path": "proposals/toolchains/fixtures/manifest-rejections.json", + "sha256": "de2ebb472c31eb8e5ff3fd9217572311dc65df1bb62a17736e4192d105576674" + }, + { + "path": "proposals/toolchains/fixtures/mise-provider.json", + "sha256": "55f9b992e8d4988afb3e8574025af2e593c71c799a98bd9b237b9d01c5fc474c" + }, + { + "path": "proposals/toolchains/fixtures/query-cases.json", + "sha256": "d1a7c3e8b8df18cba3d40cca437b712a787b8935d6c8eb7b567e74736d42c203" + }, + { + "path": "proposals/toolchains/fixtures/resolution-cases.json", + "sha256": "8292f41dc287e0d96638a3583324354f106cc0bd149c91ab76f80525893c5cab" + }, + { + "path": "proposals/toolchains/local.schema.json", + "sha256": "a27683a7d4086af4e24cfae220af4d2089bb7c14cb0f9fbd7a83e3ba7a193a51" + }, + { + "path": "proposals/toolchains/manifest-contributions.schema.json", + "sha256": "9b66f66bfeb5ad32b7415c084048f95ad54d7b1628bd89ce146dfcfc4c746ebf" + }, + { + "path": "proposals/toolchains/manifest.md", + "sha256": "c8074693b61fa55cdecd8cda923e9c21a1be923093ef1f00acafb99844afec82" + }, + { + "path": "proposals/toolchains/protocol.md", + "sha256": "5218ad632fcdc09b317dc2b248e68528fedc9afd030cd2e3d276f1c34b4a851d" + }, + { + "path": "proposals/toolchains/query.schema.json", + "sha256": "4cb8093ee80d99527fb05d4cf7aa86a55f74711e9bf9e61139db3681d7361219" + }, + { + "path": "proposals/toolchains/resolution.schema.json", + "sha256": "c0ce78a4df543e2bae06877ae9a49c425a4fb81f5af3ff4626b064c8063b288c" + }, + { + "path": "toolchains.md", + "sha256": "0d9e19ea676836d469e3342f01d1e0eef60c6cd321f3671e1b5db1235690c39f" } ] } diff --git a/contract/v1/README.md b/contract/v1/README.md index bd94d74..bd32e08 100644 --- a/contract/v1/README.md +++ b/contract/v1/README.md @@ -12,6 +12,7 @@ This directory is the cross-platform source of truth: | --- | --- | | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `fixtures/*.plugin.json` | Valid example manifests, named after the capability they exercise rather than a published plugin: `language-*` (packaged, system-command, and host-integration language servers; `node-runtime` for a discovered runtime), `hello-lithe` and `web-panel` (UI), `settings-v2`, and `build-*` (level 3 build systems and the two forms of a build provider) | +| `fixtures/tool-definition.plugin.json`, `fixtures/toolchain-provider.plugin.json` | Production-shape Core fixtures for tool definitions and a packaged toolchain provider; require explicit new host capabilities, not yet supplied by native products | | `fixtures/invalid-manifests.json` | Edits to a valid manifest and the rejection code every host must report | | `fixtures/command-plans.json` | Golden run/test command expansions and language-server launch plans | | `fixtures/runtime-versions.json` | Golden outcomes of the launch runtime version check | @@ -21,6 +22,8 @@ This directory is the cross-platform source of truth: | `fixtures/project-plans.json` | Golden `project.*` form evaluations, generation plans, and name checks | | `plugin-index.schema.json` | JSON shape of a marketplace source index | | `fixtures/marketplace/*.json` | Marketplace index, availability, source URL, and install review golden cases | +| `toolchains.md` | Implemented Core foundation: plugin definitions/provider declarations, verified-origin registry, reply validation, directory pins, snapshot resolution, version matching and discovery plans; native hosts are not connected | +| `proposals/toolchains/` | Broader design draft, including unimplemented query transport, native lifecycle/persistence, consumer migration, settings UI and manager plugins; examples are review projections, not installable manifests | Validation and template expansion are implemented once in Rust Core (`rust/lithe-core/src/plugins/`, with build systems in @@ -37,6 +40,30 @@ be synced to that repository, so links in this file that leave this directory are absolute. `plugin.json` may not carry a `$schema` key, because unknown fields are rejected; editors associate the schema by file name instead. +## Plugin-owned toolchains (Core foundation) + +The [toolchains draft](proposals/toolchains/README.md) records the replacement +design discussed in [PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +All tool definitions belong to enabled plugins; the host supplies only generic +query, validation, resolution, persistence, and presentation mechanisms. It +does not add a built-in tool-definition table or interpret version-manager +configuration formats. + +The [implemented Core contract](toolchains.md) now accepts `toolDefinitions: 1` +and `toolchainProviders: 1` declarations and provides six `toolchains.*` commands. +`executableResolution: 2` applies to tool-definition candidates only; it does not +admit new sources into existing Build candidate sets. Definitions are entirely +plugin-owned, with reserved short IDs checked against host-verified official +provenance when constructing the registry. + +Neither native host advertises or integrates this foundation yet. Core consumes +captured host facts; it does not start a provider, run a probe, watch files, scan +the system, persist pins, or change current Java/Maven selection. The broader +proposal remains unsupported where it exceeds this implemented slice. In +particular, `toolchainConsumers`, generic runtime launches, settings pages, mise +and SDKMAN providers, and live backend activation are not implemented here. +Proposal envelopes remain separate from production manifests. + ## Package layout ```text @@ -69,6 +96,8 @@ symbolic links. A host treats an installed package as read-only. | `settings` | User-adjustable plugin options (level 2 and later). | | `ui` | Host-rendered tool windows and commands (level 2 and later). | | `buildSystems` | Project-level build tools (level 3). | +| `toolDefinitions` | Plugin-owned tool policy ([Core foundation](toolchains.md)); requires `toolDefinitions: 1` and `executableResolution: 2`; native adapters are not connected. | +| `toolchainProviders` | Backend fact-provider declarations ([Core foundation](toolchains.md)); requires `toolchainProviders: 1`, backend/server-process capabilities and `toolchains.provide`. | Unknown fields are refused (`plugin.manifest.invalid`) so an older host rejects a package instead of ignoring part of it. At levels 1 and 2 a new @@ -85,6 +114,77 @@ present. A leading dot is optional only in `fileExtensions` (`.php` equals is part of the name, so `.toolrc` and `.env.example` stay hidden-file names. Optional `dependencies` lists `managementFileNames`, `projectDependencyPaths`, and `projectBinaryPaths` relative to the project. +#### Grammars and editor configuration (`languages` 2) + +`languages` 2 adds two optional fields to a language. Both are data: the host +reads them from the package and the editor applies them without starting any +process, so a language with a grammar needs no language server, backend, or +activation event, and a plugin may consist of grammars alone. + +```json +{ + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": ["http", "rest"], + "grammar": { "kind": "monarch", "path": "grammars/http.monarch.json" }, + "configuration": { + "lineComment": "#", + "blockComment": ["/*", "*/"], + "brackets": [["{", "}"], ["[", "]"]], + "autoClosingPairs": [["{", "}"], ["\"", "\""]] + } +} +``` + +- `grammar.kind` names the highlighting engine. Version 2 defines only + `monarch`; another engine is added as a new kind in a later version, so a + plugin can move between engines without changing anything else. +- This is a static package contribution: `languages[]` belongs to the manifest + and `grammar.path` references a package JSON file. No public host method accepts + inline grammar JSON to dynamically register, replace, or dispose a highlighting + provider. Live installation/update/withdrawal of eligible static packages does + not imply such an API or automatic reload after arbitrary file changes; + installed packages remain read-only. +- Adding another language or grammar using the supported structure needs only + plugin data; changing its rules does not itself raise the API version. A new + engine or capability kind still needs an implemented host adapter and an + explicit contract; an arbitrary new `kind` string cannot supply one. +- This contribution does not register completion, hover, definition, symbol, + formatting, rename, or Code Action providers backed by ordinary plugin + functions. Those independent provider bindings are not implemented; existing + LSP support and `editor/registerActions` are separate capabilities, not a + substitute for them. +- `grammar.path` is a package-relative file that stays inside the package. + Hosts read it read-only from the installed package, pass its text to + `plugins.declarative.decodeGrammar`, and hand the returned object to the + editor as validated source text, never through a reconstructed JSON object: + Monarch `cases` are evaluated in source order. The worker parses that text and + defaults `start` to `root` when absent. A grammar Core refuses is `plugin.grammar.invalid` with the + failing field (such as `grammar.tokenizer.root[3][1].log`); the language then + opens as plain text and the rest of the plugin keeps working. +- A Monarch grammar is the JSON form of a + [Monaco Monarch](https://microsoft.github.io/monaco-editor/monarch.html) + definition: rule regexes are strings, and attribute values are strings or + string lists. Core accepts at most 256 KiB, 256 states, 4096 rules, regexes + of 2048 bytes, attribute lists of 4096 entries, 64 `cases` guards, 32 + brackets, and actions nested 8 deep. It refuses `log` actions, `next`, + `switchTo`, and `include` targets that are not states of the grammar + (`@a.b` resolves to the longest declared prefix, as in Monarch), `cases` + guards naming an undeclared attribute, and `nextEmbedded` values other + than `@pop` or a language ID. The fixture + [`fixtures/grammars/http.monarch.json`](fixtures/grammars/http.monarch.json) + is a complete example. +- `configuration` sets `lineComment`, `blockComment` (open and close), + `brackets`, and `autoClosingPairs`. Each delimiter is 1 to 16 bytes without + control characters; each list has at most 32 pairs. +- Grammar compilation and matching run only in a terminable worker. No plugin + regex runs on the editor UI thread. Include cycles and expanded rule counts + over 4096 are refused; a stalled worker is terminated after its local deadline. + Buffers over 128 Ki UTF-16 units or 10,000 lines remain plain/editable. Embedded + languages are plain text in this first worker adapter. +- Declaring `languages: 2` without using either field is + `plugin.manifest.invalid`; using them under `languages: 1` is too. + ### Language servers | Field | Meaning | @@ -399,7 +499,7 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | Capability | Covers | Versions | | --- | --- | --- | -| `languages` | `languages[]` | `1` | +| `languages` | `languages[]` | `1`, `2` (adds [`grammar` and `configuration`](#grammars-and-editor-configuration-languages-2)) | | `packageTrust` | `vendor.bundledSignatureRequirement`: origin-scoped trust for a host-sealed bundled copy of a publisher-signed plugin | `1` | | `languageServers` | `languageServers[]` apart from their launch fields | `1`, `2` (adds [`workspaceConfiguration`](#workspace-configuration-languageservers-2)) | | `serverProcess` | The launch fields of language servers, of build providers with a process of their own, of VCS providers, and of the plugin backend: `launch`, `arguments`, `validationArguments`, `runtimeDependencies`, `initializationOptions` | `1` | @@ -413,6 +513,9 @@ hosts. Level 3 versions each section on its own. A level 3 manifest lists in | `debugAdapters` | [`debugAdapters[]`](#api-level-4-debug-adapters); v2 adds target arguments/environment | `1`, `2` | | `projectGenerators` | [`projectGenerators[]`](#project-generators): entries of the New Project window | `1` | | `backend` | [`backend`](#plugin-backend): the plugin's own long-lived process | `1`, `2` (adds [start without a page and workspace contexts](#backend-2-workspace-contexts)) | +| `toolDefinitions` | [Tool identities, layouts, discovery and four-layer candidates](toolchains.md) | `1` (Core only) | +| `toolchainProviders` | [Provider declarations and permission-bound answer validation](toolchains.md) | `1` (Core only) | +| `executableResolution` | [Shared executable candidates](#shared-executable-candidates-executableresolution-1); v2 adds tool-definition candidates without changing existing Build candidate sets | `1`, `2` (v2 Core only) | Version 1 of every capability means what its section means at level 2 (runtime launches, launch templates, `initializationOptions`, and so on), so @@ -502,6 +605,7 @@ Rules for publishing across levels and capability versions: | `backend.launch` | `backend` is declared | | `credentials.store` | the backend uses the [credentials host service](#host-services-protocol-version-2); refused without a `backend` | | `workspace.metadata` | the backend uses the [workspace host service](#workspace-contexts-and-capability-discovery-protocol-version-3); refused without `backend` 2 | +| `editor.actions` | the backend registers independent editor actions; requires `backend` 2 and `documents.read`, grants only `editor/registerActions`, not document edits or domain execution | | Activation event | Meaning | | --- | --- | @@ -2094,6 +2198,7 @@ methods. Unknown options fail before side effects with `unsupportedOption`. | --- | --- | --- | --- | | `documents/list` | `documents.read` | none | `{documents:[]}` | | `documents/getSnapshot` | `documents.read` | `documentID` | `{document:, text}` from the dirty live buffer | +| `editor/registerActions` | `editor.actions` (+ `documents.read`) | `id,function,selector` | `{subscriptionID}`; independent editor actions, see below | | `editor/getActive` | `documents.read` | none | summary of the foreground text tab, or `null` | | `editor/getSelections` | `documents.read` | `documentID` | `{documentID,version,selections:[{anchor,active}],primaryIndex}` | | `editor/applyEdits` | `editor.edit` | `documentID,expectedVersion,edits:[{range,text}]` | `{documentID,version,applied}` | @@ -2168,6 +2273,79 @@ plugins. `didRun` remains an event, not an acknowledgement that the command's business action succeeded; a future result-bearing execute API must use the existing request lifecycle rather than infer success from notification delivery. +### Independent editor actions (protocol version 3) + +Editor actions are a host presentation capability of an ordinary tool backend, +not an LSP, Build, Run, Test or Debug provider. A play icon is presentation, not +an execution kind. The host understands the bounded range/title/icon/invocation +shape; HTTP/SQL parsing and execution remain private to the plugin. Native domain +workflows still use their own typed contracts, never a private-function substitute. + +A backend declares `backend: 2`, `documents.read` and `editor.actions`, discovers +`lithe/host/editor/registerActions` with `getCapabilities`, then registers per +workspace context: + +```json +{ + "context": {"contextID": "w1", "generation": 1}, + "id": "requestActions", + "function": "requests.collectActions", + "selector": {"fileExtensions": ["http", "rest"], "fileNames": []} +} +``` + +- Registration returns `{subscriptionID}`. Dispose with the existing subscription + API. It only records the binding and never calls the backend while answering; + collection runs later. The function must already be registered on that session. +- No manifest `runMarkers` section or capability, no special command notification, + and no language-server/Run declaration is added. The first implementation starts + through existing `onWorkspaceOpen` (or an existing explicit entry); registration + cannot discover a backend that has not started. Backend installation still needs + restart. Standalone backends have an application-scoped enablement module, + without a fake UI or language provider; safe mode/quarantine/disable still veto + their startup. Example package: `fixtures/editor-actions.plugin.json`. +- A selector has 1 to 32 literal names/extensions; extensions accept an optional + leading dot. Matching is case-insensitive. At most 32 bindings per plugin across + contexts; duplicate registration IDs in a context are refused. +- The host invokes the collection function via existing `lithe/function/invoke` + with workspace context and `{document:{documentID,version,path},text}`. Version + is the same public buffer version as `documents/getSnapshot`, not a WebView + revision. Source is the dirty live buffer, capped at 128 KiB. Remote workspaces + and read-only preview surfaces are not served by this first implementation. +- Collection is read-only by contract: no network request or business execution. + Edits invalidate the old actions immediately and cancel older collection work; + the host debounces fresh collection and rejects stale or withdrawn results. + macOS bounds the whole collection batch to eight seconds, not eight seconds per + binding. Refresh acknowledges scheduling without waiting for collectors; a slow + or failed collector must not fail editing or the save synchronization barrier. +- Return `{actions:[{id,range:{start:{line,column},end:{line,column}},title,icon, + invoke:{functionID,params?}}]}`. Positions are one-based UTF-16, half-open and + cannot split surrogate pairs. Each range stays in the exact input document. + `id` is 1 to 128 bytes without controls, unique per answer; `title` is 1 to 200 + characters on one line. `icon` is `run` or `run-all`, a host-owned presentation + token. At most 1000 actions and 1 MiB serialized; malformed answers are refused + whole. Same-line actions share a menu rather than silently replacing each other. +- Each invocation target is a currently registered function of the same plugin; + `params` is a plugin-private JSON object. The host stores it behind an opaque + per-batch action key, never accepts invocation payloads from the web editor, and + validates document/context/session/registration again on click. It calls the + function with `{documentID,expectedVersion,actionID,data:}` through the + ordinary invocation lifecycle. The plugin validates/re-reads the live document + before side effects. Click executions are not cancelled by unrelated re-parsing; + withdrawing the binding, ending its session/context or tearing down the owning + editor cancels them. The host never retries side effects automatically. +- Registration/collection validation is shared Core command + `plugins.editorActions.decode`; public host failures retain protocol 3's existing + distinct codes (`invalidParams`, `permissionDenied`, `versionConflict`, etc.). + No tool-private error or result is interpreted as a build/test result. +- Existing `commands/didRun {commandID,context?}` is unchanged. A command-palette + entry can read the document/selection and call the same plugin implementation. +- This is an additive host method within protocol 3, not a protocol-version bump. + Old hosts do not advertise it. A plugin can retain its existing command/window + entry point; a package requiring `editor.actions` is deliberately refused by an + older host that does not recognize that permission, rather than silently granted. + Grammar capability admission is separate. SDK/contract mirrors still need sync. + ### Compatibility - Packages of every API level are unchanged: functions add no manifest field. @@ -2260,6 +2438,8 @@ command payload is a Core `invalid_request` failure instead. | `plugin.build.invalidSelection` | Core | A build request selects a scope, target, filter, or argument the declaration does not allow, or misses a required path | | `plugin.build.invalidOutput` | Core | Build-tool output or a report does not fit its decoder | | `plugin.launch.invalidRequest` | Core | A launch plan request has an unusable directory or an unknown architecture | +| `plugin.grammar.invalid` | Core | A language's grammar file is not a Monarch grammar Core accepts; `field` names the failing part | +| `plugin.grammar.unavailable` | Core | The plugin declares no grammar for the language | | `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | | `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/1lck/Lithe-IDEA/blob/preview/shared/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | diff --git a/contract/v1/fixtures/editor-actions.plugin.json b/contract/v1/fixtures/editor-actions.plugin.json new file mode 100644 index 0000000..2c499d2 --- /dev/null +++ b/contract/v1/fixtures/editor-actions.plugin.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.editoractions", + "displayName": "Editor Actions", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "documents.read", + "editor.actions", + "workspace.metadata" + ], + "activationEvents": [ + "onWorkspaceOpen" + ], + "languages": [], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/backend" + }, + "arguments": [ + "--state", + "${stateDir}" + ] + } +} diff --git a/contract/v1/fixtures/function-protocol.json b/contract/v1/fixtures/function-protocol.json index b79fdcb..6bd6b38 100644 --- a/contract/v1/fixtures/function-protocol.json +++ b/contract/v1/fixtures/function-protocol.json @@ -532,7 +532,10 @@ "method": "lithe/host/process/run", "params": {}, "expected": { "rpcCode": -32601, "code": "plugin.host.unavailable" } - } + }, + {"name": "editor actions are a discoverable independent host service", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/editor/registerActions"], "params": {"context": {"contextID": "w1", "generation": 1}, "id": "actions", "function": "actions.collect", "selector": {"fileExtensions": ["http"]}}, "expected": {"forward": "editor"}}, + {"name": "document reads do not grant editor action contribution", "method": "lithe/host/editor/registerActions", "version": 3, "hostServices": ["lithe/host/documents/getSnapshot"], "params": {}, "expected": {"code": "plugin.host.permissionDenied", "rpcCode": -32010}}, + {"name": "protocol 2 does not expose independent editor actions", "method": "lithe/host/editor/registerActions", "version": 2, "hostServices": ["lithe/host/editor/registerActions"], "params": {}, "expected": {"code": "plugin.host.unavailable", "rpcCode": -32601}} ], "invocationContexts": [ { diff --git a/contract/v1/fixtures/grammars/http.monarch.json b/contract/v1/fixtures/grammars/http.monarch.json new file mode 100644 index 0000000..ca824f0 --- /dev/null +++ b/contract/v1/fixtures/grammars/http.monarch.json @@ -0,0 +1,44 @@ +{ + "defaultToken": "", + "tokenPostfix": ".http", + "methods": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE", "CONNECT"], + "brackets": [ + { "open": "{", "close": "}", "token": "delimiter.curly" }, + { "open": "[", "close": "]", "token": "delimiter.square" } + ], + "tokenizer": { + "root": [ + ["###.*$", "comment.separator"], + ["\\s*(#|//).*$", "comment"], + ["@[A-Za-z_][\\w.-]*", "variable", "@variableValue"], + ["([A-Z]+)(\\s+)", [{ "cases": { "@methods": "keyword", "@default": "identifier" } }, ""], "@requestTarget"], + { "include": "@variables" }, + ["[A-Za-z][\\w-]*(?=\\s*:)", "attribute.name", "@headerValue"], + ["[{}\\[\\]]", "@brackets"], + ["\"([^\"\\\\]|\\\\.)*\"", "string"], + ["-?\\d+(\\.\\d+)?([eE][-+]?\\d+)?", "number"], + ["\\b(true|false|null)\\b", "keyword"] + ], + "requestTarget": [ + { "include": "@variables" }, + ["\\s+HTTP/[\\d.]+\\s*$", "keyword", "@pop"], + ["[^\\s{]+", "string.link"], + ["$", "", "@pop"] + ], + "headerValue": [ + [":\\s*", "delimiter"], + { "include": "@variables" }, + ["[^{]+", "attribute.value"], + ["$", "", "@pop"] + ], + "variableValue": [ + ["\\s*=\\s*", "delimiter"], + { "include": "@variables" }, + ["[^{]+", "string"], + ["$", "", "@pop"] + ], + "variables": [ + ["\\{\\{[^}]*\\}\\}", "variable"] + ] + } +} diff --git a/contract/v1/fixtures/invalid-manifests.json b/contract/v1/fixtures/invalid-manifests.json index dd82092..2a20bb4 100644 --- a/contract/v1/fixtures/invalid-manifests.json +++ b/contract/v1/fixtures/invalid-manifests.json @@ -1049,6 +1049,11 @@ "base": "backend-workspace.plugin.json", "edits": [{ "op": "set", "path": "/backend/arguments", "value": ["--cache", "${workspaceStateDir}"] }], "expectedCode": "plugin.manifest.invalid" - } + }, + {"name": "a grammar needs languages 2", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/requires/languages", "value": 1}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "languages 2 is used by a grammar or configuration", "base": "language-grammar.plugin.json", "edits": [{"op": "remove", "path": "/languages/0/grammar"}, {"op": "remove", "path": "/languages/0/configuration"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a grammar names a known engine", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/kind", "value": "textmate"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a grammar file stays in the package", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/grammar/path", "value": "../http.monarch.json"}], "expectedCode": "plugin.manifest.invalid"}, + {"name": "a comment delimiter is one short line", "base": "language-grammar.plugin.json", "edits": [{"op": "set", "path": "/languages/0/configuration/lineComment", "value": "#\n"}], "expectedCode": "plugin.manifest.invalid"} ] } diff --git a/contract/v1/fixtures/language-grammar.plugin.json b/contract/v1/fixtures/language-grammar.plugin.json new file mode 100644 index 0000000..09be19b --- /dev/null +++ b/contract/v1/fixtures/language-grammar.plugin.json @@ -0,0 +1,43 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.httpgrammar", + "displayName": "HTTP Request Syntax", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "languages": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Example", + "signatureRequirement": "publisherPackage" + }, + "permissions": [], + "activationEvents": [], + "languages": [ + { + "id": "http", + "displayName": "HTTP Request", + "fileExtensions": [ + "http", + "rest" + ], + "grammar": { + "kind": "monarch", + "path": "grammars/http.monarch.json" + }, + "configuration": { + "lineComment": "#", + "brackets": [ + ["{", "}"], + ["[", "]"] + ], + "autoClosingPairs": [ + ["{", "}"], + ["[", "]"], + ["\"", "\""] + ] + } + } + ] +} diff --git a/contract/v1/fixtures/tool-definition.plugin.json b/contract/v1/fixtures/tool-definition.plugin.json new file mode 100644 index 0000000..532821f --- /dev/null +++ b/contract/v1/fixtures/tool-definition.plugin.json @@ -0,0 +1,159 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.java-support", + "displayName": "Tool definition fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "toolDefinitions": 1, + "executableResolution": 2 + }, + "vendor": { + "id": "org.example", + "displayName": "Fixture publisher", + "signatureRequirement": "publisherPackage" + }, + "permissions": [], + "toolDefinitions": [ + { + "id": "java", + "displayName": "JDK", + "icon": { + "host": "plugin" + }, + "executableLayouts": [ + { + "path": "bin/java", + "platforms": [ + "macos", + "linux" + ] + }, + { + "path": "bin/java.exe", + "platforms": [ + "windows" + ] + } + ], + "executables": [ + { + "id": "pinned", + "kind": "setting", + "settingID": "executable", + "layer": "project" + }, + { + "id": "project-request", + "kind": "toolchain", + "scope": "project" + }, + { + "id": "default", + "kind": "setting", + "settingID": "executable", + "layer": "global" + }, + { + "id": "global-request", + "kind": "toolchain", + "scope": "global" + }, + { + "id": "home", + "kind": "environment", + "variable": "JAVA_HOME", + "path": "bin/java", + "platforms": [ + "macos", + "linux" + ] + }, + { + "id": "home-windows", + "kind": "environment", + "variable": "JAVA_HOME", + "path": "bin/java.exe", + "platforms": [ + "windows" + ] + }, + { + "id": "shell", + "kind": "command", + "command": "java" + }, + { + "id": "detected", + "kind": "toolchain", + "scope": "installed" + } + ], + "version": { + "arguments": [ + "-version" + ], + "pattern": "version \\\"(?:1\\.)?(\\d+(?:\\.\\d+)*)" + }, + "homeLayouts": [ + { + "match": "*.jdk", + "home": "Contents/Home", + "platforms": [ + "macos" + ] + }, + { + "match": "*", + "home": "." + } + ], + "discovery": [ + { + "root": { + "kind": "absolute", + "path": "/Library/Java/JavaVirtualMachines" + }, + "pattern": "*.jdk", + "platforms": [ + "macos" + ] + }, + { + "root": { + "kind": "home" + }, + "pattern": "Library/Java/JavaVirtualMachines/*.jdk", + "platforms": [ + "macos" + ] + } + ], + "homeVariable": "JAVA_HOME", + "commands": [ + "java" + ], + "wrappers": [], + "exports": [ + { + "name": "JAVA_HOME", + "value": "${home}" + }, + { + "name": "LITHE_JAVA_PATH", + "value": "${executable}" + } + ], + "pathPrepend": [ + "${home}/bin" + ], + "launch": [ + "${executable}", + "${runtimeArguments}", + "-jar", + "${entry}", + "${arguments}" + ] + } + ] +} diff --git a/contract/v1/fixtures/toolchain-provider.plugin.json b/contract/v1/fixtures/toolchain-provider.plugin.json new file mode 100644 index 0000000..ee9bb19 --- /dev/null +++ b/contract/v1/fixtures/toolchain-provider.plugin.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 2, + "id": "dev.lithe.fixture.mise", + "displayName": "Toolchain provider fixture", + "version": "1.0.0", + "pluginAPIVersion": 3, + "requires": { + "toolchainProviders": 1, + "backend": 2, + "serverProcess": 1 + }, + "vendor": { + "id": "org.example", + "displayName": "Fixture publisher", + "signatureRequirement": "publisherPackage" + }, + "permissions": [ + "backend.launch", + "toolchains.provide" + ], + "toolchainProviders": [ + { + "id": "mise", + "displayName": "mise", + "tools": [ + "java", + "node", + "maven", + "pnpm", + "go" + ], + "watchFiles": [ + { + "pattern": "mise.toml", + "reader": "native" + }, + { + "pattern": "mise.local.toml", + "reader": "native" + }, + { + "pattern": ".mise.toml", + "reader": "native" + }, + { + "pattern": ".tool-versions", + "reader": "compatible" + }, + { + "pattern": ".mise/config.toml", + "reader": "native" + }, + { + "pattern": ".mise/config.local.toml", + "reader": "native" + } + ] + } + ], + "backend": { + "launch": { + "kind": "packaged", + "path": "bin/mise-provider" + }, + "arguments": [ + "--stdio" + ] + } +} diff --git a/contract/v1/plugin-manifest.schema.json b/contract/v1/plugin-manifest.schema.json index 1d2b09a..3c692b1 100644 --- a/contract/v1/plugin-manifest.schema.json +++ b/contract/v1/plugin-manifest.schema.json @@ -13,7 +13,9 @@ { "if": { "required": ["pluginAPIVersion"], "properties": { "pluginAPIVersion": { "maximum": 3 } } }, "then": { "properties": { "debugAdapters": { "maxItems": 0 } } } - } + }, + {"if": {"required": ["toolDefinitions"]}, "then": {"properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolDefinitions", "executableResolution"], "properties": {"executableResolution": {"const": 2}}}}}}, + {"if": {"required": ["toolchainProviders"]}, "then": {"required": ["backend"], "properties": {"pluginAPIVersion": {"minimum": 3}, "requires": {"required": ["toolchainProviders", "backend", "serverProcess"], "properties": {"backend": {"const": 2}}}, "permissions": {"allOf": [{"contains": {"const": "toolchains.provide"}}, {"contains": {"const": "backend.launch"}}]}}}} ], "properties": { "schemaVersion": { "const": 2 }, @@ -32,14 +34,16 @@ "buildProviders": { "description": "Required exactly when any buildSystems[].provider is present, including own-process and shared language-server providers. Declares host support for Provider orchestration, independent of declarative buildSystems and serverProcess launch support; version 2 adds task execution, version 3 test execution/discovery, version 4 application run preparation, and version 5 native debug preparation; version 6 optional read-only model inputs.", "enum": [1, 2, 3, 4, 5, 6] }, "buildSystems": { "description": "Declarative build detection, models, and command plans; does not imply buildProviders support.", "enum": [1, 2] }, "debugAdapters": { "description": "Required exactly when debugAdapters is non-empty. Version 2 adds bounded target argument and environment bindings. The DAP session itself is negotiated by the adapter in initialize.", "enum": [1, 2] }, - "executableResolution": { "enum": [1], "description": "Shared executable candidate ordering and strict selection." }, + "executableResolution": { "enum": [1, 2], "description": "Shared executable candidate ordering and strict selection." }, "executionProviders": { "enum": [1] }, "languageServers": { "enum": [1] }, - "languages": { "enum": [1] }, + "languages": { "description": "Version 2 adds languages[].grammar and languages[].configuration and is required exactly when one of them is used.", "enum": [1, 2] }, "packageTrust": { "description": "Required exactly when vendor.bundledSignatureRequirement is present.", "enum": [1] }, "projectGenerators": { "description": "Entries of the New Project window: fields, requirements, and steps.", "enum": [1] }, "serverProcess": { "description": "The launch fields of language servers, of build providers that start their own process, of VCS providers, of debug adapters, and of the plugin backend. Version 2 adds runSelectedRuntime and is required exactly when a debug adapter uses it (pluginAPIVersion 4).", "enum": [1, 2] }, "settings": { "enum": [1] }, + "toolDefinitions": { "const": 1 }, + "toolchainProviders": { "const": 1 }, "ui": { "description": "Version 2 adds web content; version 3 adds native controls; version 4 adds backend-only commands.", "enum": [1, 2, 3, 4] }, "vcsProviders": { "description": "Required exactly when vcsProviders is non-empty. The provider protocol itself is negotiated in initialize (shared/contracts/vcs-provider-v1.md).", "enum": [1] } } @@ -66,8 +70,10 @@ "workspace.search", "documents.read", "editor.edit", + "editor.actions", "editor.open", "preview.open", + "toolchains.provide", "browser.open", "window.messages"] } }, @@ -79,6 +85,8 @@ "pattern": "^((onLanguage|onRun|onTest|onView|onCommand|onBuildSystem|onVcsRoot|onDebug):[a-z0-9.+_-]+|onWorkspaceOpen)$" } }, + "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolchainToolDefinition" } }, + "toolchainProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/toolchainProvider" } }, "languages": { "type": "array", "minItems": 0, @@ -1056,9 +1064,37 @@ "projectDependencyPaths": { "$ref": "#/$defs/fileNameList" }, "projectBinaryPaths": { "$ref": "#/$defs/fileNameList" } } + }, + "grammar": { + "description": "languages 2: syntax highlighting from a package file, applied by the editor without any process.", + "type": "object", + "additionalProperties": false, + "required": ["kind", "path"], + "properties": { + "kind": { "description": "Highlighting engine of the file. Version 2 defines monarch: the JSON form of a Monaco Monarch definition.", "enum": ["monarch"] }, + "path": { "description": "Package-relative grammar file inside the package.", "type": "string", "minLength": 1 } + } + }, + "configuration": { + "description": "languages 2: comment and bracket behavior of the editor.", + "type": "object", + "additionalProperties": false, + "properties": { + "lineComment": { "$ref": "#/$defs/editorDelimiter" }, + "blockComment": { "$ref": "#/$defs/editorDelimiterPair" }, + "brackets": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/editorDelimiterPair" } }, + "autoClosingPairs": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/editorDelimiterPair" } } + } } } }, + "editorDelimiter": { "type": "string", "minLength": 1, "maxLength": 16 }, + "editorDelimiterPair": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { "$ref": "#/$defs/editorDelimiter" } + }, "languageServer": { "type": "object", "$ref": "#/$defs/serverProcess", @@ -2893,6 +2929,586 @@ } } } + }, + "toolchainIdentifier": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,63}$" + }, + "toolchainToolID": { + "type": "string", + "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$", + "maxLength": 192 + }, + "toolchainLabel": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]+$" + }, + "toolchainRelativePath": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:*?\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:*?\\u0000-\\u001f\\u007f]+)*$" + }, + "toolchainBoundedGlob": { + "description": "At most eight segments; only single-segment * is supported, never **, ?, character classes, or traversal. Semantic validation also bounds expansion.", + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?!/)(?!.*\\*\\*)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+){0,7}$" + }, + "toolchainCommand": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "toolchainSafeEnvironmentName": { + "type": "string", + "maxLength": 64, + "pattern": "^(?!PATH$)(?!DYLD_)(?!LD_)[A-Z_][A-Z0-9_]*$" + }, + "toolchainPlatforms": { + "type": "array", + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "items": { + "enum": [ + "macos", + "windows", + "linux" + ] + } + }, + "toolchainVersion": { + "type": "object", + "additionalProperties": false, + "required": [ + "arguments", + "pattern" + ], + "properties": { + "arguments": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "type": "string", + "pattern": "^--?[A-Za-z][A-Za-z0-9-]*$", + "maxLength": 64 + } + }, + "pattern": { + "description": "Rust-regex-compatible, first capture is a dotted numeric version. Validity, capture count and runtime bounds require semantic checks.", + "type": "string", + "minLength": 1, + "maxLength": 512 + } + } + }, + "toolchainExecutableLayout": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainHomeLayout": { + "type": "object", + "additionalProperties": false, + "required": [ + "match", + "home" + ], + "properties": { + "match": { + "allOf": [ + { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + { + "pattern": "^[^/]+$" + } + ] + }, + "home": { + "oneOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/toolchainRelativePath" + } + ] + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainDiscovery": { + "type": "object", + "additionalProperties": false, + "required": [ + "root", + "pattern" + ], + "properties": { + "root": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "home" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "variable" + ], + "properties": { + "kind": { + "const": "environment" + }, + "variable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "const": "absolute" + }, + "path": { + "type": "string", + "minLength": 2, + "maxLength": 256, + "pattern": "^(?:/|[A-Za-z]:[\\\\/])[^\\u0000-\\u001f\\u007f]*$" + } + } + } + ] + }, + "pattern": { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainWrapper": { + "type": "object", + "additionalProperties": false, + "required": [ + "path" + ], + "properties": { + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "requires": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainRelativePath" + } + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + } + }, + "toolchainCandidateMetadata": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "priority": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "insertBefore": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "insertAfter": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "platforms": { + "$ref": "#/$defs/toolchainPlatforms" + } + }, + "not": { + "anyOf": [ + { + "required": [ + "priority", + "insertBefore" + ] + }, + { + "required": [ + "priority", + "insertAfter" + ] + }, + { + "required": [ + "insertBefore", + "insertAfter" + ] + } + ] + } + }, + "toolchainCandidate": { + "$ref": "#/$defs/toolchainCandidateMetadata", + "unevaluatedProperties": false, + "oneOf": [ + { + "required": [ + "kind", + "settingID", + "layer" + ], + "properties": { + "kind": { + "const": "setting" + }, + "settingID": { + "const": "executable" + }, + "layer": { + "enum": [ + "project", + "global" + ] + } + } + }, + { + "required": [ + "kind", + "scope" + ], + "properties": { + "kind": { + "const": "toolchain" + }, + "scope": { + "enum": [ + "project", + "global", + "installed" + ] + } + } + }, + { + "required": [ + "kind", + "path" + ], + "properties": { + "kind": { + "const": "projectFile" + }, + "path": { + "$ref": "#/$defs/toolchainRelativePath" + }, + "requires": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainRelativePath" + } + }, + "projectKind": { + "enum": [ + "wrapper", + "environment" + ], + "default": "wrapper" + }, + "home": { + "oneOf": [ + { + "const": "." + }, + { + "$ref": "#/$defs/toolchainRelativePath" + } + ] + } + }, + "allOf": [ + { + "if": { + "required": [ + "projectKind" + ], + "properties": { + "projectKind": { + "const": "environment" + } + } + }, + "then": { + "required": [ + "home" + ] + }, + "else": { + "not": { + "required": [ + "home" + ] + } + } + } + ] + }, + { + "required": [ + "kind", + "variable", + "path" + ], + "properties": { + "kind": { + "const": "environment" + }, + "variable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "path": { + "$ref": "#/$defs/toolchainRelativePath" + } + } + }, + { + "required": [ + "kind", + "command" + ], + "properties": { + "kind": { + "const": "command" + }, + "command": { + "$ref": "#/$defs/toolchainCommand" + } + } + } + ] + }, + "toolchainLaunchTokens": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "prefixItems": [ + { + "const": "${executable}" + } + ], + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^(?:\\$\\{(?:home|executable|entry|runtimeArguments|arguments)\\}|[^$\\u0000-\\u001f\\u007f]+)$" + } + }, + "toolchainToolDefinition": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "executableLayouts", + "executables", + "version", + "commands", + "launch" + ], + "properties": { + "id": { + "$ref": "#/$defs/toolchainToolID" + }, + "displayName": { + "$ref": "#/$defs/toolchainLabel" + }, + "icon": { + "$ref": "#/$defs/uiIcon" + }, + "executableLayouts": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainExecutableLayout" + } + }, + "executables": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "items": { + "$ref": "#/$defs/toolchainCandidate" + } + }, + "version": { + "$ref": "#/$defs/toolchainVersion" + }, + "homeLayouts": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainHomeLayout" + } + }, + "discovery": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/toolchainDiscovery" + } + }, + "homeVariable": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "commands": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainCommand" + } + }, + "wrappers": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/toolchainWrapper" + } + }, + "exports": { + "type": "array", + "maxItems": 16, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "$ref": "#/$defs/toolchainSafeEnvironmentName" + }, + "value": { + "enum": [ + "${home}", + "${executable}" + ] + } + } + } + }, + "pathPrepend": { + "type": "array", + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "maxLength": 256, + "pattern": "^\\$\\{home\\}(?:/(?!\\.{1,2}(?:/|$))[^/\\\\:$*?\\u0000-\\u001f\\u007f]+)*$" + } + }, + "launch": { + "$ref": "#/$defs/toolchainLaunchTokens" + } + } + }, + "toolchainProvider": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "displayName", + "tools" + ], + "properties": { + "id": { + "$ref": "#/$defs/toolchainIdentifier" + }, + "displayName": { + "$ref": "#/$defs/toolchainLabel" + }, + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolchainToolID" + } + }, + "watchFiles": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "pattern", + "reader" + ], + "properties": { + "pattern": { + "$ref": "#/$defs/toolchainBoundedGlob" + }, + "reader": { + "enum": [ + "native", + "compatible" + ] + } + } + } + } + } } } } diff --git a/contract/v1/proposals/toolchains/README.md b/contract/v1/proposals/toolchains/README.md new file mode 100644 index 0000000..73b6019 --- /dev/null +++ b/contract/v1/proposals/toolchains/README.md @@ -0,0 +1,137 @@ +# Plugin-injected toolchains (draft) + +> **Status: broader proposal; a Core-only foundation is now implemented.** +> The implemented subset is specified in [the active contract](../../toolchains.md). +> This directory remains a review artifact for +> [PR #1581's replacement plan](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +> It is not an extension of the currently accepted `plugin.json` format. +> The active manifest admits the implemented definition/provider fields, not +> these projection envelopes or the whole proposal. Native runtime selection, +> consumer migration, query transport, persistence and UI remain unimplemented. + +## Ownership + +A **tool definition** describes a tool's identity, executable layouts, version +probe, discovery locations, launch template, and environment exports. Only an +enabled plugin contributes a definition. The host has no built-in definitions, +reserved-name alias table, or tool-name-dependent launch logic in the proposed +path. Officially signed plugins may define short IDs; community plugins define +IDs in their own plugin namespace and may provide installations for any enabled +definition, including official short IDs. Verified package identity, not a +manifest assertion or ID prefix, establishes official status. + +A **toolchain provider** reports installed tools and, when it declares watched +files, the project's or user's requirements. It covers installation shapes the +frozen declarative `discovery` primitives cannot express; a plugin may provide +installations of a tool it defines. Both routes feed one automatic candidate set. +Version-manager configuration, version aliases, and file precedence +remain upstream/provider-owned. The host never interprets opaque version +requests such as `lts`. A **consumer** references a defined tool and declares +whether a changed selection needs a restart, resynchronization, or the next run. +These are independent contributions, not additional language/build requirements. + +The host owns a single generic resolver and query client, directory-scoped pins, +and the environment/sources presentation skeleton. Provider-specific settings +are injected through that provider plugin's existing settings page. Definitions +and provider replies are untrusted input; permission to provide toolchains does +not authorize replacing unrelated host commands. + +## Review Artifacts + +| File | Purpose | +| --- | --- | +| [manifest.md](manifest.md) | Definition, provider, consumer, trust, and capability contribution rules | +| [manifest-contributions.schema.json](manifest-contributions.schema.json) | Standalone draft contribution projection, not a complete plugin manifest | +| [protocol.md](protocol.md) | Query lifecycle, validation, four-layer selection, storage, and stable failures | +| [query.schema.json](query.schema.json) | Query request/reply shapes | +| [resolution.schema.json](resolution.schema.json) | Resolver outcomes and structured reasons | +| [local.schema.json](local.schema.json) | Proposed extension of the machine-local document with directory pins | +| `fixtures/` | Illustrative contributions, queries, resolution outcomes, pins, and rejections | + +Schemas describe JSON shape. Signature verification, duplicate-definition +registration, enabled-plugin ownership, path containment, executable evidence, +and lifecycle state require separate semantic validation during implementation. +A shape-valid reply is never sufficient permission to launch a program. +Example filesystem paths are fictional and must not be probed on the reader's +machine; filesystem observations in cases are inputs for future test doubles. + +## Required Behavior + +Every tool uses the existing shared executable-candidate mechanism, extended +with `toolchain` candidates and structured `skip` reasons. There is no parallel +Java, Maven, or generic-command selection chain in the proposed path. + +| Layer | Selection rule | +| --- | --- | +| Pin | Nearest ancestor directory's explicit pin; an unusable pin is a terminal failure, never automatic fallback | +| Project | Nearest declaring directory; at equal depth wrapper before environment before version statement; for the same file native before compatible reader | +| Global | Lithe's explicit global default, then provider defaults; conflicting provider defaults require a user choice, using login-shell discovery until chosen | +| Automatic | Newest eligible provider installation satisfying the minimum version; manually added installations are excluded | + +An uninstalled project requirement skips with an explicit reason and stays +visible as unmet even if a lower layer succeeds. Provider installations cannot +come from the workspace; project wrappers remain project code and require the +consumer's existing explicit-action/consent policy. Queries, discovery, settings +inspection, and passive project import must not execute project code. + +The built-in **system provider** is a mechanism, not a contributor of definitions: +it scans only enabled definitions' declared locations. Adding a manager or tool +must not require a new parser, directory constant, or tool-name branch in Core. +The first implementation must prove this with two different providers. + +Each query has an operation identity, cancellation, a bounded deadline, and a +snapshot generation. New, changed, or deleted declared watch inputs invalidate +the entire generation and requery all enabled providers. Late replies cannot +repopulate revoked state. Disabling/uninstalling the defining plugin removes its +tool rows, consumers, settings, and exports, but retains stored pins for a later +reenable. Unknown/disabled definitions grant no executable authority; only bounded +display diagnostics may remain to explain the missing plugin. + +Reading `.lithe/toolchains/local.json` never creates it. Explicitly confirmed pin +edits use conflict-protected atomic replacement, then reread the result; the first +write preserves `.lithe/.gitignore` and adds `/toolchains/local.json`. Manually +added installations live in platform-owned application storage and are pin-only. +Installed app bundles and plugin packages remain read-only throughout. + +## Staged Landing And Compatibility + +1. **Proposal:** review schemas, examples, trust boundaries, and + the decision record. No host capability is enabled and no production format + is relaxed merely by this draft. Only the active contract allocates supported + Core capability versions; native hosts have not advertised them. +2. **Core and query client:** implement definition/answer validation, candidate + extensions, pin inheritance, full-generation invalidation, and macOS query + lifecycle. Preserve strict rejection by unsupported hosts. System discovery + consumes plugin declarations, never a built-in definition data file. +3. **Selection and UI:** integrate consumers and the environment/sources pages, + move Java definitions into Java Support and coordinate Maven definitions with + [PR #1558](https://github.com/1lck/Lithe-IDEA/pull/1558). Remove migrated legacy + special cases in the same slice. Other tools keep existing behavior until + their official plugins declare definitions; do not add new compatibility + aliases or tool-specific branches. +4. **Provider acceptance:** mise delegates to its upstream machine-readable CLI; + SDKMAN supplies the second, different provider without Core changes. Each + owns its own settings and bootstrap runtime. Backend-bearing providers still + require restart in the initial implementation; this proposal does not relax + live activation. + +The existing executable-resolution v1 contract does not permit project tool +overrides and supports only the documented consumer positions. These new +semantics need explicit capability admission and implemented adapters; do not +retroactively change v1 or advertise support based on schema changes alone. +Runtime minimum-version checking must use the definition's version probe rather +than a new per-tool branch. No current API is removed by this proposal. + +The Chinese architecture decision and acceptance criteria are recorded in the +[proposed Agent Note](https://github.com/1lck/Lithe-IDEA/blob/preview/.agents/notes/proposed/architecture/2026-10-31-plugin-injected-toolchains.md). +The UI reference is the +[interactive prototype](https://gist.github.com/xiaoyumuxi/2ae889a6592599b13998bc811c647942). +This directory specifies host-readable contracts, not a plugin-author tutorial +or SDK implementation; those belong in `lithe-plugin-docs`. + +After the proposal merges, sync the byte-identical contract mirror in +`Lithe-IDEA/lithe-plugin-docs` using +`node scripts/contract.mjs sync --revision ` and commit +there. Draft status must survive the sync. SDK protocol compatibility and both +SDK end-to-end suites become implementation-stage gates; this proposal does not +claim that either SDK already implements these messages. diff --git a/contract/v1/proposals/toolchains/fixtures/java-tool-definition.json b/contract/v1/proposals/toolchains/fixtures/java-tool-definition.json new file mode 100644 index 0000000..501ec9f --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/java-tool-definition.json @@ -0,0 +1,50 @@ +{ + "proposalVersion": 1, + "pluginID": "dev.lithe.fixture.java-support", + "requires": { "toolDefinitions": 1, "executableResolution": 2, "toolchainConsumers": 1 }, + "permissions": [], + "toolDefinitions": [ + { + "id": "java", + "displayName": "JDK", + "icon": { "host": "plugin" }, + "executableLayouts": [ + { "path": "bin/java", "platforms": ["macos", "linux"] }, + { "path": "bin/java.exe", "platforms": ["windows"] } + ], + "executables": [ + { "id": "pinned", "kind": "setting", "settingID": "executable", "layer": "project" }, + { "id": "project-request", "kind": "toolchain", "scope": "project" }, + { "id": "default", "kind": "setting", "settingID": "executable", "layer": "global" }, + { "id": "global-request", "kind": "toolchain", "scope": "global" }, + { "id": "home", "kind": "environment", "variable": "JAVA_HOME", "path": "bin/java", "platforms": ["macos", "linux"] }, + { "id": "home-windows", "kind": "environment", "variable": "JAVA_HOME", "path": "bin/java.exe", "platforms": ["windows"] }, + { "id": "shell", "kind": "command", "command": "java" }, + { "id": "detected", "kind": "toolchain", "scope": "installed" } + ], + "version": { "arguments": ["-version"], "pattern": "version \\\"(?:1\\.)?(\\d+(?:\\.\\d+)*)" }, + "homeLayouts": [ + { "match": "*.jdk", "home": "Contents/Home", "platforms": ["macos"] }, + { "match": "*", "home": "." } + ], + "discovery": [ + { "root": { "kind": "absolute", "path": "/Library/Java/JavaVirtualMachines" }, "pattern": "*.jdk", "platforms": ["macos"] }, + { "root": { "kind": "home" }, "pattern": "Library/Java/JavaVirtualMachines/*.jdk", "platforms": ["macos"] } + ], + "homeVariable": "JAVA_HOME", + "commands": ["java"], + "wrappers": [], + "exports": [ { "name": "JAVA_HOME", "value": "${home}" }, { "name": "LITHE_JAVA_PATH", "value": "${executable}" } ], + "pathPrepend": ["${home}/bin"], + "launch": ["${executable}", "${runtimeArguments}", "-jar", "${entry}", "${arguments}"] + } + ], + "consumers": [ + { + "id": "fixture-language-server", + "requiresTools": [ { "tool": "java", "minimumVersion": "21" } ], + "onToolchainChange": "restart", + "launch": { "kind": "runtime", "tool": "java", "entry": "server/fixture-language-server.jar" } + } + ] +} diff --git a/contract/v1/proposals/toolchains/fixtures/local.json b/contract/v1/proposals/toolchains/fixtures/local.json new file mode 100644 index 0000000..7bf8520 --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/local.json @@ -0,0 +1,27 @@ +{ + "version": 1, + "toolchains": {}, + "pins": { + ".": { + "java": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise" + } + }, + "apps/web": { + "java": { + "home": "/opt/lithe-fixtures/manual/java/17.0.12", + "version": "17.0.12", + "provider": "host:manual" + } + }, + ".config/tools": { + "java": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise" + } + } + } +} diff --git a/contract/v1/proposals/toolchains/fixtures/manifest-rejections.json b/contract/v1/proposals/toolchains/fixtures/manifest-rejections.json new file mode 100644 index 0000000..89adc06 --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/manifest-rejections.json @@ -0,0 +1,114 @@ +{ + "proposalVersion": 1, + "description": "Proposed rejection scenarios, not current Core tests. Apply RFC 6902 patch to base; schema checks shape, registry checks enabled definition references and manifest semantics, hostTrust checks independently verified installation provenance. Context is supplied by the test host, never trusted plugin JSON.", + "cases": [ + { + "name": "provider capability requires effective permission", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/permissions", "value": ["backend.launch"] }], + "expected": { "status": "rejected", "code": "plugin.permission.undeclared" } + }, + { + "name": "export cannot replace PATH", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/exports/0/name", "value": "PATH" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "export cannot set loader injection variable", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/exports/0/name", "value": "DYLD_INSERT_LIBRARIES" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "PATH prepend cannot escape home", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/pathPrepend/0", "value": "${home}/../bin" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "watch glob cannot recurse without a bound", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolchainProviders/0/watchFiles/0/pattern", "value": "**/mise.toml" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "probe cannot pass an arbitrary script argument", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/version/arguments", "value": ["-c", "project-code"] }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "launch interpolation is argv not shell text", + "base": "java-tool-definition.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/launch/1", "value": "--args=${arguments}" }], + "expected": { "status": "rejected", "code": "plugin.template.invalid" } + }, + { + "name": "provider backend cannot bootstrap from discovered runtime", + "base": "mise-provider.json", + "phase": "schema", + "patch": [{ "op": "replace", "path": "/backend/launch", "value": { "kind": "runtime", "tool": "node", "entry": "provider/main.mjs" } }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid" } + }, + { + "name": "consumer reference to undefined tool is not a syntax error", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/consumers/0/requiresTools", "value": [{ "tool": "java", "minimumVersion": "21" }, { "tool": "dev.lithe.fixture.java-support:absent" }] }], + "context": { "enabledToolIDs": ["java"] }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "undefinedTool" } + }, + { + "name": "community plugin cannot claim reserved short definition", + "base": "java-tool-definition.json", + "phase": "hostTrust", + "patch": [], + "context": { "verifiedOrigin": "communityPackage" }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "reservedToolID" } + }, + { + "name": "community definition namespace belongs to its plugin", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/id", "value": "dev.lithe.fixture.other:java" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "foreignToolNamespace" } + }, + { + "name": "later definition cannot replace existing owner", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [], + "context": { "registeredTools": [{ "id": "java", "pluginID": "dev.lithe.fixture.first-java" }] }, + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "duplicateToolID" } + }, + { + "name": "capability list cannot advertise unused provider", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "add", "path": "/requires/toolchainProviders", "value": 1 }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "unusedCapability" } + }, + { + "name": "pattern needs a numeric version capture", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "replace", "path": "/toolDefinitions/0/version/pattern", "value": "version.*" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "missingVersionCapture" } + }, + { + "name": "ordering cannot reference another contribution set", + "base": "java-tool-definition.json", + "phase": "registry", + "patch": [{ "op": "add", "path": "/toolDefinitions/0/executables/0/insertBefore", "value": "another-set" }], + "expected": { "status": "rejected", "code": "plugin.manifest.invalid", "reason": "unknownCandidateAnchor" } + } + ] +} diff --git a/contract/v1/proposals/toolchains/fixtures/mise-provider.json b/contract/v1/proposals/toolchains/fixtures/mise-provider.json new file mode 100644 index 0000000..4ec3e07 --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/mise-provider.json @@ -0,0 +1,25 @@ +{ + "proposalVersion": 1, + "pluginID": "dev.lithe.fixture.mise", + "requires": { "toolchainProviders": 1, "backend": 2, "serverProcess": 1 }, + "permissions": ["backend.launch", "toolchains.provide"], + "toolchainProviders": [ + { + "id": "mise", + "displayName": "mise", + "tools": ["java", "node", "maven", "pnpm", "go"], + "watchFiles": [ + { "pattern": "mise.toml", "reader": "native" }, + { "pattern": "mise.local.toml", "reader": "native" }, + { "pattern": ".mise.toml", "reader": "native" }, + { "pattern": ".tool-versions", "reader": "compatible" }, + { "pattern": ".mise/config.toml", "reader": "native" }, + { "pattern": ".mise/config.local.toml", "reader": "native" } + ] + } + ], + "backend": { + "launch": { "kind": "packaged", "path": "bin/mise-provider" }, + "arguments": ["--stdio"] + } +} diff --git a/contract/v1/proposals/toolchains/fixtures/query-cases.json b/contract/v1/proposals/toolchains/fixtures/query-cases.json new file mode 100644 index 0000000..e876e4d --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/query-cases.json @@ -0,0 +1,852 @@ +{ + "proposalVersion": 1, + "status": "review-only", + "cases": [ + { + "name": "installed project requirement", + "request": { + "jsonrpc": "2.0", + "id": 1, + "method": "toolchains/query", + "params": { + "operationID": "query-1", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "operationID": "query-1", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/installs/java/21.0.4", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [ + "/opt/lithe-fixtures/workspace/mise.toml", + "/opt/lithe-fixtures/installs/java" + ] + } + }, + "filesystemEvidence": [ + { + "path": "/opt/lithe-fixtures/installs/java/21.0.4", + "canonical": "/opt/lithe-fixtures/installs/java/21.0.4", + "kind": "directory" + }, + { + "path": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "canonical": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "kind": "file", + "executable": true + } + ], + "expected": { + "validation": "accepted", + "selectedHome": "/opt/lithe-fixtures/installs/java/21.0.4" + } + }, + { + "name": "uninstalled request is retained as unmet", + "request": { + "jsonrpc": "2.0", + "id": 2, + "method": "toolchains/query", + "params": { + "operationID": "query-2", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "node" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "operationID": "query-2", + "generation": 1, + "providerID": "mise", + "installations": [], + "statements": [ + { + "tool": "node", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99", + "installation": null, + "kind": "declaration", + "reader": "native" + } + ], + "watch": [ + "/opt/lithe-fixtures/workspace/mise.toml" + ] + } + }, + "expected": { + "validation": "accepted", + "reason": { + "code": "requestedNotInstalled", + "tool": "node", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + }, + "continueToLowerLayer": true + } + }, + { + "name": "provider home inside workspace is unsafe", + "request": { + "jsonrpc": "2.0", + "id": 3, + "method": "toolchains/query", + "params": { + "operationID": "query-3", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "operationID": "query-3", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/workspace/vendor/jdk" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/workspace/vendor/jdk", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.unsafePath", + "rpcCode": -32076 + } + }, + { + "name": "cancelled query ignores late success", + "request": { + "jsonrpc": "2.0", + "id": 4, + "method": "toolchains/query", + "params": { + "operationID": "query-4", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "operationID": "query-4", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatched", + "cancelRequested", + "localCompletion", + "lateReply" + ], + "expected": { + "completionCount": 1, + "code": "plugin.toolchain.cancelled", + "rpcCode": -32800, + "publishReply": false, + "cacheReply": false + } + }, + { + "name": "global query generation rejects old result", + "request": { + "jsonrpc": "2.0", + "id": 5, + "method": "toolchains/query", + "params": { + "operationID": "query-5", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": null, + "workspace": null, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "operationID": "query-5", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatchedGeneration1", + "watchChanged", + "dispatchedGeneration2", + "replyGeneration1" + ], + "expected": { + "publishReply": false, + "cacheReply": false, + "requery": "allProviders" + } + }, + { + "name": "remote query never falls back to local", + "request": { + "jsonrpc": "2.0", + "id": 6, + "method": "toolchains/query", + "params": { + "operationID": "query-6", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "remote", + "id": "ssh-1" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/srv/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.authorityUnsupported", + "rpcCode": -32071, + "localProcessesStarted": 0 + } + }, + { + "name": "missing definition retains display-only requirement", + "request": { + "jsonrpc": "2.0", + "id": 7, + "method": "toolchains/query", + "params": { + "operationID": "query-7", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "maven" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 7, + "result": { + "operationID": "query-7", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "maven", + "version": "3.9.9", + "home": "/opt/lithe-fixtures/installs/maven/3.9.9" + } + ], + "statements": [ + { + "tool": "maven", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "3.9.9", + "installation": "/opt/lithe-fixtures/installs/maven/3.9.9", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "enabledDefinitions": [ + "java" + ], + "expected": { + "validation": "acceptedWithFilteredAuthority", + "installations": [], + "reason": { + "code": "definitionUnavailable", + "tool": "maven", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "3.9.9" + }, + "enableActionSource": "verifiedInstalledRegistry" + } + }, + { + "name": "external spelling cannot hide workspace symlink", + "request": { + "jsonrpc": "2.0", + "id": 8, + "method": "toolchains/query", + "params": { + "operationID": "query-8", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 8, + "result": { + "operationID": "query-8", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/linked-jdk" + } + ], + "statements": [], + "watch": [] + } + }, + "filesystemEvidence": [ + { + "path": "/opt/lithe-fixtures/linked-jdk", + "canonical": "/opt/lithe-fixtures/workspace/vendor/jdk", + "kind": "directory" + } + ], + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.unsafePath", + "rpcCode": -32076 + } + }, + { + "name": "statement cannot reference an unlisted home", + "request": { + "jsonrpc": "2.0", + "id": 9, + "method": "toolchains/query", + "params": { + "operationID": "query-9", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 9, + "result": { + "operationID": "query-9", + "generation": 1, + "providerID": "mise", + "installations": [ + { + "tool": "java", + "version": "21.0.4", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + } + ], + "statements": [ + { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "21", + "installation": "/opt/lithe-fixtures/other-jdk", + "kind": "declaration", + "reader": "native" + } + ], + "watch": [] + } + }, + "expected": { + "validation": "rejected", + "code": "plugin.toolchain.invalidReply", + "rpcCode": -32074 + } + }, + { + "name": "query timeout remains different from cancellation", + "request": { + "jsonrpc": "2.0", + "id": 10, + "method": "toolchains/query", + "params": { + "operationID": "query-10", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "dispatched", + "advanceManualClockToDeadline", + "lateReply" + ], + "expected": { + "completionCount": 1, + "code": "plugin.toolchain.timeout", + "rpcCode": -32072, + "publishReply": false + } + }, + { + "name": "create missing watched file re-queries all providers", + "request": { + "jsonrpc": "2.0", + "id": 11, + "method": "toolchains/query", + "params": { + "operationID": "query-11", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "fingerprintMissingMiseToml", + "createMiseToml", + "watchEvent" + ], + "enabledProviders": [ + "host:system", + "dev.lithe.fixture.mise:mise", + "dev.lithe.fixture.sdkman:sdkman" + ], + "expected": { + "requery": "allProviders", + "incremental": false + } + }, + { + "name": "delete watched input re-queries all providers", + "request": { + "jsonrpc": "2.0", + "id": 12, + "method": "toolchains/query", + "params": { + "operationID": "query-12", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "events": [ + "fingerprintExistingToolVersions", + "deleteToolVersions", + "watchEvent" + ], + "expected": { + "requery": "allProviders", + "incremental": false + } + }, + { + "name": "provider disable invalidates pending reply", + "request": { + "jsonrpc": "2.0", + "id": 13, + "method": "toolchains/query", + "params": { + "operationID": "query-13", + "generation": 1, + "providerID": "mise", + "authority": { + "kind": "local" + }, + "context": { + "contextID": "context-1", + "generation": 1 + }, + "workspace": { + "rootID": "root-1", + "root": "/opt/lithe-fixtures/workspace", + "directory": "." + }, + "tools": [ + "java" + ] + } + }, + "response": { + "jsonrpc": "2.0", + "id": 13, + "result": { + "operationID": "query-13", + "generation": 1, + "providerID": "mise", + "installations": [], + "statements": [], + "watch": [] + } + }, + "events": [ + "dispatched", + "providerDisabled", + "lateReply" + ], + "expected": { + "publishReply": false, + "cacheReply": false, + "ownedListenersRemaining": 0 + } + } + ], + "errorResponses": [ + { + "jsonrpc": "2.0", + "id": 100, + "error": { + "code": -32601, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.methodUnavailable", + "operationID": "query-100", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 101, + "error": { + "code": -32602, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.invalidParams", + "operationID": "query-101", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 102, + "error": { + "code": -32800, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.cancelled", + "operationID": "query-102", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 103, + "error": { + "code": -32070, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.permissionDenied", + "operationID": "query-103", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 104, + "error": { + "code": -32071, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.authorityUnsupported", + "operationID": "query-104", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 105, + "error": { + "code": -32072, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.timeout", + "operationID": "query-105", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 106, + "error": { + "code": -32073, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.providerUnavailable", + "operationID": "query-106", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 107, + "error": { + "code": -32074, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.invalidReply", + "operationID": "query-107", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 108, + "error": { + "code": -32075, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.limitExceeded", + "operationID": "query-108", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 109, + "error": { + "code": -32076, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.unsafePath", + "operationID": "query-109", + "providerID": "mise" + } + } + }, + { + "jsonrpc": "2.0", + "id": 110, + "error": { + "code": -32077, + "message": "Fixture diagnostic.", + "data": { + "code": "plugin.toolchain.operationExpired", + "operationID": "query-110", + "providerID": "mise" + } + } + } + ] +} diff --git a/contract/v1/proposals/toolchains/fixtures/resolution-cases.json b/contract/v1/proposals/toolchains/fixtures/resolution-cases.json new file mode 100644 index 0000000..9dd4a35 --- /dev/null +++ b/contract/v1/proposals/toolchains/fixtures/resolution-cases.json @@ -0,0 +1,571 @@ +{ + "proposalVersion": 1, + "status": "review-only", + "cases": [ + { + "name": "nearest ancestor pin applies component-wise", + "input": { + "directory": "apps/web/src", + "localFixture": "local.json" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": "apps/web/src", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/manual/java/17.0.12", + "executable": "/opt/lithe-fixtures/manual/java/17.0.12/bin/java", + "version": "17.0.12", + "provider": "host:manual", + "layer": "pinned", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/manual/java/17.0.12" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/manual/java/17.0.12/bin" + ] + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "resolved", + "source": "apps/web" + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "similarly prefixed sibling does not inherit child pin", + "input": { + "directory": "apps/website", + "localFixture": "local.json" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": "apps/website", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "pinned", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "resolved", + "source": "." + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "nonempty unavailable pin stops fallback", + "input": { + "pinHome": "/opt/lithe-fixtures/missing", + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "terminalFailure", + "reason": { + "code": "notFound" + } + }, + "trace": [ + { + "candidateID": "pinned", + "layer": "pinned", + "state": "terminalFailure", + "reason": { + "code": "notFound" + } + } + ], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "uninstalled project version remains visible after fallback", + "input": { + "statement": { + "tool": "java", + "scope": "project", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99", + "installation": null, + "kind": "declaration", + "reader": "native" + }, + "loginShellHome": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "host:system", + "layer": "global", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "project-request", + "layer": "project", + "state": "skip", + "reason": { + "code": "requestedNotInstalled", + "tool": "java", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + } + }, + { + "candidateID": "shell-command", + "layer": "global", + "state": "resolved" + } + ], + "unmet": [ + { + "code": "requestedNotInstalled", + "tool": "java", + "provider": "dev.lithe.fixture.mise:mise", + "source": "/opt/lithe-fixtures/workspace/mise.toml", + "request": "99" + } + ], + "conflicts": [] + } + }, + { + "name": "conflicting globals skip providers and use login shell", + "input": { + "providerGlobals": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + { + "provider": "dev.lithe.fixture.sdkman:sdkman", + "home": "/opt/lithe-fixtures/sdkman/java/17.0.12" + } + ], + "loginShellHome": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "host:system", + "layer": "global", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [ + { + "candidateID": "global-request", + "layer": "global", + "state": "skip", + "reason": { + "code": "providerConflict" + } + }, + { + "candidateID": "shell-command", + "layer": "global", + "state": "resolved" + } + ], + "unmet": [], + "conflicts": [ + { + "scope": "global", + "providers": [ + "dev.lithe.fixture.mise:mise", + "dev.lithe.fixture.sdkman:sdkman" + ], + "action": "chooseProvider" + } + ] + } + }, + { + "name": "automatic compares numeric components not text", + "input": { + "probedVersions": [ + "9.0.9", + "21.0.4", + "17.0.12" + ], + "minimumVersion": "17" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "automatic", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "manual-only newer installation is not automatic", + "input": { + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ], + "manualInstallations": [ + { + "home": "/opt/lithe-fixtures/manual/java/99", + "version": "99" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "executable": "/opt/lithe-fixtures/installs/java/21.0.4/bin/java", + "version": "21.0.4", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "automatic", + "projectCode": false, + "environment": { + "JAVA_HOME": "/opt/lithe-fixtures/installs/java/21.0.4" + }, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/java/21.0.4/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "child declaration beats more distant wrapper", + "input": { + "directory": "apps/web", + "projectCandidates": [ + { + "directory": ".", + "kind": "wrapper", + "path": "mvnw" + }, + { + "directory": "apps/web", + "kind": "declaration", + "home": "/opt/lithe-fixtures/installs/maven/3.9.9" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "maven", + "directory": "apps/web", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/installs/maven/3.9.9", + "executable": "/opt/lithe-fixtures/installs/maven/3.9.9/bin/mvn", + "version": "3.9.9", + "provider": "dev.lithe.fixture.mise:mise", + "layer": "project", + "projectCode": false, + "environment": {}, + "pathPrepend": [ + "/opt/lithe-fixtures/installs/maven/3.9.9/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "same-directory wrapper precedes manager statement after user action", + "input": { + "directory": ".", + "userActionAuthorized": true, + "wrapper": "/opt/lithe-fixtures/workspace/mvnw", + "providerHome": "/opt/lithe-fixtures/installs/maven/3.9.9" + }, + "expected": { + "operationID": "resolve-1", + "tool": "maven", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": null, + "executable": "/opt/lithe-fixtures/workspace/mvnw", + "version": null, + "provider": "dev.lithe.fixture.maven:wrapper", + "layer": "project", + "projectCode": true, + "environment": {}, + "pathPrepend": [] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "native reader beats compatible reader for the same file", + "input": { + "source": "/opt/lithe-fixtures/workspace/.nvmrc", + "readers": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "reader": "compatible", + "home": "/opt/lithe-fixtures/installs/node/22.0.0" + }, + { + "provider": "dev.lithe.fixture.nvm:nvm", + "reader": "native", + "home": "/opt/lithe-fixtures/nvm/node/20.0.0" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "node", + "directory": ".", + "outcome": { + "kind": "resolved", + "selection": { + "home": "/opt/lithe-fixtures/nvm/node/20.0.0", + "executable": "/opt/lithe-fixtures/nvm/node/20.0.0/bin/node", + "version": "20.0.0", + "provider": "dev.lithe.fixture.nvm:nvm", + "layer": "project", + "projectCode": false, + "environment": {}, + "pathPrepend": [ + "/opt/lithe-fixtures/nvm/node/20.0.0/bin" + ] + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "disabled definition leaves pin but no executable authority", + "input": { + "localFixture": "local.json", + "enabledDefinitions": [] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "definitionUnavailable", + "tool": "java" + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "unknown probed version cannot satisfy automatic minimum", + "input": { + "installedHomes": [ + "/opt/lithe-fixtures/installs/java/21.0.4" + ], + "probeOutput": "unknown", + "minimumVersion": "17" + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "versionUnsatisfied" + } + }, + "trace": [], + "unmet": [], + "conflicts": [] + } + }, + { + "name": "equal newest versions at distinct homes require explicit choice", + "input": { + "installations": [ + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/installs/java/21.0.4", + "normalizedVersion": "21.0.4" + }, + { + "provider": "dev.lithe.fixture.mise:mise", + "home": "/opt/lithe-fixtures/other-installs/java/21.0.4", + "normalizedVersion": "21.0.4" + } + ] + }, + "expected": { + "operationID": "resolve-1", + "tool": "java", + "directory": ".", + "outcome": { + "kind": "skip", + "reason": { + "code": "ambiguousInstallation", + "tool": "java" + } + }, + "trace": [ + { + "candidateID": "installed", + "layer": "automatic", + "state": "skip", + "reason": { + "code": "ambiguousInstallation", + "tool": "java" + } + } + ], + "unmet": [], + "conflicts": [ + { + "scope": "automatic", + "providers": [ + "dev.lithe.fixture.mise:mise" + ], + "installations": [ + "/opt/lithe-fixtures/installs/java/21.0.4", + "/opt/lithe-fixtures/other-installs/java/21.0.4" + ], + "action": "chooseInstallation" + } + ] + } + } + ], + "invalidLocalDocuments": [ + { + "name": "parent segment", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + "apps/../other": {} + } + } + }, + { + "name": "absolute directory key", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + "/apps": {} + } + } + }, + { + "name": "relative home", + "value": { + "version": 1, + "toolchains": {}, + "pins": { + ".": { + "java": { + "home": "vendor/jdk", + "version": "21", + "provider": "host:manual" + } + } + } + } + } + ] +} diff --git a/contract/v1/proposals/toolchains/local.schema.json b/contract/v1/proposals/toolchains/local.schema.json new file mode 100644 index 0000000..0727b88 --- /dev/null +++ b/contract/v1/proposals/toolchains/local.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/local.schema.json", + "title": "Proposed local toolchain pins extension (not implemented)", + "description": "Review-only complete document shape for .lithe/toolchains/local.json. The active toolchain-local-v1.schema.json remains unchanged and does not accept pins.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "toolchains" + ], + "properties": { + "version": { + "const": 1 + }, + "toolchains": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "minLength": 1 + }, + "executable": { + "type": "string", + "minLength": 1 + }, + "javaHome": { + "type": "string", + "minLength": 1 + } + } + }, + "description": "Existing v1 entries are preserved byte-semantically; new resolution does not manufacture aliases to legacy project-jdk/project-maven IDs." + }, + "pins": { + "type": "object", + "maxProperties": 1024, + "propertyNames": { + "$ref": "#/$defs/directory" + }, + "additionalProperties": { + "type": "object", + "maxProperties": 64, + "propertyNames": { + "$ref": "#/$defs/toolID" + }, + "additionalProperties": { + "$ref": "#/$defs/pin" + } + } + } + }, + "$defs": { + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "nativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "pin": { + "type": "object", + "additionalProperties": false, + "required": [ + "home", + "version", + "provider" + ], + "properties": { + "home": { + "$ref": "#/$defs/nativePath" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "^[a-z][a-z0-9.-]*:[a-z][a-z0-9-]*$", + "description": "Qualified pluginID:providerID, or reserved host:manual / host:system; not an activation instruction." + } + } + } + } +} diff --git a/contract/v1/proposals/toolchains/manifest-contributions.schema.json b/contract/v1/proposals/toolchains/manifest-contributions.schema.json new file mode 100644 index 0000000..20c8906 --- /dev/null +++ b/contract/v1/proposals/toolchains/manifest-contributions.schema.json @@ -0,0 +1,260 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/manifest-contributions.schema.json", + "title": "PROPOSAL: toolchain contribution projections, not a plugin manifest", + "description": "A standalone review envelope for proposed manifest fields. Passing this schema does not make a plugin installable and does not prove signature, registry, filesystem or executable safety. See manifest.md.", + "type": "object", + "additionalProperties": false, + "required": ["proposalVersion", "pluginID", "requires", "permissions"], + "properties": { + "proposalVersion": { "const": 1 }, + "pluginID": { "type": "string", "pattern": "^[a-z0-9]+(?:[.-][a-z0-9]+)*$", "maxLength": 128 }, + "requires": { + "type": "object", "additionalProperties": false, "minProperties": 1, + "properties": { + "toolDefinitions": { "const": 1 }, + "toolchainProviders": { "const": 1 }, + "toolchainConsumers": { "const": 1 }, + "executableResolution": { "const": 2 }, + "backend": { "const": 2 }, + "serverProcess": { "const": 1 } + } + }, + "permissions": { + "type": "array", "maxItems": 16, "uniqueItems": true, + "items": { "enum": ["toolchains.provide", "backend.launch"] } + }, + "toolDefinitions": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/toolDefinition" } }, + "toolchainProviders": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/provider" } }, + "backend": { "$ref": "#/$defs/backend" }, + "consumers": { + "description": "Review projection of fields on existing language/build/run/debug contributions; NOT a proposed top-level manifest field.", + "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/$defs/consumer" } + } + }, + "anyOf": [ { "required": ["toolDefinitions"] }, { "required": ["toolchainProviders"] }, { "required": ["consumers"] } ], + "allOf": [ + { + "if": { "required": ["toolDefinitions"] }, + "then": { "properties": { "requires": { "required": ["toolDefinitions", "executableResolution"] } } } + }, + { + "if": { "required": ["toolchainProviders"] }, + "then": { + "required": ["backend"], + "properties": { + "requires": { "required": ["toolchainProviders", "backend", "serverProcess"] }, + "permissions": { "allOf": [ { "contains": { "const": "toolchains.provide" } }, { "contains": { "const": "backend.launch" } } ] } + } + } + }, + { + "if": { "required": ["consumers"] }, + "then": { "properties": { "requires": { "required": ["toolchainConsumers"] } } } + } + ], + "$defs": { + "identifier": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,63}$" }, + "toolID": { "type": "string", "pattern": "^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*:)?[a-z][a-z0-9-]{0,63}$", "maxLength": 192 }, + "label": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^\\u0000-\\u001f\\u007f]+$" }, + "relativePath": { + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^(?!/)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:*?\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:*?\\u0000-\\u001f\\u007f]+)*$" + }, + "boundedGlob": { + "description": "At most eight segments; only single-segment * is supported, never **, ?, character classes, or traversal. Semantic validation also bounds expansion.", + "type": "string", "minLength": 1, "maxLength": 256, + "pattern": "^(?!/)(?!.*\\*\\*)(?!.*(?:^|/)(?:\\.|\\.\\.)(?:/|$))[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+(?:/[^/\\\\:?\\[\\]{}\\u0000-\\u001f\\u007f]+){0,7}$" + }, + "command": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" }, + "safeEnvironmentName": { "type": "string", "maxLength": 64, "pattern": "^(?!PATH$)(?!DYLD_)(?!LD_)[A-Z_][A-Z0-9_]*$" }, + "platforms": { "type": "array", "minItems": 1, "maxItems": 3, "uniqueItems": true, "items": { "enum": ["macos", "windows", "linux"] } }, + "version": { + "type": "object", "additionalProperties": false, "required": ["arguments", "pattern"], + "properties": { + "arguments": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "type": "string", "pattern": "^--?[A-Za-z][A-Za-z0-9-]*$", "maxLength": 64 } }, + "pattern": { "description": "Rust-regex-compatible, first capture is a dotted numeric version. Validity, capture count and runtime bounds require semantic checks.", "type": "string", "minLength": 1, "maxLength": 512 } + } + }, + "executableLayout": { + "type": "object", "additionalProperties": false, "required": ["path"], + "properties": { "path": { "$ref": "#/$defs/relativePath" }, "platforms": { "$ref": "#/$defs/platforms" } } + }, + "homeLayout": { + "type": "object", "additionalProperties": false, "required": ["match", "home"], + "properties": { + "match": { "allOf": [ { "$ref": "#/$defs/boundedGlob" }, { "pattern": "^[^/]+$" } ] }, + "home": { "oneOf": [ { "const": "." }, { "$ref": "#/$defs/relativePath" } ] }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "discovery": { + "type": "object", "additionalProperties": false, "required": ["root", "pattern"], + "properties": { + "root": { + "oneOf": [ + { "type": "object", "additionalProperties": false, "required": ["kind"], "properties": { "kind": { "const": "home" } } }, + { "type": "object", "additionalProperties": false, "required": ["kind", "variable"], "properties": { "kind": { "const": "environment" }, "variable": { "$ref": "#/$defs/safeEnvironmentName" } } }, + { "type": "object", "additionalProperties": false, "required": ["kind", "path"], "properties": { "kind": { "const": "absolute" }, "path": { "type": "string", "minLength": 2, "maxLength": 256, "pattern": "^(?:/|[A-Za-z]:[\\\\/])[^\\u0000-\\u001f\\u007f]*$" } } } + ] + }, + "pattern": { "$ref": "#/$defs/boundedGlob" }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "wrapper": { + "type": "object", "additionalProperties": false, "required": ["path"], + "properties": { + "path": { "$ref": "#/$defs/relativePath" }, + "requires": { "type": "array", "maxItems": 8, "uniqueItems": true, "items": { "$ref": "#/$defs/relativePath" } }, + "platforms": { "$ref": "#/$defs/platforms" } + } + }, + "candidateMetadata": { + "type": "object", + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "priority": { "type": "integer", "minimum": -2147483648, "maximum": 2147483647 }, + "insertBefore": { "$ref": "#/$defs/identifier" }, + "insertAfter": { "$ref": "#/$defs/identifier" }, + "platforms": { "$ref": "#/$defs/platforms" } + }, + "not": { "anyOf": [ { "required": ["priority", "insertBefore"] }, { "required": ["priority", "insertAfter"] }, { "required": ["insertBefore", "insertAfter"] } ] } + }, + "candidate": { + "$ref": "#/$defs/candidateMetadata", "unevaluatedProperties": false, + "oneOf": [ + { + "required": ["kind", "settingID", "layer"], + "properties": { "kind": { "const": "setting" }, "settingID": { "const": "executable" }, "layer": { "enum": ["project", "global"] } } + }, + { + "required": ["kind", "scope"], + "properties": { "kind": { "const": "toolchain" }, "scope": { "enum": ["project", "global", "installed"] } } + }, + { + "required": ["kind", "path"], + "properties": { + "kind": { "const": "projectFile" }, + "path": { "$ref": "#/$defs/relativePath" }, + "requires": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/relativePath" } }, + "projectKind": { "enum": ["wrapper", "environment"], "default": "wrapper" }, + "home": { "oneOf": [ { "const": "." }, { "$ref": "#/$defs/relativePath" } ] } + }, + "allOf": [ + { + "if": { "required": ["projectKind"], "properties": { "projectKind": { "const": "environment" } } }, + "then": { "required": ["home"] }, + "else": { "not": { "required": ["home"] } } + } + ] + }, + { + "required": ["kind", "variable", "path"], + "properties": { "kind": { "const": "environment" }, "variable": { "$ref": "#/$defs/safeEnvironmentName" }, "path": { "$ref": "#/$defs/relativePath" } } + }, + { + "required": ["kind", "command"], + "properties": { "kind": { "const": "command" }, "command": { "$ref": "#/$defs/command" } } + }, + { + "required": ["kind", "runtime"], + "properties": { "kind": { "const": "runtime" }, "runtime": { "$ref": "#/$defs/toolID" } } + } + ] + }, + "launchTokens": { + "type": "array", "minItems": 1, "maxItems": 32, + "prefixItems": [ { "const": "${executable}" } ], + "items": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^(?:\\$\\{(?:home|executable|entry|runtimeArguments|arguments)\\}|[^$\\u0000-\\u001f\\u007f]+)$" } + }, + "toolDefinition": { + "type": "object", "additionalProperties": false, + "required": ["id", "displayName", "executableLayouts", "executables", "version", "commands", "launch"], + "properties": { + "id": { "$ref": "#/$defs/toolID" }, + "displayName": { "$ref": "#/$defs/label" }, + "icon": { "$ref": "../../plugin-manifest.schema.json#/$defs/uiIcon" }, + "executableLayouts": { "type": "array", "minItems": 1, "maxItems": 8, "items": { "$ref": "#/$defs/executableLayout" } }, + "executables": { "type": "array", "minItems": 1, "maxItems": 32, "items": { "$ref": "#/$defs/candidate" } }, + "version": { "$ref": "#/$defs/version" }, + "homeLayouts": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/homeLayout" } }, + "discovery": { "type": "array", "maxItems": 16, "items": { "$ref": "#/$defs/discovery" } }, + "homeVariable": { "$ref": "#/$defs/safeEnvironmentName" }, + "commands": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "$ref": "#/$defs/command" } }, + "wrappers": { "type": "array", "maxItems": 8, "items": { "$ref": "#/$defs/wrapper" } }, + "exports": { + "type": "array", "maxItems": 16, + "items": { + "type": "object", "additionalProperties": false, "required": ["name", "value"], + "properties": { "name": { "$ref": "#/$defs/safeEnvironmentName" }, "value": { "enum": ["${home}", "${executable}"] } } + } + }, + "pathPrepend": { + "type": "array", "maxItems": 8, "uniqueItems": true, + "items": { "type": "string", "maxLength": 256, "pattern": "^\\$\\{home\\}(?:/(?!\\.{1,2}(?:/|$))[^/\\\\:$*?\\u0000-\\u001f\\u007f]+)*$" } + }, + "launch": { "$ref": "#/$defs/launchTokens" } + } + }, + "provider": { + "type": "object", "additionalProperties": false, "required": ["id", "displayName", "tools"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "displayName": { "$ref": "#/$defs/label" }, + "tools": { "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, "items": { "$ref": "#/$defs/toolID" } }, + "watchFiles": { + "type": "array", "minItems": 1, "maxItems": 32, "uniqueItems": true, + "items": { + "type": "object", "additionalProperties": false, "required": ["pattern", "reader"], + "properties": { "pattern": { "$ref": "#/$defs/boundedGlob" }, "reader": { "enum": ["native", "compatible"] } } + } + } + } + }, + "backend": { + "type": "object", "additionalProperties": false, "required": ["launch"], + "properties": { + "launch": { + "oneOf": [ + { + "type": "object", "additionalProperties": false, "required": ["kind", "path"], + "properties": { "kind": { "const": "packaged" }, "path": { "$ref": "#/$defs/relativePath" } } + }, + { + "type": "object", "additionalProperties": false, "required": ["kind", "tool", "packagedRuntime", "entry"], + "properties": { + "kind": { "const": "runtime" }, "tool": { "$ref": "#/$defs/toolID" }, + "packagedRuntime": { "$ref": "#/$defs/relativePath" }, "entry": { "$ref": "#/$defs/relativePath" }, + "runtimeArguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256, "pattern": "^[^$\\u0000-\\u001f\\u007f]*$" } } + } + } + ] + }, + "arguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256, "pattern": "^[^$\\u0000-\\u001f\\u007f]*$" } } + } + }, + "consumer": { + "type": "object", "additionalProperties": false, "required": ["id", "requiresTools", "onToolchainChange"], + "properties": { + "id": { "$ref": "#/$defs/identifier" }, + "requiresTools": { + "type": "array", "minItems": 1, "maxItems": 16, + "items": { "type": "object", "additionalProperties": false, "required": ["tool"], "properties": { "tool": { "$ref": "#/$defs/toolID" }, "minimumVersion": { "type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+){0,7}$", "maxLength": 64 } } } + }, + "onToolchainChange": { "enum": ["restart", "resync", "nextRun"] }, + "launch": { + "type": "object", "additionalProperties": false, "required": ["kind", "tool", "entry"], + "properties": { + "kind": { "const": "runtime" }, "tool": { "$ref": "#/$defs/toolID" }, "entry": { "$ref": "#/$defs/relativePath" }, + "runtimeArguments": { "type": "array", "maxItems": 16, "items": { "type": "string", "maxLength": 256 } } + } + }, + "runtimeDependencies": { + "type": "array", "maxItems": 16, + "items": { "type": "object", "additionalProperties": false, "required": ["tool"], "properties": { "tool": { "$ref": "#/$defs/toolID" } } } + } + } + } + } +} diff --git a/contract/v1/proposals/toolchains/manifest.md b/contract/v1/proposals/toolchains/manifest.md new file mode 100644 index 0000000..fae3ab1 --- /dev/null +++ b/contract/v1/proposals/toolchains/manifest.md @@ -0,0 +1,318 @@ +# Toolchain manifest contributions (proposal) + +**Status: full proposal, not a claim of native host support.** The implemented +Core subset is specified by [the active contract](../../toolchains.md). This +document follows the +[final scope of PR #1581](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). +It is a host contract draft, not a plugin author tutorial. The current manifest +schema now admits only that subset; generic consumer/runtime migration remains +unsupported. Do not install these projection examples as `plugin.json`. + +[`manifest-contributions.schema.json`](manifest-contributions.schema.json) is a +standalone **review projection**, not a replacement manifest schema and not an +`allOf` extension of the current closed schema. Its `proposalVersion`, `pluginID` +and `consumers` envelope fields organize fixtures; they are not new production +manifest fields. `toolDefinitions`, `toolchainProviders`, and the consumer fields +below would be integrated into the existing manifest only when Core and the +corresponding host adapters implement and advertise the capabilities. The +production `schemaVersion: 2` and existing API levels are not changed by this +proposal. A future implementation must not silently accept these fields under an +older capability. + +## Three contributors, one tool registry + +| Contributor | Owns | Does not own | +| --- | --- | --- | +| Tool definition | Identity, executable layout, version probe, bounded discovery, candidate order, exported environment, argv launch template | Version-manager aliases, private configuration formats, provider priority rules | +| Toolchain provider | Installed tools and project/global requirements, computed by its own backend or upstream manager | Tool definitions, arbitrary command replacement, host UI or resolver code | +| Consumer | Explicit tool references and minimum versions, directory, response to a changed selection | Discovery paths, alternate secret selection chains | + +There are **no host-owned tool definitions, built-in definition data files, +short-name aliases, or tool-name switches**. Java support defines `java`; a Maven +plugin defines `maven`. Node, Python and other official plugins migrate only when +they supply their own definitions. This draft does not remove existing behavior +or authorize new hardcoded fallbacks during that staged migration. + +The enabled registry is populated from installed, enabled plugins. A namespaced +definition ID is `:`; a plugin cannot define another +plugin's namespace. Short IDs such as `java` are reserved for packages whose +official identity the **host independently verified during installation**. A +manifest's vendor or signature-policy text is not evidence of that identity. +Community providers may answer short IDs; defining and answering are different +authorities. The first registered owner of an ID remains the owner; a later +duplicate is rejected, never used as an override. Stable enumeration order makes +diagnostics reproducible, not a priority policy for conflicting runtime facts. + +## Proposed capability admission + +The names and versions here are review assignments, not host advertisements: + +| Capability | Required exactly when | +| --- | --- | +| `toolDefinitions: 1` | The plugin contributes definitions | +| `toolchainProviders: 1` | The plugin contributes providers | +| `executableResolution: 2` | A definition uses the new shared candidate projection, including `toolchain` or `setting.layer` | +| `toolchainConsumers: 1` | A consumer uses generic tool references and change policy | +| Existing `backend: 2`, `serverProcess: 1` | A provider uses the plugin's backend | + +Each actual contribution still declares its existing language/build/run/debug +capabilities and permissions. The review envelope lists only capabilities relevant +to this proposal. Missing **or unused** capabilities are rejected, just as the +current manifest contract requires. There is no broad new API-level increment to +pretend that an unimplemented launch adapter supports the draft. + +## Tool definitions + +The [Java-support-style projection](fixtures/java-tool-definition.json) supplies +the tool name and every executable policy. Its short ID is valid only in an +independently official installation context; the fixture itself is not signed. + +| Field | Draft meaning and limits | +| --- | --- | +| `id`, `displayName`, `icon` | Tool ID, one-line label of at most 64 characters, and existing shared `uiIcon` format; normal icon package checks still apply | +| `executableLayouts` | 1-8 exact tool-home-relative executable paths, optionally filtered by platform, in fallback order; native executable checks apply | +| `executables` | 1-32 **resolver candidates**, not executable filenames; see below | +| `version` | 1-8 flag-shaped literal probe arguments and a Rust-regex-compatible expression of at most 512 bytes; first capture yields the normalized version | +| `homeLayouts` | At most 8 ordered, single-component name matches mapping a selected installation directory to `.` or a relative home suffix; for example `*.jdk` maps to `Contents/Home` | +| `discovery` | At most 16 bounded scans with root `home`, named `environment`, or an absolute system directory; the host executes data, not a tool-specific scanner. This primitive set is frozen; shapes it cannot express use a provider | +| `homeVariable`, `commands` | Metadata identifying the login-shell input and 1-8 bare commands belonging to this tool | +| `wrappers` | At most 8 exact project-relative wrapper paths, required marker files, and platform filters | +| `exports` | At most 16 unique environment names, each assigned exactly `${home}` or `${executable}` | +| `pathPrepend` | At most 8 paths under `${home}`; no traversal, shell interpolation, or replacement of PATH | +| `launch` | At most 32 argv tokens; the first is exactly `${executable}` | + +`homeLayouts` applies only when normalizing a manual selection or a system +discovery match. A provider's `installations[].home` is already the final tool +home; applying a layout again could append the same suffix twice. + +`executableLayouts` and `executables` deliberately have different roles. The first +locates the executable **within an already chosen installation**. The second +selects which source may supply that installation or executable. A candidate does +not gain permission to launch a same-named command for another tool. + +`homeVariable`, `commands`, and `wrappers` are checked metadata for the same +candidate plan, **not alternate discovery or launch paths**: every `environment` +candidate must use the declared home variable and one declared executable layout; +every `command` must belong to `commands`; every wrapper `projectFile` must match +one wrapper, including its `requires` markers and platform applicability. A +`projectFile` with `projectKind: "environment"` instead declares its own exact +`path`, `requires` markers and mandatory project-relative `home`; its path must +equal that home plus an applicable executable layout. It is not a wrapper or +an external provider installation. Conversely, +declared wrappers and shell inputs must have their corresponding candidate(s). +Any mismatch rejects the definition, so the UI and resolver cannot drift. Only +the candidate plan is executed. An implementation may normalize these references +to one internal representation; it must not maintain two independent pipelines. + +### Version probing and argv safety + +The first version capture must match `[0-9]+(?:\.[0-9]+){0,7}`, at most 64 bytes. +Core compares unsigned numeric components, padding absent trailing components +with zeros. The Java fixture's optional `1.` prefix is declared by that plugin: +`1.8.0_422` produces `8.0`, while `21.0.4` produces `21.0.4`. The host has no Java +8 branch. Provider display text is provenance; auto-selection uses the validated +probe capture, not a parsed alias such as `temurin-21`. + +The host caps probe stdout and stderr at 64 KiB each, applies a 5-second monotonic +deadline, and cancels the child tree on timeout or cancellation. A failed probe +does not provide a sortable version. It cannot satisfy a consumer's minimum +version by assumption. Equal normalized versions with distinct homes are shown +as an explicit ambiguity requiring a choice; no plugin ID or directory name wins. + +Arguments match `-x`/`--long-flag` shape only; no positional commands, `=value`, +scripts, or environment templates. This is a syntax restriction, **not proof that +an executable is side-effect-free**. Installation review must list each executable, +probe arguments, scan roots, export names, and provider backend the package can +run. A future tool whose upstream version command cannot satisfy this restriction +needs a reviewed generic extension; do not add a tool-name exception. + +Launch is an argv vector, never a shell command. `${home}`, `${executable}` and +`${entry}` are whole scalar tokens; `${runtimeArguments}` and `${arguments}` are +whole-token list expansions, at most once each. No inline interpolation, unknown +variable, shell evaluation, shell launcher, or empty executable is allowed. +`${entry}` is independently validated inside the consumer package. Empty argument +lists expand to zero tokens. Arguments and paths with spaces remain single argv +elements. On Windows the existing platform argv-quoting adapter owns native +serialization; the plugin cannot supply shell quoting. + +Exports reject `PATH`, `DYLD_*`, and `LD_*` names. Existing host process-environment +filtering still applies; the permission does not authorize injecting credentials, +loader variables or arbitrary environment expressions. `pathPrepend` is checked +after canonicalization: symlinks may not escape the selected home. Exports from a +rejected candidate never leak into the next candidate's environment. + +## One candidate resolver, four product layers + +The draft extends the +[existing candidate model](../../README.md#shared-executable-candidates-executableresolution-1) +rather than building a second runtime resolver: + +- `setting` gains mandatory `layer: "project" | "global"` in this projection. + `settingID: "executable"` names a generic framework slot, not a plugin private + path-setting ID. Project values come from nearest-ancestor `pins`; global values + are Lithe-owned application preferences. An invalid nonempty fixed/default + value is `terminalFailure`, never an automatic fallback. +- `toolchain` gains `scope: "project" | "global" | "installed"`. The tool is the + containing definition; a candidate cannot inject facts for a different ID. +- Existing `projectFile`, `environment`, `command`, and `runtime` source kinds + retain their source meanings; `runtime` references an explicit tool ID and + dependency cycles are rejected. A `projectFile` only runs through the existing + consumer trust/explicit-user-action boundary, never passive discovery/probing. +- Under the new capability only, `projectFile.projectKind` distinguishes + `wrapper` (default, executable-only) from `environment` (explicit `home`). A + wrapper produces `home: null`; it never invents a tool home from the workspace + root. The environment's home and program are relative to the candidate's + source-directory anchor, which also determines project specificity. The + existing `environment` kind still means an external login-shell variable, + not a project environment. Old executable-resolution v1 is unchanged. +- IDs, signed priority, insertion anchors, deterministic topological ordering, + platform filtering, and validation of the whole set remain the current model. + Anchors cannot cross contributions or plugins. Consumer adjustments apply only + to their own candidate set, not to another tool's definition or global registry. + +The normalized candidate list must preserve these four barriers: + +1. Fixed: nearest ancestor pin in the chosen directory, invalid means terminal. +2. Project: nearest applicable directory, then wrapper > environment > declaration; + for the same source file native reader > compatible reader. Equal claims are + an explicit unresolved conflict, not an ID-based tie-break. Uninstalled requests + return `skip` **with structured reason**, and remain visible as unsatisfied. +3. Global: Lithe default > unambiguous provider global > login shell. Conflicting + provider globals require a user choice; until then, skip them with a conflict + reason and use login-shell candidates. No hidden provider-ID precedence. +4. Automatic: eligible installations from both routes, provider answers and the + generic system scanner driven only by `discovery`; one canonical home counts + once. Select the highest valid normalized version satisfying the consumer + minimum, excluding manually added pin-only homes. + +Array order is the default source order inside these barriers. Ordering metadata +must not move candidates across a barrier or reverse project specificity/reader +precedence; such a definition is rejected. Tool definitions may omit unavailable +sources, but cannot silently redefine the four-layer policy. Absence is `skip`, +not a fabricated value. See the adjacent protocol/selection proposal for exact +query results and reason shapes; no current `skip` consumer changes in this stage. + +## Providers, file watching, and bootstrap + +The [mise projection](fixtures/mise-provider.json) declares `id`, `displayName`, +`tools`, and `watchFiles`. `watchFiles` is optional: without it a provider reports +installations only and any returned statement is rejected. That shape lets the +plugin defining a tool answer for its own installations when declarative +`discovery` cannot express them. Provider IDs are local to the plugin/session; registry +identity is the pair `(verified pluginID, provider id)`. There are at most eight +providers, each naming 1-32 tool IDs. The query set is the intersection of requested +and declared IDs; this may include dormant IDs solely for requirement diagnostics. +Only its subset with enabled definitions can supply executable candidates. +Dormant unknown IDs do not inject commands; the host may preserve the requirements +as diagnostics so it can offer enabling the installed definition plugin. A +consumer with a missing required definition cannot launch. + +`toolchains.provide` means the backend may supply candidates which influence the +program Lithe launches, **not merely read directories**. Its installation review +must say so explicitly. It does not authorize installation/download, network, +trusting workspace code, reading secrets, writing native configuration, or +replacing arbitrary commands. Normal backend permission and sandbox restrictions +continue to apply. + +`watchFiles` entries are `{pattern, reader: "native" | "compatible"}`. The host +validates each project statement's source and reader against the declaration; +returned facts cannot promote a compatible reader to native. Native reader +ownership conflicts are explicit and not resolved by plugin registration order. +Patterns are relative, at most eight path components and 256 bytes. Only `*` +inside a single component is supported; no `**`, `?`, classes, braces, traversal, +absolute paths or following a symlink outside the watched root. The host applies +each pattern directly at the selected directory and at most 64 ancestor +directories, including enclosing configuration above the opened workspace; no +recursive `**` scan is implied. Providers also return potential external/global +inputs, including missing paths, in `watch`. Watching an enclosing source does +not expand the workspace identity or editable pin scope. Expansion across the +whole query round is bounded to 256 watch roots and 4,096 enumerated entries, +not just successful matches, as well as the existing workspace scan budget. +Limit hits are reported, never treated as a complete snapshot. The host observes +the parent of missing matches so creation and deletion also invalidate the snapshot. + +Any declared watched file or returned absolute input change cancels the generation +and re-queries **all enabled providers**; no mixed-generation incremental result +is published. Remote workspaces query their own authority or are explicitly +unsupported, never the local machine's managers. Providers own private version-file +formats, `lts`, `latest`, `system`, configuration hierarchy and manager semantics. +The host neither parses those files nor independently guesses their precedence. + +The provider uses the plugin's existing `backend`; no per-tool process framework +and no LSP language feature is required. It must negotiate the typed toolchain +capability over the existing base JSON-RPC transport before receiving queries. +`toolchainProviders: 1` proposes an additional **host-query startup trigger** for +that backend. Existing `backend: 2` alone does not start the fixture today: its +current view/command/`onWorkspaceOpen` admission rules remain unchanged until this +new capability is implemented. The future host starts at most one backend for +the plugin when it needs a query, not one process per tool or directory; after +startup it negotiates support before dispatch. A query is not an implicit +`onWorkspaceOpen` activation declaration. +The backend is either `launch.kind: "packaged"` (a signed/package-checked native +executable), or `launch.kind: "runtime"` with mandatory `packagedRuntime`. The +latter starts exactly that packaged executable and uses the referenced active +tool definition's argv launch template; it never calls the resolver for its own +runtime. This also holds when a plugin provides installations of a tool it +defines: the backend never runs on the tool it is discovering. The launch definition must be registered independently of provider +queries and must be available before backend start. Missing definitions or +dependency cycles fail admission/start explicitly. A packaged runtime is not a +host fallback if a definition is disabled. Native packaged executables are the +recommended bootstrap, and are what the mise fixture demonstrates. + +Provider backends stay within the current restart-required activation policy. +This proposal does not relax live activation. Disabling/uninstalling cancels +queries, invalidates generations, stops the owned backend, and removes active +definitions, dependent consumer rows, exports, and plugin settings together. +Local pins remain stored for re-enable; dormant pins are not allowed to re-create +an executable definition or export. + +## Consumer field projections + +`consumers[]` exists only in this review envelope. Its fields project additions +onto existing language-server, build-provider, execution-provider, debug-adapter +or run-configuration contributions: + +- `launch.kind: "runtime"` accepts any registered `tool` ID; no `java | node` + enum or inferred filename mapping remains on the migrated path. +- `runtimeDependencies[].tool` likewise accepts any registered ID, with existing + uniqueness/no-duplicate-launch-runtime checks retained. +- `requiresTools: [{tool, minimumVersion?}]` explicitly lists every tool a + consumer uses. `launch.tool` and dependencies must be included, each once. +- `onToolchainChange` is `restart`, `resync`, or `nextRun`; an absent corresponding + lifecycle adapter makes that contribution unsupported, not silently ignored. + +Definitions must be known for a required consumer to launch. A syntactically valid +ID alone grants no authority. If its installed defining plugin is disabled, show +the requirement, known owner, and plugin-page enable action; do not launch a stale +pin. Optional provider facts for missing definitions remain diagnostic data only. + +The environment/source page skeleton renders only enabled registry data, scoped +to each directory/module. Tool/plugin-specific settings are contributed on that +plugin's settings page. Manually added homes live in platform application storage, +can only be pinned, never enter auto-selection, and are validated through the +same declared layouts/probe before a confirmed pin is written. + +## Validation boundaries and fixtures + +The standalone schema proves shape and bounded syntax only. Registry semantics +also check capability exactness, ownership, references, candidate graph/layers, +regular-expression validity, version captures, metadata consistency and template +usage. The native host separately proves verified signature provenance, package +containment, canonical paths, executable status, workspace exclusion, cancellation +and limits. None of those checks can be replaced by JSON Schema. + +[`manifest-rejections.json`](fixtures/manifest-rejections.json) separates these +phases. Each case applies RFC 6902 `patch` to its `base`; `context` is **host test +input**, never a plugin field. `phase: "schema"` must fail schema validation; +`registry` and `hostTrust` cases intentionally remain valid JSON shape and describe +the later semantic rejection. Codes reuse existing `plugin.manifest.invalid`, +`plugin.permission.undeclared`, and `plugin.template.invalid` categories; `reason` +is structured diagnostic detail, not a second numeric JSON-RPC error mapping. +These are proposed acceptance/rejection vectors, not tests of current Core. + +The implementation stage must add executable Core and host tests before declaring +support, then run both SDK end-to-end suites for actual backend protocol changes. +After merging contract changes, synchronize the byte-for-byte mirror using +`node scripts/contract.mjs sync --revision ` in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs); do not hand-edit +the mirror or place SDK parsing helpers in the Lithe host. diff --git a/contract/v1/proposals/toolchains/protocol.md b/contract/v1/proposals/toolchains/protocol.md new file mode 100644 index 0000000..dd80653 --- /dev/null +++ b/contract/v1/proposals/toolchains/protocol.md @@ -0,0 +1,371 @@ +# Proposed toolchain query, resolution and local pins + +**Status: broader proposal; the macOS settings pages query providers read-only, and SDK support is not implemented.** +The Core-only validation/resolution subset is specified by +[the active contract](../../toolchains.md). These files remain review vectors, +not an advertised native capability or an SDK guide. The existing local-toolchain +writer/schema is unchanged. The design follows the +[latest #1581 review](https://github.com/1lck/Lithe-IDEA/pull/1581#issuecomment-6541366112). + +## Ownership and transport + +A tool definition says how to recognize, probe and use a tool. A provider +answers which installations and requirements exist. A consumer explicitly +references a tool ID. The host supplies query orchestration and one resolver; +it never parses a manager's version files, aliases or requested-version syntax. +There are no built-in tool definitions, short-name aliases or tool-name +branches. The generic system scanner is a host provider whose entire scan plan +comes from enabled definitions' `discovery` contributions. Plugin providers +cover installation shapes those frozen scan primitives cannot express; both +routes feed one automatic candidate set, and one canonical home counts once. + +The logical **and wire** method is `toolchains/query`. This independent typed +extension reuses the existing LSP base JSON-RPC transport and ServerProcess +lifecycle, not the language-intelligence domain. A toolchain backend need not +declare a language, language server, Build provider or private callable +function. Initialization offers `experimental.litheToolchains: {versions: [1], +providers: ["mise"]}`; the backend selects `{version: 1, providers: ["mise"]}`. +The host intersects the returned local provider IDs with the verified manifest. +An unoffered version, duplicate/undeclared provider ID, or malformed answer fails +negotiation rather than enabling a partial registry; no query is sent before +negotiation succeeds. +This draft does not upgrade or relax any existing `litheFunctions` version. + +An installed, enabled provider requires `toolchains.provide` and explicit +installation review of the programs it may invoke. This permission can affect +which external program the host launches; it is not described as a harmless +filesystem read. Plugin identity, signature/trust, package digest and enabled +state come from the host registry, never from a reply. The provider's stable +identity is `pluginID:providerID`; the unqualified `providerID` on the wire is +bound to that already authenticated session. + +## Query and reply + +[`query.schema.json`](query.schema.json) defines JSON-RPC envelopes. A request +contains `operationID`, `generation`, `providerID`, `authority`, `context`, +`workspace`, and `tools`. The host round generation also applies to global-only +queries, which have no workspace context. Tools are unique and sorted by ID. `workspace: null` asks only about +global state. Otherwise `workspace.root` is the absolute native root in the +query's authority, `rootID` is the host's opaque identity, and `directory` is +the directory being resolved relative to that root (`.` for the root). A +non-null workspace requires the existing `{contextID, generation}` host context +reference. These native paths are transient machine-local bindings, not portable +project identities. The opened workspace, not its containing repository, sets +the default scope. + +The host queries the intersection of requested tool IDs and the provider's +declared `tools`. IDs with no enabled definition may still be queried for +display-only requirement diagnostics. They cannot create installations, exports, +launches or tool rows. IDs outside that intersection are discarded entirely. +A failed or empty provider reply is not proof that there is no requirement. + +A successful result echoes `operationID`, `generation` and `providerID`, then returns: + +| Field | Meaning | +| --- | --- | +| `installations` | `{tool, version, home}` for external installations; `home` is absolute, `version` is provider display data, not a manager selector interpreted by Core. | +| `statements` | `{tool, scope, source, request, installation, kind, reader}` requirements; `request` is opaque, `installation` is a listed home or `null` for unmet. | +| `watch` | Absolute native input paths to fingerprint, including absent inputs and installation roots. This does not authorize reading contents, executing or writing these paths. | + +`scope` is `project` or `global`; `source` is the absolute originating file. +`kind` is `environment` or `declaration`. Wrappers are separate `projectFile` +candidates supplied by the tool definition, never provider installations. +`reader` is `native` or `compatible`, matching that file's declared +`watchFiles[].reader`; a backend cannot claim native ownership arbitrarily. +If more than one enabled provider declares native ownership of the same source, +the host reports an unresolved conflict rather than sorting plugin IDs. + +For a project statement, match the declared watch pattern relative to each +candidate source-directory anchor, with at most 64 ancestors of the requested +native directory considered. The matching anchor determines project specificity, +not the source file's physical parent: `.mise/config.toml` at `/project` belongs +to `/project`, not `/project/.mise`. If more than one pattern/anchor matches the +same canonical source, use the nearest matching anchor and then native-reader +precedence; indistinguishable conflicting claims remain explicit conflicts. +An upstream may identify an enclosing project file above the opened workspace; +its absolute source is evidence only and never expands the workspace identity +or the editable pins scope. Global sources need not be ancestors. A global-only +query cannot return project statements. `installation: null` means the manager +knows the request but cannot supply a usable installed choice. The host does not +translate `lts`, `system`, `ref:`, `prefix:` or any other request syntax. + +## Validation and limits + +The host passes untrusted replies through Core's deterministic shape and policy +validation before publishing them. Native adapters supply canonical paths, +file identities, existence, type and executable evidence; Core must not assume a +string prefix or JSON Schema proves any of these facts. Every candidate is +revalidated before launch to close stale-cache and symlink replacement windows. +The eventual launch adapter retains its existing atomic/native identity checks. + +- A reply is at most 1 MiB of UTF-8 JSON: at most 512 installations, 256 + statements and 256 watch paths. Each native path is at most 4096 characters; + operation/tool/provider identifiers and request strings have schema bounds. + Oversized replies fail as a whole. Deduplication cannot hide a limit breach. +- Installation homes are absolute directories. Executables come only from the + enabled tool definition's `executableLayouts`; the provider cannot replace + them with arbitrary argv, executables or environment variables. The selected + executable must exist, be a regular executable in the current authority, and + remain inside the canonical home. Providers return the final tool home; + `homeLayouts` applies to manual/system discovery inputs, not a second rewrite + of a provider home. +- Neither a provider installation's canonical home nor its executable may be + inside any root of the current workspace, including a symlink into a root. + An unsafe installation rejects the provider reply with `unsafePath`; it is + not silently promoted to a project wrapper. A provider can report an unmet + requirement instead of returning a project-contained home. +- A non-null statement installation must reference a validated installation of + the same tool in this reply. Unknown or disabled definition IDs lose all + installation authority. Their statements retain only bounded diagnostic facts + (`tool`, `scope`, `source`, `request`), never a usable path. +- Apply byte/count/string bounds and structural validation before filtering. + Then discard installation entries without requested, declared and active + definition IDs **before native filesystem validation**. Only remaining + installations undergo existence/executable/containment checks. Consequently, + an unknown tool's unusable home cannot invalidate an otherwise valid reply; + malformed JSON or a bounds violation still can. +- The host's installed-plugin registry supplies any missing-definition plugin + name and Enable action. Provider data cannot invent plugin names, install + links, enable targets or arbitrary commands. Enabling requires user action. +- Paths cannot contain controls, unnormalized parent traversal or mismatched + authority. Native case and volume rules determine equality and containment; + do not lowercase all paths. Canonical duplicates are collapsed after bounds + checking. Missing watch paths are valid; missing installations are not. +- Lists are normalized deterministically: installations by tool, canonical + home, then version; statements by tool, scope, canonical source, kind, reader, + request, then installation; watch paths by canonical identity. Sorting is not + a tie-breaking rule for conflicting provider requirements. + +Project wrappers and environments are **project code**, not exceptions to the +provider boundary. A tool definition's `projectFile`/wrapper candidate may +resolve a workspace-contained program only through the consumer's existing +explicit user-action/consent flow (there is no persistent workspace trust state +in the current product). Passive import or opening settings does not +execute wrappers or probe project environments. All resulting selections carry +`projectCode: true`, and automatic background consumers must reject them unless +their existing trust boundary explicitly permits execution. Returning +`kind: environment` from a provider cannot waive this rule. + +The new capability's `projectFile.projectKind` is `wrapper` by default or +`environment` with an explicit source-directory-relative `home`. The latter's +`path` must equal that home plus an applicable declared executable layout; no +tool-name or `bin`-parent heuristic supplies a home. Specificity uses the +candidate's source-directory anchor, not the executable's parent directory. +A project-environment candidate can be declared as +`{"kind":"projectFile","projectKind":"environment","home":".venv","path":".venv/bin/python"}`; +the tool's executable layout supplies `bin/python`, not the host. +A wrapper is executable-only and returns `selection.home: null`. It emits no +`${home}` exports or PATH entries; a launch template requiring `${home}` skips +with `homeUnavailable` rather than pretending the repository is an installation. + +Project environments may contain executable symlinks (for example a venv's +interpreter link to an external interpreter). This is a distinct project-code +policy, not a provider exception: the lexical home must remain within the +workspace, the lexical program must match its declared layout, and the adapter +must validate the resolved executable and the complete link chain's identities +at the authorized launch boundary. Preserve the declared lexical launch path; +substituting the canonical target can change environment semantics. A changed +link or missing native identity check rejects the launch. No link grants +background execution authority. Without the consumer's explicit-action/consent +admission, skip with `projectCodeRequiresAction`, never `requestedNotInstalled`. + +## Query lifecycle, cache and watching + +Each query round captures authority, workspace/root/context generation, enabled +definition/provider digests, tool set, environment snapshot and a monotonic +round generation. It queries **every enabled provider** relevant to that round; +each request owns a unique operation ID and JSON-RPC ID. Replies must match +both IDs, round generation and the bound provider. A round publishes one atomic snapshot after +all requests finish, fail, or reach their deadlines. It can preserve a previous +valid snapshot while visibly loading, but cannot label old state as current. +Partial failures stay visible; absence of a reply never means an empty provider. + +The default per-query deadline is 5 seconds including backend startup; +host configuration can choose 100-30000 milliseconds. Cancellation sends +`$/cancelRequest` with the JSON-RPC ID and completes local ownership immediately. +An owned backend unresponsive to cancellation gets a bounded 2-second cleanup +grace before the existing process-tree termination path. A shared backend's +unrelated operations are not stopped to cancel one query. Session shutdown, +disable, uninstall or workspace replacement cancels all owned operations and +listeners; callbacks recheck generation before changing any state. Every +operation completes once. A reply after cancellation, timeout, generation +change or provider removal is discarded, never cached or published. + +The cache key includes the captured identities above. Its input fingerprint +combines returned `watch` paths with the manifest's `watchFiles` declarations. +The host watches parent directories and the bounded set of matching inputs so +creation, deletion, atomic replacement and rename are observable even when a +file did not exist in the previous reply. Missing/existing, type, identity, +size, modification time and canonical target are fingerprinted; native watcher +events invalidate even if a replacement happens to preserve metadata. A lost +watcher event/overflow invalidates the round rather than claiming the cache is +fresh. No file contents or manager syntax are parsed by this cache. + +Manifest patterns have at most eight components and no recursive `**`; matching +is bounded to workspace/context ancestor directories and explicitly returned +absolute watch roots. The host caps each round at 256 watch roots and 4096 +enumerated directory entries, not only successful matches; exceeding this +produces a visible limit failure, not a recursive scan +of the user's home. Multiple file events may coalesce in one scheduling turn. +**Any** matching file change invalidates the entire snapshot, cancels a pending +round, and re-queries **all** providers. There are no per-provider or per-tool +incremental updates. Providers remain responsible for returning every external +global/configuration input they read or would consult if created, including +absent inputs during a global-only query. Returning only existing files is not +sufficient to detect the first creation of a higher-priority configuration. + +Local and remote authorities never share caches, shell environment, native +paths or providers. The first implementation is local only. A remote request +fails with `authorityUnsupported` until that authority has an actual query and +filesystem adapter. It never probes the local placeholder, shell, local pins +or local installations as a fallback. Static definitions may be reused only as +inert data, not as permission to run a local scanner for a remote workspace. + +## One four-layer resolver + +[`resolution.schema.json`](resolution.schema.json) describes normalized output, +not a second resolver implementation. Tool definitions supply their ordered +`executables` candidates; the reusable executable resolver extends its existing +`skip`, `resolved`, `terminalFailure` result. `setting.layer` distinguishes +project pins from application defaults; `toolchain.scope` is `project`, +`global`, or `installed`. Existing per-owner candidate ordering and insertion +rules remain; a consumer cannot reorder another plugin's candidate set or move +a lower layer above a pin. The following layer order is invariant: + +| Layer | Selection and failure behavior | +| --- | --- | +| 1 Fixed | Nearest ancestor pin for this directory and tool; invalid/missing/unexecutable/probe-failing/incompatible nonempty pin is `terminalFailure`, never fallback. | +| 2 Project | Nearest originating directory first; within it wrapper > environment > version declaration. For the same source, native reader > compatible reader. Uninstalled requirements are `skip` with a reason, retained as unmet while lower layers continue. | +| 3 Global | Lithe's explicitly saved default > selected provider global statement > login-shell home variable/commands. Conflicting provider globals require user choice; before choosing, skip provider globals and follow login Shell, not plugin-ID order. | +| 4 Automatic | Highest compatible probed installed release from all providers plus the definition-driven system scanner; excludes manual-only registrations. | + +Project distance is compared **before** project candidate category: a child +version file beats a wrapper in a more distant parent. After native/compatible +precedence, differing equally specific project statements are an explicit +conflict requiring user choice; while unresolved they skip with a conflict +reason. Equal normalized installations are deduplicated, not conflicts. Chosen +provider identities are application-owned preferences scoped by authority, +workspace/directory and tool, never implicit ID ordering. + +Consequently, project sources must expand into one bounded project-layer plan +before its first candidate is attempted. A sequential implementation that +searches every ancestor for the wrapper candidate before considering the next +`toolchain` candidate violates this contract. The existing resolver consumes +the normalized plan; source enumeration is read-only and must not execute or +probe shadowed wrappers/environments to establish their precedence. + +The `skip.reason` object is mandatory. `requestedNotInstalled` includes `tool`, +`provider`, `source`, and opaque `request`; `definitionUnavailable` always includes +`tool` and also includes `source` and `request` when it describes an observed +statement rather than an inactive definition or dormant pin. `providerConflict` is accompanied by a +`conflicts` entry listing qualified identities. Reasons are structured facts +rendered through shared i18n, not English sentences assembled in Core. A +successful fallback retains all applicable unmet requirements and conflicts. +Empty layers remain `empty` trace rows; layers masked by success are `shadowed` +and must not be probed just to populate the settings display. Expanders show +only the effective layer by default; Show All exposes the trace without +changing execution behavior. No selection means `skip`, not invented success. + +The version probe's declared regular expression supplies a normalized numeric +dotted release in its first capture (at most eight unsigned 32-bit components, +64 characters). Compare components numerically with absent trailing components +as zero; there is no Java-8, `v` prefix, vendor, alias or pre-release special +case. No valid comparable capture means not eligible for automatic selection; +an explicit choice still cannot claim to satisfy a consumer's declared minimum +without a successful probe. The provider's `version` is preserved as display +provenance, never trusted instead of the definition's probe. A wrapper may have +`selection.version: null` until the existing user-authorized execution flow can +probe it; consumers requiring a version cannot treat `null` as compatible. +If the newest normalized release has different canonical homes, automatic +resolution reports `ambiguousInstallation`, an `automatic` conflict listing +the distinct canonical homes, and a `chooseInstallation` action; it requires an +explicit choice; sorting homes or plugin IDs must not silently decide which binary runs. +Multiple reports of the same canonical home are one installation. + +Environment exports and PATH prepend entries are generated solely by enabled +definitions using the selected validated home/executable. A failed candidate's +environment is discarded before trying the next. Consumer `requiresTools`, +`launch.tool` and `onToolchainChange` bind directory-scoped usage and explain +whether a switch needs restart, resync or applies next run. Disabling a +definition withdraws its rows, consumers, exports and injected settings page; +it does not delete saved pins. Existing processes do not silently restart. +An executable-only wrapper can export `${executable}`, but cannot export or +prepend paths based on an absent home. Its consumer still owns wrapper-specific +launch arguments; it cannot silently reuse a runtime entry template requiring +installation facts that the wrapper does not provide. + +## Local pins and manual installations + +[`local.schema.json`](local.schema.json) is an isolated proposed extension of +the machine-local `.lithe/toolchains/local.json`, retaining `version: 1` and +legacy `toolchains` while adding `pins`. It does not modify the active v1 +schema or promise that an old host accepts new fields. The implementation phase +must gate writers on the new capability and migrate the corresponding readers +together; it must not write this draft shape for an old reader. + +`pins` maps normalized workspace-relative directory keys to tool-ID maps. +`.` is the opened root; absolute keys, backslashes, empty segments, `.`, `..` +segments and trailing separators are rejected. Dotted directory names are valid. +For `apps/web/src`, check that directory, `apps/web`, `apps`, then `.`, never +`apps/website` by string prefix. Each pin stores `{home, version, provider}`; +provider is a qualified identity or the reserved generic `host:manual` / +`host:system`, not a backend activation instruction. An inherited pin applies +until a nearer explicit value replaces it. Removing an override reveals the +ancestor; it does not delete that ancestor or the whole local file. + +Opening a project or settings only reads. An edit is a draft until explicit +Save/Apply confirms the target directory, tool and machine-local path. The +platform adapter checks the read revision and rejects stale writes, preserves +unrelated legacy entries/pins and permissions, atomically replaces the file, +then reads it again to display the effective choice. No write on focus loss, +navigation, startup, cancellation or unchanged inherited values. Read failures +are errors, not empty defaults. Reject symlinked local targets and any write +escaping the selected workspace's machine-local store. + +The first explicit save adds `/toolchains/local.json` exactly once to +`.lithe/.gitignore`, preserving other content and using conflict protection. +There is no transaction spanning two files: secure the ignore entry before +publishing the new local file; an ignore-write failure aborts the save. If local +file publication fails afterward, the harmless ignore entry may remain and the +draft remains unsaved. Neither target is created merely by opening a project. + +Manual Add validates a user-selected directory via `homeLayouts`, +`executableLayouts`, and the declared bounded version probe. It registers the +installation only in platform application storage, outside app bundles, +installed plugin packages and the project. Such registrations can be explicitly +pinned but do not participate in automatic discovery. A workspace-contained +directory is not converted into a trusted external installation by manual Add; +project wrappers/environments retain their separate project-code boundary. +Pins survive plugin disable/uninstall and reactivate only when that definition +is enabled again. No tool is downloaded, installed, trusted or repaired by query. + +## Stable failure mapping + +These are proposed additions, not assignments in the current runtime. Existing +host-service codes `-32010` through `-32021` keep their meanings. Toolchain +custom failures reserve `-32070` through `-32077` in the server-error range; +each numeric code has exactly one semantic code. JSON-RPC error `data` includes +`code`, `operationID`, and `providerID`; optional details are bounded to 4 KiB +serialized and contain no credentials. Native errors are translated to shared +i18n at the host boundary; plugin diagnostic text remains unmodified but is +not a policy selector. Timeout is not cancellation, and stale replies do not +generate a second completion. + +| JSON-RPC | Semantic code | Meaning | +| --- | --- | --- | +| `-32601` | `plugin.toolchain.methodUnavailable` | Query method/capability not negotiated. | +| `-32602` | `plugin.toolchain.invalidParams` | Malformed request or invalid declared input. | +| `-32800` | `plugin.toolchain.cancelled` | Caller cancelled the owned request. | +| `-32070` | `plugin.toolchain.permissionDenied` | Permission/review/enabled-state prerequisite absent. | +| `-32071` | `plugin.toolchain.authorityUnsupported` | No adapter for this authority. | +| `-32072` | `plugin.toolchain.timeout` | Monotonic local deadline exceeded. | +| `-32073` | `plugin.toolchain.providerUnavailable` | Backend failed to start, disconnected, or failed its upstream query. | +| `-32074` | `plugin.toolchain.invalidReply` | Malformed reply or inconsistent same-tool installation reference. | +| `-32075` | `plugin.toolchain.limitExceeded` | Reply/scan/watch budget exceeded. | +| `-32076` | `plugin.toolchain.unsafePath` | Invalid absolute path, containment or executable safety evidence. | +| `-32077` | `plugin.toolchain.operationExpired` | Context/generation was already invalid when a query was dispatched. | + +Request-local failures (missing installation, incompatible version, conflicting +requirements) are resolver reasons, not JSON-RPC transport errors. The query +fixture includes the numeric mapping; runtime tests, SDK end-to-end tests and +both native host lifecycle tests are required in later implementation phases. diff --git a/contract/v1/proposals/toolchains/query.schema.json b/contract/v1/proposals/toolchains/query.schema.json new file mode 100644 index 0000000..4f1fa55 --- /dev/null +++ b/contract/v1/proposals/toolchains/query.schema.json @@ -0,0 +1,615 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/query.schema.json", + "title": "Proposed toolchain query protocol v1 (not implemented)", + "description": "Review-only JSON-RPC request, successful response or error response. Semantic validation and authority checks are specified in protocol.md.", + "oneOf": [ + { + "$ref": "#/$defs/request" + }, + { + "$ref": "#/$defs/response" + }, + { + "$ref": "#/$defs/errorResponse" + } + ], + "$defs": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "providerID": { + "$ref": "manifest-contributions.schema.json#/$defs/identifier" + }, + "nativePath": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "pattern": "^(?:/|[A-Za-z]:[\\\\/]|\\\\\\\\)", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + }, + "description": "Absolute native path in this query's authority; canonical containment requires native filesystem evidence, not this lexical pattern." + }, + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + }, + "description": "Workspace-relative directory using /; . means the opened root. Each other component must not be . or ..; control characters are forbidden semantically." + }, + "authority": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind" + ], + "properties": { + "kind": { + "const": "local" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "id" + ], + "properties": { + "kind": { + "const": "remote" + }, + "id": { + "$ref": "#/$defs/identifier" + } + } + } + ] + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "contextID", + "generation" + ], + "properties": { + "contextID": { + "$ref": "#/$defs/identifier" + }, + "generation": { + "type": "integer", + "minimum": 1 + } + } + }, + "workspace": { + "type": "object", + "additionalProperties": false, + "required": [ + "rootID", + "root", + "directory" + ], + "properties": { + "rootID": { + "$ref": "#/$defs/identifier" + }, + "root": { + "$ref": "#/$defs/nativePath" + }, + "directory": { + "$ref": "#/$defs/directory" + } + } + }, + "params": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "generation", + "providerID", + "authority", + "context", + "workspace", + "tools" + ], + "properties": { + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "authority": { + "$ref": "#/$defs/authority" + }, + "context": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/context" + } + ] + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/workspace" + } + ] + }, + "tools": { + "type": "array", + "minItems": 1, + "maxItems": 64, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/toolID" + } + }, + "generation": { + "type": "integer", + "minimum": 1, + "description": "Host query-round generation, including workspace:null queries; echoed unchanged." + } + }, + "allOf": [ + { + "if": { + "properties": { + "workspace": { + "type": "object" + } + } + }, + "then": { + "properties": { + "context": { + "$ref": "#/$defs/context" + } + } + } + } + ] + }, + "installation": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "version", + "home" + ], + "properties": { + "tool": { + "$ref": "#/$defs/toolID" + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "home": { + "$ref": "#/$defs/nativePath" + } + } + }, + "statement": { + "type": "object", + "additionalProperties": false, + "required": [ + "tool", + "scope", + "source", + "request", + "installation", + "kind", + "reader" + ], + "properties": { + "tool": { + "$ref": "#/$defs/toolID" + }, + "scope": { + "enum": [ + "project", + "global" + ] + }, + "source": { + "$ref": "#/$defs/nativePath" + }, + "request": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "installation": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/nativePath" + } + ] + }, + "kind": { + "enum": [ + "environment", + "declaration" + ] + }, + "reader": { + "enum": [ + "native", + "compatible" + ] + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "generation", + "providerID", + "installations", + "statements", + "watch" + ], + "properties": { + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "installations": { + "type": "array", + "maxItems": 512, + "items": { + "$ref": "#/$defs/installation" + } + }, + "statements": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/statement" + } + }, + "watch": { + "type": "array", + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nativePath" + } + }, + "generation": { + "type": "integer", + "minimum": 1, + "description": "Host query-round generation, including workspace:null queries; echoed unchanged." + } + } + }, + "request": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "method", + "params" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "method": { + "const": "toolchains/query" + }, + "params": { + "$ref": "#/$defs/params" + } + } + }, + "response": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "result" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "result": { + "$ref": "#/$defs/result" + } + } + }, + "errorResponse": { + "type": "object", + "additionalProperties": false, + "required": [ + "jsonrpc", + "id", + "error" + ], + "properties": { + "jsonrpc": { + "const": "2.0" + }, + "id": { + "type": "integer", + "minimum": 1 + }, + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message", + "data" + ], + "properties": { + "code": { + "enum": [ + -32601, + -32602, + -32800, + -32070, + -32071, + -32072, + -32073, + -32074, + -32075, + -32076, + -32077 + ] + }, + "message": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "data": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "operationID", + "providerID" + ], + "properties": { + "code": { + "enum": [ + "plugin.toolchain.methodUnavailable", + "plugin.toolchain.invalidParams", + "plugin.toolchain.cancelled", + "plugin.toolchain.permissionDenied", + "plugin.toolchain.authorityUnsupported", + "plugin.toolchain.timeout", + "plugin.toolchain.providerUnavailable", + "plugin.toolchain.invalidReply", + "plugin.toolchain.limitExceeded", + "plugin.toolchain.unsafePath", + "plugin.toolchain.operationExpired" + ] + }, + "operationID": { + "$ref": "#/$defs/identifier" + }, + "providerID": { + "$ref": "#/$defs/providerID" + }, + "details": { + "type": "object", + "maxProperties": 8 + } + } + } + }, + "allOf": [ + { + "oneOf": [ + { + "properties": { + "code": { + "const": -32601 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.methodUnavailable" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32602 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.invalidParams" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32800 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.cancelled" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32070 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.permissionDenied" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32071 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.authorityUnsupported" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32072 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.timeout" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32073 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.providerUnavailable" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32074 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.invalidReply" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32075 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.limitExceeded" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32076 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.unsafePath" + } + } + } + } + }, + { + "properties": { + "code": { + "const": -32077 + }, + "data": { + "properties": { + "code": { + "const": "plugin.toolchain.operationExpired" + } + } + } + } + } + ] + } + ] + } + } + } + } +} diff --git a/contract/v1/proposals/toolchains/resolution.schema.json b/contract/v1/proposals/toolchains/resolution.schema.json new file mode 100644 index 0000000..623c67c --- /dev/null +++ b/contract/v1/proposals/toolchains/resolution.schema.json @@ -0,0 +1,362 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/lithe-plugin-docs/main/contract/v1/proposals/toolchains/resolution.schema.json", + "title": "Proposed shared toolchain resolution result v1 (not implemented)", + "description": "Review-only normalized resolver output. Host-owned snapshots and native filesystem evidence are inputs; providers never produce this object directly.", + "type": "object", + "additionalProperties": false, + "required": [ + "operationID", + "tool", + "directory", + "outcome", + "trace", + "unmet", + "conflicts" + ], + "properties": { + "operationID": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tool": { + "$ref": "#/$defs/toolID" + }, + "directory": { + "$ref": "#/$defs/directory" + }, + "outcome": { + "$ref": "#/$defs/outcome" + }, + "trace": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/trace" + } + }, + "unmet": { + "type": "array", + "maxItems": 256, + "items": { + "$ref": "#/$defs/reason" + } + }, + "conflicts": { + "type": "array", + "maxItems": 64, + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "providers", + "action" + ], + "properties": { + "scope": { + "enum": [ + "project", + "global" + ] + }, + "providers": { + "type": "array", + "minItems": 2, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "action": { + "const": "chooseProvider" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "scope", + "providers", + "installations", + "action" + ], + "properties": { + "scope": { + "const": "automatic" + }, + "providers": { + "type": "array", + "minItems": 1, + "maxItems": 32, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + }, + "installations": { + "type": "array", + "minItems": 2, + "maxItems": 512, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/nativePath" + } + }, + "action": { + "const": "chooseInstallation" + } + } + } + ] + } + } + }, + "$defs": { + "directory": { + "type": "string", + "maxLength": 2048, + "pattern": "^(?:\\.|(?!\\.\\.?(?:/|$))[^/\\\\:]+(?:/(?!\\.\\.?(?:/|$))[^/\\\\:]+)*)$", + "not": { + "pattern": "[\\u0000-\\u001f\\u007f]" + } + }, + "toolID": { + "$ref": "manifest-contributions.schema.json#/$defs/toolID" + }, + "layer": { + "enum": [ + "pinned", + "project", + "global", + "automatic" + ] + }, + "reason": { + "type": "object", + "additionalProperties": false, + "required": [ + "code" + ], + "properties": { + "code": { + "enum": [ + "notConfigured", + "notFound", + "notExecutable", + "probeFailed", + "versionUnsatisfied", + "requestedNotInstalled", + "definitionUnavailable", + "providerConflict", + "unsafePath", + "providerUnavailable", + "ambiguousInstallation", + "homeUnavailable", + "projectCodeRequiresAction" + ] + }, + "tool": { + "$ref": "#/$defs/toolID" + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "request": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "description": "Structured facts, not assembled English. The host renders its shared i18n message with these arguments. Required facts per code are specified in protocol.md." + }, + "selection": { + "type": "object", + "additionalProperties": false, + "required": [ + "home", + "executable", + "version", + "provider", + "layer", + "projectCode", + "environment", + "pathPrepend" + ], + "properties": { + "home": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/nativePath" } + ] + }, + "executable": { + "$ref": "#/$defs/nativePath" + }, + "version": { + "type": [ + "string", + "null" + ], + "minLength": 1, + "maxLength": 128 + }, + "provider": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "layer": { + "$ref": "#/$defs/layer" + }, + "projectCode": { + "type": "boolean" + }, + "environment": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + }, + "propertyNames": { + "$ref": "manifest-contributions.schema.json#/$defs/safeEnvironmentName" + } + }, + "pathPrepend": { + "type": "array", + "maxItems": 16, + "items": { + "$ref": "#/$defs/nativePath" + } + } + } + }, + "outcome": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "selection" + ], + "properties": { + "kind": { + "const": "resolved" + }, + "selection": { + "$ref": "#/$defs/selection" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "reason" + ], + "properties": { + "kind": { + "const": "skip" + }, + "reason": { + "$ref": "#/$defs/reason" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "reason" + ], + "properties": { + "kind": { + "const": "terminalFailure" + }, + "reason": { + "$ref": "#/$defs/reason" + } + } + } + ] + }, + "trace": { + "type": "object", + "additionalProperties": false, + "required": [ + "candidateID", + "layer", + "state" + ], + "properties": { + "candidateID": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "layer": { + "$ref": "#/$defs/layer" + }, + "state": { + "enum": [ + "skip", + "resolved", + "terminalFailure", + "shadowed", + "empty" + ] + }, + "reason": { + "$ref": "#/$defs/reason" + }, + "source": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + }, + "allOf": [ + { + "if": { + "properties": { + "state": { + "enum": [ + "skip", + "terminalFailure" + ] + } + } + }, + "then": { + "required": [ + "reason" + ] + } + } + ] + }, + "nativePath": { + "$ref": "query.schema.json#/$defs/nativePath" + } + } +} diff --git a/contract/v1/toolchains.md b/contract/v1/toolchains.md new file mode 100644 index 0000000..af255a1 --- /dev/null +++ b/contract/v1/toolchains.md @@ -0,0 +1,264 @@ +# Plugin-owned toolchains: Core foundation + +**Status: shared Core implemented; the macOS settings pages run both routes +read-only.** Core validates data and resolves captured facts; it does not launch +providers, probe programs, parse manager files, watch paths, write pins, or +download tools. The macOS settings host expands `discoveryPlan` scans, starts or +reuses each enabled provider's plugin backend, sends `toolchains/query` through +`lsp.toolchainRequest`, passes every raw reply with native facts to `decodeReply`, +runs the definition's version probe on executables Core returns from +`candidates`, and shows the result. Any change to a watched input reloads the +whole snapshot. Nothing is saved, and no build, run or language-server launch +uses the result yet. Windows is not connected. +The [broader proposal](proposals/toolchains/README.md) still describes unsupported +consumer migration, native lifecycle, persistence and manager-plugin work. +The active shape is [plugin-manifest.schema.json](plugin-manifest.schema.json), +not the proposal envelopes. + +## Manifest admission and registry + +`toolDefinitions[]` needs `requires.toolDefinitions: 1` and +`requires.executableResolution: 2`. `toolchainProviders[]` needs +`requires.toolchainProviders: 1`, `backend: 2`, `serverProcess: 1`, and permissions +`backend.launch` / `toolchains.provide`. Capability lists are exact: missing or +unused capabilities reject the manifest. A definition/provider alone is a valid +contribution. Old executable candidate sets retain their strict source types. + +Definitions own IDs, labels/icons, `executableLayouts`, ordered `executables`, +`version`, `homeLayouts`, `discovery`, `homeVariable`, `commands`, `wrappers`, +`exports`, `pathPrepend` and argv `launch`. Counts, relative paths, bounded globs, +platforms, templates and version expressions are validated recursively; unknown +fields fail closed. Candidate graph ordering reuses the existing shared graph +validator and cannot cross layer barriers. This slice does not support a +definition candidate of `kind: runtime`, or `toolchainConsumers`. + +Provider entries contain `{id, displayName, tools, watchFiles?}`; each watch entry +is `{pattern, reader: "native" | "compatible"}`. A present `watchFiles` lists 1-32 +unique patterns; an absent one makes an installation-only provider, whose +replies may not contain statements. Providers share the plugin's +backend; admission permits a host-query trigger without a page or startup event, +and the macOS host starts the backend on the first query. Bootstrap must be `packaged`, or +`runtime` with `packagedRuntime`, without discovered runtime dependencies. Existing +runtime launch tool restrictions remain; generic consumer launches are not added. + +`toolchains.provide` may influence which program a defined tool launches. It does +not grant arbitrary command replacement, installation, network or workspace-code +execution. Short definition IDs require independently verified official origin; +community definitions use `:`. Community providers may +answer short IDs. No host-owned tool table, name alias or manager parser is added. + +## Two installation routes + +A definition receives installations through either route, or both. Neither is +required: a tool may rely only on pins, project files, global defaults and +login-shell commands. + +| Route | Declared by | Fits | Runs | +| --- | --- | --- | --- | +| Declarative discovery | The definition's `discovery`, `homeLayouts`, `homeVariable`, `commands` and `version` | Installations with a fixed directory shape: a single-component glob under the user home, an environment variable or an absolute system directory, an optional home suffix, and a version expression over a probe | The host expands `discoveryPlan` scans; no plugin code runs | +| Provider query | `toolchainProviders[]` answering `toolchains/query` | Locations those primitives cannot express: registries, property lists, manager databases, command output, manager-specific version files | The provider's packaged backend | + +The declarative primitives are frozen: root kinds stay `home`, `environment` +and `absolute`, and patterns stay single-component `*` globs. A new installation +shape is answered by a provider, not by a new root kind or a host parser. The +plugin that defines a tool may also declare a provider for it; omit +`watchFiles` when it only reports installations. A provider backend never runs +on a tool it discovers: the bootstrap rules above apply to a defining plugin's own +provider as well. + +`discoveryPlan` lists both routes per tool. The host expands `scans` into +`systemInstallations`, recorded with provenance `host:system`, and queries every +qualified provider in `providers`. In the automatic layer both routes form one +candidate set. One canonical home counts once: a provider answer labels it +before the host scan, and among providers the smaller qualified ID labels it. +This label is display provenance and never chooses between different homes; +equal highest versions in different homes remain an `automatic` conflict that +names every route. + +## Commands and envelopes + +All commands accept UTF-8 JSON values and have a 4 MiB input budget. Success is +`{status:"ok", :value}`; refusal is `{status:"rejected",error:{code,message, +field?}}`. Errors are structured policy outcomes, not host-localized UI text. +Future hosts render stable codes through shared i18n; English diagnostic messages +are not a substitute for that adapter. These commands perform no native I/O. + +| Command | Input | Success key | +| --- | --- | --- | +| `toolchains.registry` | `{plugins:[{manifest,verifiedOfficial}]}` | `registry` | +| `toolchains.decodeReply` | Query/response and native evidence described below | `snapshot` | +| `toolchains.failureReply` | `{params,code}` described below | `reply` | +| `toolchains.resolvePins` | `{document,directory,tool}` | `resolution` | +| `toolchains.resolve` | Captured resolver input described below | `resolution` | +| `toolchains.checkVersion` | `{registry,tool,output,minimumVersion?}` | `version` | +| `toolchains.discoveryPlan` | `{registry,platform}` | `plan` | +| `toolchains.candidates` | Captured discovery input described below | `candidates` | + +Every `registry` input is the raw `{plugins:[...]}` request, not a previously +normalized result. Core revalidates complete production manifests and the +host-supplied `verifiedOfficial` boolean; a plugin cannot assert that evidence +inside its own manifest. Output is `{definitions:[{pluginID,definition}], +providers:[{pluginID,provider}]}` in stable order. Duplicate plugin/tool ownership +rejects the batch without replacing earlier owners. Limits are 128 plugins, +512 aggregate definitions and 256 aggregate providers. + +## Provider reply validation + +`decodeReply` takes `{registry,pluginID,request,response,currentGeneration, +filesystem,platform,filesystemCaseSensitive?,workspaceRoots?}`. `pluginID` binds +the host-owned session, not a provider claim. `platform` is `macos`, `windows` or +`linux`; native filesystem case policy is explicit where platform defaults differ. + +`request` is `{jsonrpc:"2.0",id,method:"toolchains/query",params:{operationID, +generation,providerID,authority:{kind:"local"},context,workspace,tools}}`. +`context` is `{contextID,generation}` or `null`; workspace queries require it. +`workspace` is `{rootID,root,directory}` or `null` for global-only queries. +`directory` is normalized workspace-relative, with `.` for the opened root. +Tools are unique and sorted. This envelope is validated as inert data; no query +is dispatched and no protocol capability is negotiated by this command. + +Successful `response` echoes JSON-RPC `id` and has `result:{operationID,generation, +providerID,installations,statements,watch}`. Installations are `{tool,version,home}`. +Statements are `{tool,scope,source,request,installation,kind,reader}`: scope is +`project|global`, kind is `environment|declaration`, reader matches the provider's +declared watch pattern; `installation:null` records an unmet requirement. Request +syntax is opaque. Non-null installations must reference the same tool in the +reply. `watch` contains absolute native inputs, including missing paths. + +Each native filesystem fact is `{path,canonicalPath,kind,executable?}` with kind +`file|directory|missing`. These facts come from the authority-local host, never +the provider. Core checks unique paths, actual-kind evidence, definition-owned +executable layouts, canonical home containment and exclusion of all workspace +roots. Provider homes are final tool homes; `homeLayouts` is not reapplied. +A non-missing fact also attests its canonical target with the same kind and mode, +so canonical results found through a link are checked against the same snapshot; +facts that disagree about one canonical path reject it. +Inactive/unknown tools lose installation authority; bounded requirement facts +remain diagnostic only. Lists normalize deterministically; sort order never +chooses between conflicting requirements. A reply is bounded to 1 MiB, 512 +installations, 256 statements and 256 watch paths; mismatched IDs/generations, +remote authority, unsafe paths and malformed error mappings reject it. + +The snapshot contains `{installations,statements,watch,diagnostics}`; normalized +entries carry qualified `:` provenance and project source +directory. JSON-RPC errors are checked against the distinct semantic/numeric map +in the [protocol proposal](proposals/toolchains/protocol.md#stable-failure-mapping). +Cancellation, deadlines, cache invalidation and whole-generation requery remain +native-client implementation work, not behavior performed by this decoder. + +When a query gets no reply at all, the host asks `failureReply` for one. `params` +is the query's own `params` and `code` is `plugin.toolchain.timeout` (the local +deadline passed) or `plugin.toolchain.providerUnavailable` (the backend failed to +start, disconnected or did not offer the provider). The result is +`{id,response}`, the same shape `lsp.toolchainRequest` completes with, holding the +mapped JSON-RPC error for that operation and provider. The host decodes it with +the same request like any provider error. Cancellation and every other code are +rejected, so a cancelled or refused query never becomes a missing provider. + +## Pins and snapshot resolution + +`resolvePins` validates an inert local document `{version:1,toolchains,pins?}`. +`toolchains` retains legacy entries; `pins` maps directory keys to tool maps of +`{home,version,provider}`. It returns `{pin,sourceDirectory}`, both `null` if absent. +Nearest exact ancestor wins; sibling prefixes do not match. Dormant pins survive +missing definitions. No lookup grants execution authority or writes a file. +Saving, conflict protection and `.lithe/.gitignore` updates are not implemented. + +`resolve` requires `{registry,tool,directory,operationID,generation,context, +platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, +`filesystemCaseSensitive`, `local`, `minimumVersion`, `probes`, `queries`, +`systemInstallations`, `globalDefault`, `environment`, `commandHomes`, +`projectCodeAllowed` and `providerChoices`. `local` defaults to an empty v1 +document. Probes are host observations `{executable,output,exitCode}` keyed by the +canonical executable a selection reports; system installations are `{tool,home}`. +Shell environment home variables and `commandHomes` come from the native login-shell +adapter, not command-name guessing. An environment candidate selects only through +its declared `path` layout. + +Each query is a complete `decodeReply` input, bound to the resolver's registry, +generation/context, root/directory, case policy, roots and filesystem snapshot. +The resolver accepts local workspace queries here; global-only decoder inputs +are not a substitute for the workspace-bound query round. `providerChoices` +records explicit project/global provider or automatic-installation choices; it +does not activate providers or bypass their evidence checks. +Its shape is `{project?:"pluginID:providerID",global?:"pluginID:providerID", +automatic?:"absolute-installation-home"}`; choices not present in the current +validated candidate set confer no authority. +Unavailable/timed-out optional providers remain visible as `providerUnavailable` +diagnostics while lower sources may resolve. Each is `{code,tool,provider,cause}`, +recorded only for a query that asked about this tool; `provider` is qualified and +`cause` is the decoded error code, keeping a missed deadline apart from a backend +that could not answer. Safety, identity and cancellation +failures are not converted to missing tools. A valid explicit pin does not need +lower-layer provider answers at all. + +Four layers apply: fixed nearest pin; nearest project source (wrapper > environment +> declaration, native reader before compatible reader of the same source); +application global default > unambiguous/chosen provider global > login shell; +highest compatible installed release. Nearest project requirements mask parent +requirements. Invalid fixed values are terminal, even if a definition omits a pin +candidate. Uninstalled requirements retain reasons while resolution falls back. +Conflicts require explicit choices, never plugin-ID or home-path precedence. +Manual pin-only installations are not automatic candidates. + +Outcome is `resolved` with `{home,executable,version,provider,layer,projectCode, +environment,pathPrepend}`, `skip` with a reason, or `terminalFailure` with a reason. +Output also carries operation/tool/directory, `trace`, `unmet` and `conflicts`. +Wrapper homes may be `null`; home exports/PATH are then omitted. Project code +requires an explicit consumer authorization snapshot and is never passively +probed. This foundation rejects project programs or environment homes whose +canonical identity differs from their declared lexical identity; +complete native link-chain validation, including common venv interpreter links, +is deferred rather than silently treated as safe. Each candidate builds a fresh +environment, so rejected exports cannot leak into its successor. + +`checkVersion` returns `{satisfied,version}` from the definition's first regex +capture. Numeric dotted components (at most eight unsigned 32-bit parts, 64 bytes) +compare with trailing zeros ignored; aliases and tool-specific version syntax are +not interpreted. No match gives `version:null,satisfied:false`. `discoveryPlan` +returns `{tools,limits}`. Each tool carries platform-filtered `scans`, the sorted +qualified `providers` that declare it, and the definition's `homeLayouts`, +`executableLayouts`, `version` probe, `pathPrepend` templates and `environment` +login-shell home variables (`{variable,path}` from its platform-applicable +`environment` candidates). It neither scans, queries nor executes them; limits +are 256 roots, 4096 entries and 64 ancestors for the host adapter. + +## Settings candidates + +`candidates` reports what a settings page shows for one tool's global and +automatic layers without resolving a directory. It requires `{registry,tool, +platform,workspaceRoot,filesystem}` and accepts `workspaceRoots`, +`filesystemCaseSensitive`, `minimumVersion`, `probes`, `systemInstallations`, +`providerInstallations` and `environment`, with the same meaning and checks as in +`resolve`. `providerInstallations` (at most 512) are `{tool,home,provider}` rows a +host copies from `decodeReply` snapshots; `provider` must name a registry provider +`pluginID:providerID` declaring the tool, and the row keeps that attribution when +a scan reports the same canonical home. The output is +`{tool,shell,installations,skipped,selected,conflicts}`: + +- `shell` lists each declared home variable present in `environment` as + `{candidateID,variable,home,executable?,version?|reason?}`, checked through + the candidate's own `path` layout. +- `installations` are eligible host-scanned homes `{home,executable,version, + provider}`, highest version first, with the canonical home Core checked. +- `skipped` lists homes that failed a check as `{home,provider,reason, + executable?}`; reasons are stable codes such as `unsafePath`, `probeFailed` + and `versionUnsatisfied`. +- `selected` is the automatic-layer home, or `null`; equal highest versions + in different homes leave it `null` and add an `automatic` entry to `conflicts`. + +A first call without `probes` returns every checked executable as `probeFailed`. +The host runs only those executables with the definition's probe arguments, +then calls again with their `{executable,output,exitCode}`. Homes inside a +workspace root are refused before any probe, so a project program is never run +to read its version. The macOS host bounds probes by count, concurrency and +total time, and reports skipped work as diagnostics rather than a missing tool. + +Production-shape examples are [tool-definition.plugin.json](fixtures/tool-definition.plugin.json) +and [toolchain-provider.plugin.json](fixtures/toolchain-provider.plugin.json). +Core acceptance vectors are in +[toolchain_definitions.rs](https://github.com/1lck/Lithe-IDEA/blob/preview/rust/lithe-core/src/tests/toolchain_definitions.rs), +with sibling pins/query/resolution suites. Passing them is not native product +verification. Contract changes must be mirrored after merge with +`node scripts/contract.mjs sync --revision ` in +[lithe-plugin-docs](https://github.com/Lithe-IDEA/lithe-plugin-docs).