Skip to content

Commit ec36764

Browse files
miknclaude
andcommitted
feat: live tsserver resolution hook — TypeScript's GOPACKAGESDRIVER
tsserver-hook.js patches ts.resolveModuleName to resolve modules from Bazel's build graph, eliminating IDE red squiggles without manual refresh commands. Architecture: - Main hook (--require script) patches ts.resolveModuleName - Worker thread runs bazel query + scans npm external repo in background - Resolution cache updated on BUILD/lockfile/bazel-bin changes via fs.watch - Layered: .d.ts from bazel-bin (post-build) → .ts source (pre-build) - Path aliases read from # gazelle:ts_path_alias directives in BUILD files - Zero npm dependencies, graceful degradation if Bazel unavailable Usage (any editor with tsserver): node --require .bazel/tsserver-hook.js VS Code: "typescript.tsserver.nodeOptions": "--require .bazel/tsserver-hook.js" refresh_tsconfig now copies hook files to .bazel/ and generates VS Code settings template. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b65a306 commit ec36764

6 files changed

Lines changed: 1173 additions & 2 deletions

File tree

tests/lsp/BUILD.bazel

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Bazel unit tests for the tsserver resolution hook.
2+
3+
These tests exercise the hook's worker and resolution logic directly without
4+
starting a full tsserver process:
5+
6+
:test_resolution_map — Run the worker against the current workspace,
7+
assert that npm packages (zod, vitest) and internal packages appear
8+
in the resolution map with valid .d.ts paths.
9+
10+
Prerequisites for the resolution-map test to pass:
11+
bazel build @npm//... (fetches npm packages into the output base)
12+
bazel build //... (builds .d.ts files into bazel-bin)
13+
"""
14+
15+
load("@rules_shell//shell:sh_test.bzl", "sh_test")
16+
17+
sh_test(
18+
name = "test_resolution_map",
19+
size = "medium",
20+
srcs = ["test_resolution_map.sh"],
21+
data = [
22+
# MODULE.bazel is included so the test can resolve MODULE.bazel as a
23+
# symlink in the runfiles tree to locate the real workspace root.
24+
# (The same pattern used by //tests/integration:*_test runners.)
25+
"//:MODULE.bazel",
26+
"//tools:tsserver-hook.js",
27+
"//tools:tsserver-hook-worker.js",
28+
],
29+
tags = [
30+
# Requires network-accessible Bazel output base and npm packages.
31+
"requires-network",
32+
# The test shells out to `bazel query` which is not hermetic.
33+
"no-sandbox",
34+
],
35+
timeout = "long",
36+
)

tests/lsp/test_resolution_map.sh

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
#!/usr/bin/env bash
2+
# test_resolution_map.sh — Verify the tsserver-hook worker builds a correct
3+
# resolution map from the rules_typescript workspace.
4+
#
5+
# Usage (direct):
6+
# bash tests/lsp/test_resolution_map.sh
7+
#
8+
# Usage (via Bazel):
9+
# bazel test //tests/lsp:test_resolution_map --test_output=all
10+
#
11+
# What it tests:
12+
# 1. The hook script loads without errors.
13+
# 2. The worker builds a resolution map that contains npm packages (zod,
14+
# vitest) pointing at real .d.ts files in the Bazel output base.
15+
# 3. At least one resolution entry points at an existing file on disk.
16+
#
17+
# Prerequisites for the npm-package checks to pass:
18+
# bazel build @npm//... (or bazel build //...) must have run at least
19+
# once to populate the @npm external repo in the Bazel output base.
20+
#
21+
# Exit code: 0 = all assertions passed, non-zero = failure.
22+
23+
set -euo pipefail
24+
25+
pass() { echo "PASS: $*"; }
26+
fail() { echo "FAIL: $*" >&2; exit 1; }
27+
28+
# ── Locate files ───────────────────────────────────────────────────────────────
29+
# When run via `bazel test`, the runfiles tree is set up and the DATA deps
30+
# (//tools:tsserver-hook.js, //tools:tsserver-hook-worker.js) are accessible
31+
# via the standard runfiles layout under $RUNFILES_DIR or $TEST_SRCDIR.
32+
#
33+
# When run directly (bash tests/lsp/test_resolution_map.sh from workspace root),
34+
# we use relative paths.
35+
36+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
37+
38+
# Portable realpath (macOS lacks readlink -f).
39+
_realpath() {
40+
python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$1"
41+
}
42+
43+
# Detect Bazel test environment.
44+
if [[ -n "${TEST_SRCDIR:-}" ]]; then
45+
# Bazel test: tools/ is under the _main runfiles repo.
46+
_RUNFILES_MAIN="${TEST_SRCDIR}/_main"
47+
[[ -d "${_RUNFILES_MAIN}" ]] || _RUNFILES_MAIN="${TEST_SRCDIR}"
48+
TOOLS_DIR="${_RUNFILES_MAIN}/tools"
49+
50+
# Derive the real workspace root from the MODULE.bazel symlink.
51+
# The test has `data = ["//:MODULE.bazel"]` which causes MODULE.bazel to
52+
# appear as a symlink in the runfiles tree pointing at the real source file.
53+
_MODULE_SYMLINK="${_RUNFILES_MAIN}/MODULE.bazel"
54+
if [[ -L "${_MODULE_SYMLINK}" ]]; then
55+
WORKSPACE_ROOT="$(dirname "$(_realpath "${_MODULE_SYMLINK}")")"
56+
elif [[ -f "${_MODULE_SYMLINK}" ]]; then
57+
WORKSPACE_ROOT="$(_realpath "$(dirname "${_MODULE_SYMLINK}")")"
58+
else
59+
# Fallback: assume workspace root is the real rules_typescript checkout,
60+
# which we derive by resolving the SCRIPT_DIR symlink chain.
61+
WORKSPACE_ROOT="$(dirname "$(dirname "$(_realpath "${SCRIPT_DIR}")")")"
62+
fi
63+
else
64+
# Direct invocation: tools/ is two directories up from this script.
65+
WORKSPACE_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
66+
TOOLS_DIR="${WORKSPACE_ROOT}/tools"
67+
fi
68+
69+
[[ -f "${TOOLS_DIR}/tsserver-hook.js" ]] || \
70+
fail "tsserver-hook.js not found at ${TOOLS_DIR}/tsserver-hook.js"
71+
[[ -f "${TOOLS_DIR}/tsserver-hook-worker.js" ]] || \
72+
fail "tsserver-hook-worker.js not found at ${TOOLS_DIR}/tsserver-hook-worker.js"
73+
74+
# Derive the Bazel output base so the worker can locate the @npm external repo
75+
# without needing to run `bazel info output_base` (which is blocked by the
76+
# outer `bazel test` server lock).
77+
#
78+
# Strategy:
79+
# 1. If running inside a Bazel test, derive the output base from the
80+
# runfiles directory path (which is always under
81+
# <output_base>/execroot/_main/bazel-out/.../bin/...).
82+
# 2. Fall back to `bazel info output_base` for direct invocations.
83+
BAZEL_OUTPUT_BASE=""
84+
85+
if [[ -n "${TEST_SRCDIR:-}" ]]; then
86+
# Inside Bazel test: TEST_SRCDIR is something like
87+
# <output_base>/execroot/_main/bazel-out/.../bin/<target>.runfiles
88+
# Extract output_base by stripping everything from /execroot/ onward.
89+
if [[ "${TEST_SRCDIR}" == */execroot/* ]]; then
90+
BAZEL_OUTPUT_BASE="${TEST_SRCDIR%%/execroot/*}"
91+
fi
92+
fi
93+
94+
if [[ -z "${BAZEL_OUTPUT_BASE}" ]]; then
95+
# Direct invocation or fallback: ask Bazel.
96+
BAZEL_OUTPUT_BASE="$(bazel info output_base 2>/dev/null || true)"
97+
fi
98+
99+
echo "INFO: workspace_root = ${WORKSPACE_ROOT}"
100+
echo "INFO: tools_dir = ${TOOLS_DIR}"
101+
echo "INFO: bazel_output_base = ${BAZEL_OUTPUT_BASE:-<not found>}"
102+
103+
pass "hook files exist"
104+
105+
# ── Prerequisite: Node.js available ───────────────────────────────────────────
106+
command -v node >/dev/null 2>&1 || fail "node not found on PATH"
107+
NODE_VERSION=$(node --version)
108+
echo "INFO: node ${NODE_VERSION}"
109+
110+
# ── Test 1: Hook script loads without errors ──────────────────────────────────
111+
echo "INFO: testing that hook loads without errors..."
112+
node --require "${TOOLS_DIR}/tsserver-hook.js" --eval "process.exit(0)"
113+
pass "hook loads without errors"
114+
115+
# ── Test 2: Run worker and capture resolution map ─────────────────────────────
116+
echo "INFO: running worker to build resolution map (may take up to 60 s)..."
117+
118+
RESOLUTION_MAP_FILE="$(mktemp -t resolution_map.XXXXXX.json)"
119+
WORKER_SCRIPT="$(mktemp -t tsserver_hook_test.XXXXXX.js)"
120+
cleanup() {
121+
rm -f "${RESOLUTION_MAP_FILE}" "${WORKER_SCRIPT}"
122+
}
123+
trap cleanup EXIT
124+
125+
# Write the worker-runner as a temp file to avoid bash/JS interpolation issues.
126+
cat > "${WORKER_SCRIPT}" << WORKER_EOF
127+
'use strict';
128+
const { Worker } = require('worker_threads');
129+
const path = require('path');
130+
const fs = require('fs');
131+
132+
const workerPath = path.resolve(process.argv[2]);
133+
const workspaceRoot = process.argv[3];
134+
const outputFile = process.argv[4];
135+
const outputBase = process.argv[5] || ''; // optional, passed when Bazel lock is held
136+
137+
const workerData = { workspaceRoot };
138+
if (outputBase) workerData.outputBase = outputBase;
139+
140+
const w = new Worker(workerPath, { workerData });
141+
142+
const timeout = setTimeout(() => {
143+
w.terminate();
144+
process.stderr.write('FAIL: worker did not send resolution map within 60 s\\n');
145+
process.exit(1);
146+
}, 60000);
147+
148+
w.on('message', (msg) => {
149+
clearTimeout(timeout);
150+
w.terminate();
151+
if (msg.type !== 'resolution-map') {
152+
process.stderr.write('FAIL: unexpected message type: ' + msg.type + '\\n');
153+
process.exit(1);
154+
}
155+
fs.writeFileSync(outputFile, JSON.stringify(msg.data, null, 2));
156+
process.exit(0);
157+
});
158+
159+
w.on('error', (err) => {
160+
clearTimeout(timeout);
161+
process.stderr.write('FAIL: worker error: ' + err.message + '\\n');
162+
process.exit(1);
163+
});
164+
WORKER_EOF
165+
166+
node "${WORKER_SCRIPT}" \
167+
"${TOOLS_DIR}/tsserver-hook-worker.js" \
168+
"${WORKSPACE_ROOT}" \
169+
"${RESOLUTION_MAP_FILE}" \
170+
"${BAZEL_OUTPUT_BASE:-}"
171+
172+
[[ -s "${RESOLUTION_MAP_FILE}" ]] || fail "resolution map file is empty"
173+
pass "worker produced a resolution map"
174+
175+
echo "INFO: resolution map written to ${RESOLUTION_MAP_FILE}"
176+
177+
# ── Test 3: Check npm packages are present ─────────────────────────────────────
178+
echo "INFO: checking npm packages in resolution map..."
179+
180+
python3 - "${RESOLUTION_MAP_FILE}" << 'PYEOF'
181+
import json
182+
import os
183+
import sys
184+
185+
map_path = sys.argv[1]
186+
187+
with open(map_path) as f:
188+
data = json.load(f)
189+
190+
errors = []
191+
192+
# zod and vitest are in the rules_typescript test lockfile.
193+
# They must be present after `bazel build @npm//...`.
194+
REQUIRED_PACKAGES = ["zod", "vitest"]
195+
196+
for pkg in REQUIRED_PACKAGES:
197+
if pkg not in data:
198+
errors.append("'{}' not in resolution map".format(pkg))
199+
else:
200+
dts_path = data[pkg]
201+
if not os.path.exists(dts_path):
202+
errors.append("'{}' path does not exist: {!r}".format(pkg, dts_path))
203+
elif not (dts_path.endswith('.d.ts') or
204+
dts_path.endswith('.d.mts') or
205+
dts_path.endswith('.d.cts')):
206+
errors.append("'{}' path is not a .d.ts file: {!r}".format(pkg, dts_path))
207+
else:
208+
print("PASS: '{}' -> {!r}".format(pkg, dts_path))
209+
210+
module_entries = [k for k in data.keys() if not k.startswith('__alias__')]
211+
alias_entries = [k for k in data.keys() if k.startswith('__alias__')]
212+
print("INFO: {} module entries, {} alias entries".format(
213+
len(module_entries), len(alias_entries)))
214+
215+
if errors:
216+
for e in errors:
217+
print("FAIL: {}".format(e), file=sys.stderr)
218+
sys.exit(1)
219+
220+
sys.exit(0)
221+
PYEOF
222+
pass "npm packages present in resolution map"
223+
224+
# ── Test 4: At least one module points at a real .d.ts ────────────────────────
225+
echo "INFO: verifying .d.ts paths exist on disk..."
226+
227+
python3 - "${RESOLUTION_MAP_FILE}" << 'PYEOF'
228+
import json
229+
import os
230+
import sys
231+
232+
with open(sys.argv[1]) as f:
233+
data = json.load(f)
234+
235+
valid = 0
236+
invalid = 0
237+
for k, v in data.items():
238+
if k.startswith('__alias__'):
239+
continue
240+
if os.path.exists(v):
241+
valid += 1
242+
else:
243+
invalid += 1
244+
245+
print("INFO: {} paths exist, {} paths missing (may be unbuilt packages)".format(
246+
valid, invalid))
247+
248+
if valid == 0:
249+
print("FAIL: no resolution map entries point to existing files", file=sys.stderr)
250+
sys.exit(1)
251+
252+
sys.exit(0)
253+
PYEOF
254+
pass "at least one resolution entry points to an existing file"
255+
256+
# ── Summary ────────────────────────────────────────────────────────────────────
257+
echo ""
258+
echo "ALL PASSED"

tools/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ exports_files([
77
"add_package_wrapper.sh",
88
"pnpm_wrapper.sh",
99
"refresh_tsconfig.sh",
10+
"tsserver-hook.js",
11+
"tsserver-hook-worker.js",
1012
])

0 commit comments

Comments
 (0)