Skip to content

Commit 0cdc87b

Browse files
authored
Merge pull request #2 from magaransoft/refactor-tool-harness-unification
refactor: tool-harness + sbt/dotnet/formatter wrappers (v1.2.0)
2 parents db8cbdb + 14873d6 commit 0cdc87b

62 files changed

Lines changed: 5431 additions & 700 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ jobs:
3636
- name: run hook tests
3737
run: bash hooks/tests/run.sh
3838

39+
harness-tests:
40+
name: harness + proxy smoke (${{ matrix.os }})
41+
runs-on: ${{ matrix.os }}
42+
strategy:
43+
fail-fast: false
44+
matrix:
45+
os: [ubuntu-latest, macos-latest]
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- name: setup node
50+
uses: actions/setup-node@v4
51+
with:
52+
node-version: '20'
53+
54+
- name: run harness + proxy smoke tests
55+
run: node --test bin/tool-harness.test.js bin/tool-server-proxy.test.js
56+
3957
fixture-probe:
4058
name: fixture probe (${{ matrix.os }})
4159
runs-on: ${{ matrix.os }}
@@ -60,6 +78,10 @@ jobs:
6078
run: |
6179
npm i -g pyright typescript-language-server typescript
6280
81+
- name: install formatter tools (opt-in wrappers)
82+
run: |
83+
npm i -g prettier eslint
84+
6385
- name: install jq (linux)
6486
if: matrix.os == 'ubuntu-latest'
6587
run: sudo apt-get update && sudo apt-get install -y jq
@@ -72,5 +94,97 @@ jobs:
7294
if: matrix.os == 'macos-latest'
7395
run: brew install jdtls
7496

75-
- name: probe fixtures
76-
run: bash scripts/verify.sh
97+
- name: install wrapper symlinks into ~/.claude
98+
run: |
99+
mkdir -p ~/.claude
100+
./scripts/install.sh
101+
102+
- name: probe fixtures + diff baselines (shape-gate)
103+
# Body-sha check gated behind VERIFY_STRICT_SHA — CI runners install
104+
# the latest pyright/ts-ls/csharp-ls/jdtls, which can emit
105+
# version-specific serialization differences even when semantics
106+
# are identical. Shape (top_level + total_nodes counts) still
107+
# hard-fails on structural regressions; sha drift warns.
108+
run: bash scripts/verify.sh --diff-baselines
109+
110+
opt-in-probe:
111+
name: opt-in wrapper smoke (${{ matrix.os }})
112+
runs-on: ${{ matrix.os }}
113+
strategy:
114+
fail-fast: false
115+
matrix:
116+
os: [ubuntu-latest, macos-latest]
117+
steps:
118+
- uses: actions/checkout@v4
119+
120+
- name: setup node
121+
uses: actions/setup-node@v4
122+
with:
123+
node-version: '20'
124+
125+
- name: setup python
126+
uses: actions/setup-python@v5
127+
with:
128+
python-version: '3.11'
129+
130+
- name: install jq (linux)
131+
if: matrix.os == 'ubuntu-latest'
132+
run: sudo apt-get update && sudo apt-get install -y jq
133+
134+
- name: install jq (macos)
135+
if: matrix.os == 'macos-latest'
136+
run: brew install jq
137+
138+
- name: install prettier + eslint
139+
run: npm i -g prettier eslint
140+
141+
- name: install dotnet (macos)
142+
if: matrix.os == 'macos-latest'
143+
uses: actions/setup-dotnet@v4
144+
with:
145+
dotnet-version: '9.0.x'
146+
147+
- name: install dotnet (linux)
148+
if: matrix.os == 'ubuntu-latest'
149+
uses: actions/setup-dotnet@v4
150+
with:
151+
dotnet-version: '9.0.x'
152+
153+
- name: install sbt (macos)
154+
if: matrix.os == 'macos-latest'
155+
run: brew install sbt
156+
157+
- name: install sbt (linux)
158+
if: matrix.os == 'ubuntu-latest'
159+
run: |
160+
sudo mkdir -p /etc/apt/keyrings
161+
curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" \
162+
| sudo gpg --dearmor -o /etc/apt/keyrings/sbt.gpg
163+
echo "deb [signed-by=/etc/apt/keyrings/sbt.gpg] https://repo.scala-sbt.org/scalasbt/debian all main" \
164+
| sudo tee /etc/apt/sources.list.d/sbt.list
165+
sudo apt-get update
166+
sudo apt-get install -y sbt
167+
168+
- name: install wrapper symlinks into ~/.claude
169+
run: |
170+
mkdir -p ~/.claude
171+
./scripts/install.sh
172+
173+
- name: smoke prettier-direct
174+
run: |
175+
bin/prettier-direct call format-file \
176+
"{\"filepath\":\"$PWD/fixtures/node-formatter/hello.ts\"}" \
177+
"$PWD/fixtures/node-formatter"
178+
179+
- name: smoke eslint-direct
180+
run: |
181+
bin/eslint-direct call version '{}' "$PWD/fixtures/node-formatter"
182+
183+
- name: smoke dotnet-direct
184+
run: |
185+
bin/dotnet-direct call version '{}' "$PWD/fixtures/dotnet-csproj"
186+
187+
- name: smoke sbt-direct (non-fatal — sbt cold JVM may be slow on CI)
188+
continue-on-error: true
189+
run: |
190+
bin/sbt-direct call version '{}' "$PWD/fixtures/scala-sbt"

CHANGELOG.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,59 @@ All notable changes to this project will be documented here.
44

55
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/).
66

7+
## [1.2.0] — 2026-04-22
8+
9+
### Added
10+
- `bin/adapters/sbt-thin-client.js` — persistent-JVM sbt adapter. Activated via `SBT_DIRECT_MODE=thin-client` or `--mode thin-client`. Coordinator spawns `sbt` in server mode once per workspace and proxies each `/call` through `sbt --client "<task>"`; warm calls land in 200-500ms vs 20-40s one-shot cold. Adoption: attaches to an externally-running `sbt shell` if `target/active.json` + live socket detected. Requires `install.sh` allowlist for ipcsocket paths (pre-merged into `sandbox.filesystem.allowWrite`).
11+
- `hooks/prewarm-direct-wrappers.py` — SessionStart hook. Iterates `~/.cache/*-direct/*/` slots, probes each with `GET /health`, fires `<wrapper>-direct start <workspace>` in the background for any dead slot. First `call` in a new session is warm.
12+
- `install.sh` — wires the prewarm hook into `~/.claude/settings.json`'s `hooks.SessionStart` (idempotent, `unique_by(.command)`). `uninstall.sh` removes it symmetrically.
13+
- `scripts/verify.sh VERIFY_STRICT_SHA=1` — env-gated strict mode for CI. Default (unset/0) treats sha256-body mismatch as a warning so downstream users on different pyright/tsserver/csharp-ls versions don't fail the gate; structural-shape match (top_level + total_nodes) remains hard either way. CI workflow exports `VERIFY_STRICT_SHA=1`.
14+
- `bin/tool-harness.js` — shared coordinator primitives: `resolveWorkspace`, `stateDir`, `freePort`, `serveHttp`, `invalidationLoop`, `callLog`, plus `framing` readers/writers (contentLength, jsonLine, tsserverMixed) and a `jsonRpcClient` correlation helper
15+
- `bin/tool-server-proxy.js` — external-child coordinator; adapters declare `children[]`, `init`, `onChildMessage`, `call`, `triggers`
16+
- `bin/node-formatter-daemon.js` — in-process Node-library coordinator (sibling of tool-server-proxy); adapters declare `preload(workspace)` + `call(req, {pkg, state})`
17+
- `bin/adapters/lsp-stdio.js` — LSP adapter (py/ts/cs/java); extracted from monolithic `lsp-stdio-proxy.js`
18+
- `bin/adapters/vue-hybrid.js` — Vue LS v3 + tsserver hybrid adapter; extracted from `vue-direct-coordinator.js`
19+
- `bin/adapters/sbt-oneshot.js` + `bin/sbt-direct` + `bin/sbt-direct-coordinator.js` + `fixtures/scala-sbt/` — per-call sbt coordinator (`task`, `reload`, `version`)
20+
- `bin/adapters/dotnet-cli.js` + `bin/dotnet-direct` + `bin/dotnet-direct-coordinator.js` + `fixtures/dotnet-csproj/` — per-call dotnet coordinator (11 methods: build/test/restore/publish/run/pack/…); MSBuild build-server handles warm persistence transparently
21+
- `bin/adapters/prettier.js` + `bin/prettier-direct` + `bin/prettier-direct-daemon.js` + `fixtures/node-formatter/` — in-process prettier daemon (`format`, `check`, `format-file`, `resolve-config`, `version`)
22+
- `bin/adapters/eslint.js` + `bin/eslint-direct` + `bin/eslint-direct-daemon.js` — in-process eslint daemon (`lint-text`, `lint-files`, `fix-text`, `format-results`, `version`)
23+
- `bin/adapters/scalafmt-cli.js` + `bin/scalafmt-direct` + `bin/scalafmt-direct-coordinator.js` — per-call scalafmt coordinator (`format-stdin`, `format-files`, `check-files`, `version`)
24+
- `scripts/capture-baseline.sh` + `fixtures/baselines/*.json` — per-wrapper JSON baselines (cold/warm timings + response sha256 + shape summary) for 5 LSP wrappers
25+
- `scripts/verify.sh --json` and `scripts/verify.sh --diff-baselines` modes
26+
- `docs/per-language/sbt.md`, `docs/per-language/dotnet.md`, `docs/per-language/node-formatters.md`, `docs/per-language/scalafmt.md`
27+
- `MIGRATION.md` — describes the refactor, back-compat guarantees, rollback tags
28+
- `CONTRIBUTING.md` — new "Architecture overview" + rewritten "Hybrid servers" + "Non-LSP tools" sections covering the adapter contract for both module families
29+
30+
### Changed
31+
- `bin/lsp-stdio-proxy.js` body replaced with composition of `tool-harness` + `tool-server-proxy` + `adapters/lsp-stdio`. Steady-state response shape + state-dir layout byte-identical; CLI unchanged. External Node importers keep working — the file name and argv contract are preserved.
32+
- `bin/vue-direct-coordinator.js` body similarly replaced, now composing `tool-harness` + `tool-server-proxy` + `adapters/vue-hybrid`. Vue LS v3 + tsserver bridging preserved verbatim (configurePlugin → warmup → init order, tsserver/request↔response tuple unwrap + double-wrap).
33+
- `bin/py-direct`, `bin/ts-direct`, `bin/cs-direct`, `bin/java-direct` now pass `--tool-name <wrapper>` so the harness's `stateDir` resolves to the wrapper's existing slot instead of drifting to `~/.cache/lsp-stdio-proxy-direct/…`.
34+
- `scripts/install.sh` symlinks the new shared modules + adapters dir + 5 opt-in wrappers + their coordinators. Merges new permission entries + sandbox-write allowlist entries for the new cache dirs.
35+
- `README.md` lists the new opt-in wrappers; architecture section restructured (layout vs behavior) to describe the three-module split.
36+
37+
### Fixed
38+
- Prettier/eslint adapter preload now consults `npm root -g` so globally-installed packages are picked up when the workspace has no local install.
39+
- Stale-config bugs on existing LSP wrappers: touching `tsconfig.json`/`pyrightconfig.json`/`*.csproj`/`pom.xml`/`package.json` triggers `workspace/didChangeConfiguration` + `workspace/didChangeWatchedFiles` without a stop/start cycle. Hard-trigger files (`.env`, `.jvmopts`, `global.json`, `pnpm-lock.yaml`, `.python-version`, `.java-version`, `dotnet-tools.json`) force coordinator restart on next call.
40+
41+
### Added (observability)
42+
- `<stateDir>/calls.log` — per-call JSON lines: `{ts, method, ms, adopted, invalidation_fired, outcome}`. Disable via `TOOL_DIRECT_CALLLOG=0`.
43+
- `<stateDir>/triggers.json` — mtime baseline for invalidation.
44+
45+
### Verified
46+
- `scripts/verify.sh --diff-baselines` clean on py/ts/cs/java/vue (response-body sha + shape match pre-refactor baselines; timings within machine noise).
47+
- Invalidation smoke on all 5 LSP wrappers: soft trigger preserves PID, hard trigger restarts coordinator.
48+
- `dotnet-direct call version {}` returns `{exit: 0, stdout: "10.0.103\n"}`.
49+
- `prettier-direct call format-file {...}` returns `{formatted, changed}`; `eslint-direct call version {}` returns `{version: "10.2.1"}`.
50+
- `sbt-direct call version {}` reads a real Play 3 / Scala 3 project's `build.sbt``{exit: 0, stdout: "sbt version in this project: 1.11.6\nsbt runner version: 1.10.11"}`.
51+
- `sbt-direct call task {"task":"scalafmtCheckAll"}` against the same project runs the sbt-scalafmt plugin end-to-end, correctly surfaces per-file formatting diagnostics. Under Claude Bash sandbox, `install.sh` pre-allows `/private/var/folders/**/.sbt/**` + `~/.sbt/**` + `~/.ivy2/**` + `~/.coursier/**` so sbt's BootServerSocket + dependency caches write without `dangerouslyDisableSandbox`.
52+
- `scalafmt-direct call check-files {...}` against a version-matched fixture → `{exit: 0, stdout: "All files are formatted with scalafmt :)"}`.
53+
- `scalafmt-direct call format-stdin {source: "object A{def x=1}", filepath: "A.scala"}``{exit: 0, stdout: "object A { def x = 1 }\n"}`.
54+
- `dotnet-direct call version {}``{exit: 0, stdout: "10.0.103\n"}`.
55+
- `prettier-direct call format-file {filepath}``{formatted, changed}`.
56+
- `eslint-direct call version {}``{version: "10.2.1"}`.
57+
- `hooks/tests/run.sh` → 97/97 pass.
58+
- 19/19 harness + proxy smoke tests (`node --test bin/*.test.js`).
59+
760
## [1.1.0] — 2026-04-21
861

962
### Added
@@ -38,5 +91,6 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [S
3891
- `fixtures/` — minimal sample projects for CI + local verification
3992
- GitHub Actions CI on macOS + Ubuntu
4093

94+
[1.2.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.2.0
4195
[1.1.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.1.0
4296
[1.0.0]: https://github.com/CHANGE-ME/claude-lsp-direct/releases/tag/v1.0.0

CONTRIBUTING.md

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Contributing
22

3+
## Architecture overview
4+
5+
The coordinator is split into three modules sharing one harness:
6+
7+
- `bin/tool-harness.js` — six primitives: `resolveWorkspace`,
8+
`stateDir`, `serveHttp`, `invalidationLoop`, `callLog`, plus
9+
framing readers/writers (contentLength, jsonLine, tsserverMixed).
10+
- `bin/tool-server-proxy.js` — coordinator for tools with an external
11+
child process (LSPs, build tools). Adapters declare `children[]`
12+
+ `init` + `onChildMessage` + `call` + `triggers`.
13+
- `bin/node-formatter-daemon.js` — coordinator for in-process Node
14+
libraries (prettier, eslint). Adapters declare `preload(workspace)`
15+
→ pkg + `call(req, {pkg, state})`.
16+
17+
See `docs/architecture.md` for the full adapter contract.
18+
319
## Adding a new language
420

521
Minimal steps for a standard stdio LSP (server speaks LSP over `--stdio`, no hybrid coordination):
@@ -53,11 +69,46 @@ Add the new language to the primary-path table.
5369

5470
## Hybrid servers (require paired processes)
5571

56-
If the target LSP requires a paired companion process (like Vue LS v3 + tsserver + `@vue/typescript-plugin`), the generic `lsp-stdio-proxy.js` isn't enough. Write a dedicated coordinator modeled on `bin/vue-direct-coordinator.js`:
57-
- Spawn both children
58-
- Bridge any custom cross-server notifications
59-
- Expose the same HTTP surface (`POST /lsp`, `GET /health`)
60-
- Wire the bash wrapper to point at your new coordinator instead of `lsp-stdio-proxy.js`
72+
If the target LSP requires a paired companion process (like Vue LS v3 + tsserver + `@vue/typescript-plugin`), the generic `lsp-stdio-proxy.js` isn't enough. Write a dedicated adapter in `bin/adapters/<name>.js` declaring two child specs:
73+
74+
```js
75+
spawn(workspace) {
76+
return [
77+
{ id: 'main', frame: 'contentLength', cmd: 'main-ls', args: ['--stdio'] },
78+
{ id: 'helper', frame: 'tsserverMixed', cmd: 'node', args: [...] },
79+
];
80+
}
81+
```
82+
83+
Adapter `onChildMessage(childId, msg, ctx)` handles cross-child routing;
84+
`ctx.state` stores bridge tables. See `bin/adapters/vue-hybrid.js` for
85+
the Vue LS v3 + tsserver bridging pattern.
86+
87+
Compose into `bin/<name>-direct-coordinator.js` (3-line shim):
88+
89+
```js
90+
const { createProxy } = require('./tool-server-proxy.js');
91+
const { createAdapter } = require('./adapters/<name>.js');
92+
createProxy({ adapter: createAdapter(), workspace, port, toolName });
93+
```
94+
95+
## Non-LSP tools (build tools, formatters)
96+
97+
The same harness accepts non-LSP tools. Two patterns:
98+
99+
### External-subprocess adapter (JVM CLIs, compilers)
100+
Use `tool-server-proxy.js`. Adapter spawns a keepalive child (e.g.
101+
`node -e 'setInterval(...)'`) so the harness's child-exit +
102+
health-probe wiring works; each `call` runs the target CLI via
103+
`child_process.spawn` and returns the `{exit, signal, stdout,
104+
stderr}` quad. See `bin/adapters/sbt-oneshot.js`, `dotnet-cli.js`,
105+
`scalafmt-cli.js`.
106+
107+
### In-process Node library adapter (prettier, eslint)
108+
Use `node-formatter-daemon.js`. Adapter implements `preload(workspace)`
109+
→ pkg + `call(req, {pkg, state})`. Prefer workspace-local resolution
110+
via `require.resolve(pkg, {paths: [workspace]})`, fall back to global.
111+
See `bin/adapters/prettier.js`, `bin/adapters/eslint.js`.
61112

62113
## Invariants
63114

0 commit comments

Comments
 (0)