@pandi-coding-agent/typescript-lsp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/diagnostics.ts +262 -0
- package/index.ts +527 -0
- package/messages.ts +23 -0
- package/package.json +34 -0
- package/settings.ts +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# @pandi-coding-agent/typescript-lsp
|
|
2
|
+
|
|
3
|
+
TypeScript **diagnostics feedback** from inside a Pi session. After the agent
|
|
4
|
+
finishes a turn that wrote or edited TypeScript, this extension runs
|
|
5
|
+
`tsc --noEmit` on the relevant project(s), keeps only the **files the turn
|
|
6
|
+
actually touched**, and surfaces a bounded top-N report.
|
|
7
|
+
|
|
8
|
+
> **Not a full LSP.** The package name says "lsp", but the real contract is
|
|
9
|
+
> diagnostics only — there is **no hover, no go-to-definition, no completions**.
|
|
10
|
+
> Think of it as "did my TypeScript edits still compile?" feedback.
|
|
11
|
+
|
|
12
|
+
## Why the coherent edge?
|
|
13
|
+
|
|
14
|
+
Diagnostics fire on **`agent_end`** — the coherent edge, after the whole turn
|
|
15
|
+
finishes — not after every individual write/edit. Mid-turn a file is often
|
|
16
|
+
half-edited and would report transient, misleading errors. Checking once at the
|
|
17
|
+
edge, scoped to touched files, gives honest signal with minimal noise. It is
|
|
18
|
+
**non-blocking**: a tool call is never blocked, and a missing tsconfig/tsc is a
|
|
19
|
+
quiet NO-OP (one advisory warning), never a broken session.
|
|
20
|
+
|
|
21
|
+
## Surfaces
|
|
22
|
+
|
|
23
|
+
- **Automatic feedback** on `agent_end` (advisory by default).
|
|
24
|
+
- **`typescript_diagnostics`** — a model-callable tool (pull / on-demand).
|
|
25
|
+
- **`/tsc`** — a human slash command.
|
|
26
|
+
|
|
27
|
+
`tsc` is always spawned with an **argv array (never a shell string)**, exactly as
|
|
28
|
+
`pi-worktree` spawns `git`, so paths cannot inject shell commands.
|
|
29
|
+
|
|
30
|
+
## Feedback modes
|
|
31
|
+
|
|
32
|
+
- **advisory** (default): if touched files have type errors, the report is sent
|
|
33
|
+
as a non-blocking message delivered on the next turn
|
|
34
|
+
(`sendMessage({ deliverAs: "nextTurn" })`). Identical reports are de-duplicated
|
|
35
|
+
so the same errors are not re-injected turn after turn.
|
|
36
|
+
- **autofix** (opt-in): the report is delivered as a follow-up that triggers a
|
|
37
|
+
turn (`deliverAs: "followUp", triggerTurn: true`) so the agent fixes the errors
|
|
38
|
+
immediately. There is a **per-prompt budget** (default 1 auto-triggered fix)
|
|
39
|
+
plus the same de-duplication, so it can never loop. It **never** blocks.
|
|
40
|
+
|
|
41
|
+
## Tool
|
|
42
|
+
|
|
43
|
+
`typescript_diagnostics` takes an optional `scope`:
|
|
44
|
+
|
|
45
|
+
- `touched` (default): only the files edited so far this turn.
|
|
46
|
+
- `project`: the whole `<cwd>/tsconfig.json`.
|
|
47
|
+
|
|
48
|
+
It returns a text summary plus structured `details`
|
|
49
|
+
(`{ scope, hasErrors, count, diagnostics }`).
|
|
50
|
+
|
|
51
|
+
## Command
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
/tsc show status
|
|
55
|
+
/tsc status show status
|
|
56
|
+
/tsc on | off enable / disable automatic feedback
|
|
57
|
+
/tsc run run a check now and report
|
|
58
|
+
/tsc scope touched|project set the default scope
|
|
59
|
+
/tsc autofix on|off switch advisory ↔ autofix
|
|
60
|
+
/tsc max <n> cap how many diagnostics are surfaced
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## tsc resolution
|
|
64
|
+
|
|
65
|
+
In order:
|
|
66
|
+
|
|
67
|
+
1. `PI_TS_LSP_TSC` — absolute path to a `tsc.js`, executed with the current
|
|
68
|
+
`node`.
|
|
69
|
+
2. The nearest `node_modules/typescript/bin/tsc`, walking up from the tsconfig
|
|
70
|
+
directory.
|
|
71
|
+
3. Fallback: `npx tsc`.
|
|
72
|
+
|
|
73
|
+
If neither a tsconfig nor a tsc can be found, the extension is a NO-OP with a
|
|
74
|
+
single advisory warning.
|
|
75
|
+
|
|
76
|
+
## Configuration (env)
|
|
77
|
+
|
|
78
|
+
| Variable | Meaning | Default |
|
|
79
|
+
| ----------------- | ---------------------------------------- | ---------- |
|
|
80
|
+
| `PI_TS_LSP` | `on` / `off` — enable the extension | `on` |
|
|
81
|
+
| `PI_TS_LSP_MODE` | `advisory` / `autofix` | `advisory` |
|
|
82
|
+
| `PI_TS_LSP_MAX` | max diagnostics surfaced (positive int) | `20` |
|
|
83
|
+
| `PI_TS_LSP_AUTOFIX` | `on` / `off` — opt into autofix turns | `off` |
|
|
84
|
+
| `PI_TS_LSP_TSC` | absolute path to a `tsc.js` to run | (auto) |
|
|
85
|
+
|
|
86
|
+
## Install
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
pi install ./extensions/pi-typescript-lsp
|
|
90
|
+
# or, to try without installing:
|
|
91
|
+
pi --no-extensions -e ./extensions/pi-typescript-lsp
|
|
92
|
+
```
|
package/diagnostics.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-typescript-lsp helpers: pure, UI-free logic for turning a `tsc --noEmit`
|
|
3
|
+
* run into a bounded, touched-file diagnostics report.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is deliberately free of pi's ExtensionContext / UI so it can be
|
|
6
|
+
* unit-tested in isolation against the same bundle the extension ships. The only
|
|
7
|
+
* side effects are filesystem reads (tsconfig / tsc discovery, realpath
|
|
8
|
+
* canonicalization) — never a spawn. Spawning `tsc` (with an ARGV array, never a
|
|
9
|
+
* shell string) lives in index.ts, mirroring how pi-worktree keeps `runGit`
|
|
10
|
+
* beside its pure helpers.
|
|
11
|
+
*
|
|
12
|
+
* Contract note: this is NOT a full Language Server. There is no hover, no
|
|
13
|
+
* go-to-definition, no completions. The single contract is *diagnostics
|
|
14
|
+
* feedback*: parse `tsc` output, keep only the files the turn actually touched,
|
|
15
|
+
* and surface a top-N summary.
|
|
16
|
+
*
|
|
17
|
+
* Depth-one sibling module imported by index.ts via "./diagnostics.js".
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
|
|
23
|
+
/** Default wall-clock budget for a single `tsc` invocation. */
|
|
24
|
+
export const DEFAULT_TSC_TIMEOUT_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
/** Default cap on how many diagnostics are surfaced in one report. */
|
|
27
|
+
export const DEFAULT_MAX_ERRORS = 20;
|
|
28
|
+
|
|
29
|
+
/** A single parsed `tsc` diagnostic. */
|
|
30
|
+
export interface Diagnostic {
|
|
31
|
+
/** File path exactly as tsc emitted it (may be relative to tsc's cwd). */
|
|
32
|
+
file: string;
|
|
33
|
+
line: number;
|
|
34
|
+
col: number;
|
|
35
|
+
/** TypeScript error code, e.g. "TS2322". */
|
|
36
|
+
code: string;
|
|
37
|
+
severity: "error" | "warning";
|
|
38
|
+
message: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Result of a single `tsc` spawn (returned by index.ts's runner). */
|
|
42
|
+
export interface TscRunResult {
|
|
43
|
+
/** true when tsc exited 0 and was neither aborted nor timed out. */
|
|
44
|
+
ok: boolean;
|
|
45
|
+
exitCode: number | null;
|
|
46
|
+
stdout: string;
|
|
47
|
+
stderr: string;
|
|
48
|
+
signal: NodeJS.Signals | null;
|
|
49
|
+
timedOut: boolean;
|
|
50
|
+
/** set when we never managed to spawn tsc at all. */
|
|
51
|
+
spawnError?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How `tsc` should be invoked (command + leading args before the tsc flags). */
|
|
55
|
+
export interface TscCommand {
|
|
56
|
+
/** Executable to spawn (node for env/local tsc.js, "npx" for the fallback). */
|
|
57
|
+
command: string;
|
|
58
|
+
/** Leading args (the tsc.js path for node, or ["tsc"] for npx). */
|
|
59
|
+
args: string[];
|
|
60
|
+
/** Which resolution branch produced this command (for diagnostics/tests). */
|
|
61
|
+
kind: "env" | "local" | "npx";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A TypeScript source file we care about: .ts/.tsx/.mts/.cts but NOT a .d.ts
|
|
66
|
+
* declaration file (editing a .d.ts is rare and re-checking it adds noise).
|
|
67
|
+
*/
|
|
68
|
+
export function isTsFile(filePath: string): boolean {
|
|
69
|
+
if (!filePath) return false;
|
|
70
|
+
const lower = filePath.toLowerCase();
|
|
71
|
+
if (lower.endsWith(".d.ts")) return false;
|
|
72
|
+
return /\.(ts|tsx|mts|cts)$/.test(lower);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse `tsc --pretty false` output into structured diagnostics.
|
|
77
|
+
*
|
|
78
|
+
* Each diagnostic is a line of the form:
|
|
79
|
+
* `path/to/file.ts(line,col): error TSxxxx: message`
|
|
80
|
+
* Handles CRLF, and folds INDENTED continuation lines (tsc wraps long messages)
|
|
81
|
+
* into the preceding diagnostic's message. Non-matching, non-indented lines (e.g.
|
|
82
|
+
* a trailing "Found N errors." summary) are ignored.
|
|
83
|
+
*/
|
|
84
|
+
export function parseTscDiagnostics(stdout: string): Diagnostic[] {
|
|
85
|
+
const diags: Diagnostic[] = [];
|
|
86
|
+
if (!stdout) return diags;
|
|
87
|
+
const re = /^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.*)$/;
|
|
88
|
+
let current: Diagnostic | null = null;
|
|
89
|
+
for (const rawLine of stdout.split("\n")) {
|
|
90
|
+
const line = rawLine.replace(/\r$/, "");
|
|
91
|
+
const match = re.exec(line);
|
|
92
|
+
if (match) {
|
|
93
|
+
if (current) diags.push(current);
|
|
94
|
+
current = {
|
|
95
|
+
file: match[1],
|
|
96
|
+
line: Number(match[2]),
|
|
97
|
+
col: Number(match[3]),
|
|
98
|
+
severity: match[4] as "error" | "warning",
|
|
99
|
+
code: match[5],
|
|
100
|
+
message: match[6],
|
|
101
|
+
};
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Indented, non-empty line that is not a new diagnostic → message continuation.
|
|
105
|
+
if (current && /^\s+\S/.test(line)) {
|
|
106
|
+
current.message += `\n${line.trim()}`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (current) diags.push(current);
|
|
110
|
+
return diags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True when `dir` is `root` or a descendant of `root`. */
|
|
114
|
+
function isWithinOrEqual(root: string, dir: string): boolean {
|
|
115
|
+
if (dir === root) return true;
|
|
116
|
+
const rel = path.relative(root, dir);
|
|
117
|
+
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Find the nearest tsconfig.json by walking UP from `file`'s directory, stopping
|
|
122
|
+
* at `cwd` (inclusive). Falls back to `<cwd>/tsconfig.json` (whether or not it
|
|
123
|
+
* exists) so callers always get a stable path to gate on with existsSync.
|
|
124
|
+
*/
|
|
125
|
+
export function findNearestTsconfig(file: string, cwd: string): string {
|
|
126
|
+
const root = path.resolve(cwd);
|
|
127
|
+
const fallback = path.join(root, "tsconfig.json");
|
|
128
|
+
let dir = path.dirname(path.resolve(file));
|
|
129
|
+
if (!isWithinOrEqual(root, dir)) return fallback;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const candidate = path.join(dir, "tsconfig.json");
|
|
132
|
+
if (existsSync(candidate)) return candidate;
|
|
133
|
+
if (dir === root) break;
|
|
134
|
+
const parent = path.dirname(dir);
|
|
135
|
+
if (parent === dir) break;
|
|
136
|
+
dir = parent;
|
|
137
|
+
}
|
|
138
|
+
return fallback;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Build the tsc flag array for a project check. Pure; touches nothing. */
|
|
142
|
+
export function buildTscArgs(tsconfigPath: string): string[] {
|
|
143
|
+
return ["--noEmit", "--pretty", "false", "-p", tsconfigPath];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve HOW to run tsc, in order:
|
|
148
|
+
* 1. env PI_TS_LSP_TSC — absolute path to tsc.js, run with the current node.
|
|
149
|
+
* 2. nearest node_modules/typescript/bin/tsc walking up from `tsconfigDir`.
|
|
150
|
+
* 3. fallback: `npx tsc`.
|
|
151
|
+
* Pure aside from existsSync probes; `env` is injectable for tests.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveTscCommand(tsconfigDir: string, env: NodeJS.ProcessEnv = process.env): TscCommand {
|
|
154
|
+
const envTsc = env.PI_TS_LSP_TSC?.trim();
|
|
155
|
+
if (envTsc) return { command: process.execPath, args: [envTsc], kind: "env" };
|
|
156
|
+
|
|
157
|
+
let dir = path.resolve(tsconfigDir);
|
|
158
|
+
for (;;) {
|
|
159
|
+
const candidate = path.join(dir, "node_modules", "typescript", "bin", "tsc");
|
|
160
|
+
if (existsSync(candidate)) return { command: process.execPath, args: [candidate], kind: "local" };
|
|
161
|
+
const parent = path.dirname(dir);
|
|
162
|
+
if (parent === dir) break;
|
|
163
|
+
dir = parent;
|
|
164
|
+
}
|
|
165
|
+
return { command: "npx", args: ["tsc"], kind: "npx" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Canonicalize a path for comparison: resolve to absolute, then follow symlinks
|
|
170
|
+
* via realpath when the path exists (so macOS /var ↔ /private/var and other
|
|
171
|
+
* symlinked temp dirs compare equal). Falls back to the resolved path otherwise.
|
|
172
|
+
*/
|
|
173
|
+
function canonicalize(filePath: string): string {
|
|
174
|
+
const abs = path.resolve(filePath);
|
|
175
|
+
try {
|
|
176
|
+
return realpathSync.native(abs);
|
|
177
|
+
} catch {
|
|
178
|
+
return abs;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stable 5-field dedupe key for a single diagnostic, given its already-canonical
|
|
184
|
+
* file path. Used by both the touched-file filter and the feedback dedupe so the
|
|
185
|
+
* key string stays identical in both places.
|
|
186
|
+
*/
|
|
187
|
+
function diagKey(canonicalFile: string, d: Diagnostic): string {
|
|
188
|
+
return `${canonicalFile}:${d.line}:${d.col}:${d.code}:${d.message}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Keep only diagnostics whose file is one of `touchedAbsPaths`, normalizing both
|
|
193
|
+
* sides (realpath-aware) so symlinked temp dirs match, and de-duplicating
|
|
194
|
+
* identical diagnostics. Returned diagnostics carry the canonical absolute path.
|
|
195
|
+
*/
|
|
196
|
+
export function filterToTouched(diags: Diagnostic[], touchedAbsPaths: string[]): Diagnostic[] {
|
|
197
|
+
const touched = new Set(touchedAbsPaths.map(canonicalize));
|
|
198
|
+
const seen = new Set<string>();
|
|
199
|
+
const out: Diagnostic[] = [];
|
|
200
|
+
for (const diag of diags) {
|
|
201
|
+
const file = canonicalize(diag.file);
|
|
202
|
+
if (!touched.has(file)) continue;
|
|
203
|
+
const key = diagKey(file, diag);
|
|
204
|
+
if (seen.has(key)) continue;
|
|
205
|
+
seen.add(key);
|
|
206
|
+
out.push({ ...diag, file });
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Result of formatting diagnostics for display. */
|
|
212
|
+
export interface FormatResult {
|
|
213
|
+
hasErrors: boolean;
|
|
214
|
+
text: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Format diagnostics as a top-N list. Each line is
|
|
219
|
+
* `file(line,col): severity TSxxxx: message` (first line of the message only).
|
|
220
|
+
* When there are more than `maxErrors`, a trailing `(+N más)` is appended.
|
|
221
|
+
* `hasErrors` is true when any diagnostic has error severity.
|
|
222
|
+
*/
|
|
223
|
+
export function formatDiagnostics(diags: Diagnostic[], opts: { maxErrors?: number } = {}): FormatResult {
|
|
224
|
+
const maxErrors = opts.maxErrors ?? DEFAULT_MAX_ERRORS;
|
|
225
|
+
if (diags.length === 0) return { hasErrors: false, text: "" };
|
|
226
|
+
const shown = diags.slice(0, Math.max(0, maxErrors));
|
|
227
|
+
const lines = shown.map((d) => {
|
|
228
|
+
const firstLine = d.message.split("\n")[0];
|
|
229
|
+
return `${d.file}(${d.line},${d.col}): ${d.severity} ${d.code}: ${firstLine}`;
|
|
230
|
+
});
|
|
231
|
+
const extra = diags.length - shown.length;
|
|
232
|
+
let text = lines.join("\n");
|
|
233
|
+
if (extra > 0) text += `\n(+${extra} más)`;
|
|
234
|
+
return { hasErrors: diags.some((d) => d.severity === "error"), text };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Inputs to the run gate. `touched` is the COUNT of touched TS files. */
|
|
238
|
+
export interface ShouldRunState {
|
|
239
|
+
touched: number;
|
|
240
|
+
aborted: boolean;
|
|
241
|
+
idle: boolean;
|
|
242
|
+
pending: boolean;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The coherent-edge gate: run only when the turn touched TS files, was not
|
|
247
|
+
* aborted, the agent is idle, and nothing else is queued. Pure boolean logic.
|
|
248
|
+
*/
|
|
249
|
+
export function shouldRun(state: ShouldRunState): boolean {
|
|
250
|
+
return state.touched > 0 && !state.aborted && state.idle && !state.pending;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Stable, order-independent key for a set of diagnostics, used to DEDUPE feedback
|
|
255
|
+
* so identical reports are not re-injected turn after turn.
|
|
256
|
+
*/
|
|
257
|
+
export function diagnosticsKey(diags: Diagnostic[]): string {
|
|
258
|
+
return diags
|
|
259
|
+
.map((d) => diagKey(canonicalize(d.file), d))
|
|
260
|
+
.sort()
|
|
261
|
+
.join("|");
|
|
262
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-typescript-lsp: TypeScript diagnostics feedback that fires on the COHERENT
|
|
3
|
+
* EDGE (agent_end), scoped to the files the turn actually touched.
|
|
4
|
+
*
|
|
5
|
+
* This is NOT a full Language Server — there is no hover, no go-to-definition,
|
|
6
|
+
* no completions. The single contract is *diagnostics feedback*: after the agent
|
|
7
|
+
* finishes a turn that wrote/edited TypeScript, we run `tsc --noEmit` on the
|
|
8
|
+
* relevant project(s), keep only the touched files' errors, and surface a bounded
|
|
9
|
+
* top-N report. It is non-blocking by design (never `block`s a tool call).
|
|
10
|
+
*
|
|
11
|
+
* Surfaces (the project convention — see pi-worktree / pi-auto-compact):
|
|
12
|
+
* - automatic feedback on `agent_end` (advisory by default; opt-in autofix)
|
|
13
|
+
* - `typescript_diagnostics` model-callable tool (pull, on-demand)
|
|
14
|
+
* - `/tsc` human slash command (status/on/off/run/scope/…)
|
|
15
|
+
*
|
|
16
|
+
* `tsc` is always spawned with an ARGV array (never a shell string), exactly as
|
|
17
|
+
* pi-worktree spawns `git`, so paths can never inject shell commands. If neither
|
|
18
|
+
* a tsconfig nor a usable tsc can be found, the extension is a NO-OP with a
|
|
19
|
+
* single advisory warning — it never breaks the session.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { existsSync } from "node:fs";
|
|
24
|
+
import * as path from "node:path";
|
|
25
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
26
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { Type } from "typebox";
|
|
28
|
+
import {
|
|
29
|
+
buildTscArgs,
|
|
30
|
+
DEFAULT_MAX_ERRORS,
|
|
31
|
+
DEFAULT_TSC_TIMEOUT_MS,
|
|
32
|
+
type Diagnostic,
|
|
33
|
+
diagnosticsKey,
|
|
34
|
+
filterToTouched,
|
|
35
|
+
findNearestTsconfig,
|
|
36
|
+
formatDiagnostics,
|
|
37
|
+
isTsFile,
|
|
38
|
+
parseTscDiagnostics,
|
|
39
|
+
resolveTscCommand,
|
|
40
|
+
shouldRun,
|
|
41
|
+
type TscRunResult,
|
|
42
|
+
} from "./diagnostics.js";
|
|
43
|
+
import { advisoryMessage, autofixMessage } from "./messages.js";
|
|
44
|
+
import { type FeedbackMode, parseMax, parseMode, parseOnOff, parseScope, type Scope } from "./settings.js";
|
|
45
|
+
|
|
46
|
+
// Re-exported for the integration suite to unit-test the pure helpers directly
|
|
47
|
+
// against the same bundle (an `export … from` re-export creates no local binding,
|
|
48
|
+
// so there is no clash with the import above).
|
|
49
|
+
export {
|
|
50
|
+
buildTscArgs,
|
|
51
|
+
diagnosticsKey,
|
|
52
|
+
filterToTouched,
|
|
53
|
+
findNearestTsconfig,
|
|
54
|
+
formatDiagnostics,
|
|
55
|
+
isTsFile,
|
|
56
|
+
parseTscDiagnostics,
|
|
57
|
+
resolveTscCommand,
|
|
58
|
+
shouldRun,
|
|
59
|
+
} from "./diagnostics.js";
|
|
60
|
+
|
|
61
|
+
// Setting parsers live in settings.ts (mirrors the diagnostics.ts split); re-exported
|
|
62
|
+
// here so the extension's public surface stays identical.
|
|
63
|
+
export { parseMax, parseMode, parseOnOff, parseScope } from "./settings.js";
|
|
64
|
+
|
|
65
|
+
/** Custom message type owned by this extension (for dedupe/rendering). */
|
|
66
|
+
const CUSTOM_TYPE = "pi-typescript-lsp";
|
|
67
|
+
const MAX_TSC_OUTPUT_BYTES = 2_000_000;
|
|
68
|
+
/** Default autofix budget per prompt: at most one auto-triggered fix turn. */
|
|
69
|
+
const DEFAULT_AUTOFIX_BUDGET = 1;
|
|
70
|
+
|
|
71
|
+
// --------------------------------------------------------------------------
|
|
72
|
+
// tsc runner — argv array, never a shell (mirrors pi-worktree's runGit)
|
|
73
|
+
// --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
interface RunTscOptions {
|
|
76
|
+
cwd: string;
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
timeoutMs?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Run `command args…` (a resolved tsc invocation) in `cwd` and resolve with a
|
|
83
|
+
* typed result. NEVER rejects: spawn failure, non-zero exit, timeout, or abort
|
|
84
|
+
* all come back as a TscRunResult. Output is byte-bounded so a runaway tsc cannot
|
|
85
|
+
* flood memory.
|
|
86
|
+
*/
|
|
87
|
+
function runTsc(command: string, args: string[], options: RunTscOptions): Promise<TscRunResult> {
|
|
88
|
+
const { cwd, signal, timeoutMs = DEFAULT_TSC_TIMEOUT_MS } = options;
|
|
89
|
+
return new Promise<TscRunResult>((resolve) => {
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let stderr = "";
|
|
92
|
+
let stdoutBytes = 0;
|
|
93
|
+
let stderrBytes = 0;
|
|
94
|
+
let settled = false;
|
|
95
|
+
let timedOut = false;
|
|
96
|
+
|
|
97
|
+
const child = spawn(command, args, { cwd, windowsHide: true });
|
|
98
|
+
|
|
99
|
+
const finish = (result: TscRunResult): void => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
104
|
+
resolve(result);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const onAbort = (): void => {
|
|
108
|
+
try {
|
|
109
|
+
child.kill("SIGTERM");
|
|
110
|
+
} catch {
|
|
111
|
+
/* already gone */
|
|
112
|
+
}
|
|
113
|
+
finish({ ok: false, exitCode: null, stdout, stderr, signal: "SIGTERM", timedOut: false });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
timedOut = true;
|
|
118
|
+
try {
|
|
119
|
+
child.kill("SIGTERM");
|
|
120
|
+
} catch {
|
|
121
|
+
/* already gone */
|
|
122
|
+
}
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
125
|
+
|
|
126
|
+
if (signal) {
|
|
127
|
+
if (signal.aborted) {
|
|
128
|
+
onAbort();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
135
|
+
if (stdoutBytes >= MAX_TSC_OUTPUT_BYTES) return;
|
|
136
|
+
stdoutBytes += chunk.length;
|
|
137
|
+
stdout += chunk.toString("utf8");
|
|
138
|
+
if (stdoutBytes > MAX_TSC_OUTPUT_BYTES) stdout = stdout.slice(0, MAX_TSC_OUTPUT_BYTES);
|
|
139
|
+
});
|
|
140
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
141
|
+
if (stderrBytes >= MAX_TSC_OUTPUT_BYTES) return;
|
|
142
|
+
stderrBytes += chunk.length;
|
|
143
|
+
stderr += chunk.toString("utf8");
|
|
144
|
+
if (stderrBytes > MAX_TSC_OUTPUT_BYTES) stderr = stderr.slice(0, MAX_TSC_OUTPUT_BYTES);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
child.on("error", (err) => {
|
|
148
|
+
finish({
|
|
149
|
+
ok: false,
|
|
150
|
+
exitCode: null,
|
|
151
|
+
stdout,
|
|
152
|
+
stderr,
|
|
153
|
+
signal: null,
|
|
154
|
+
timedOut,
|
|
155
|
+
spawnError: err.message,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
child.on("close", (code, sig) => {
|
|
159
|
+
finish({
|
|
160
|
+
ok: code === 0 && !timedOut,
|
|
161
|
+
exitCode: code,
|
|
162
|
+
stdout,
|
|
163
|
+
stderr,
|
|
164
|
+
signal: sig,
|
|
165
|
+
timedOut,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Run tsc for a single tsconfig and return the parsed diagnostics with their file
|
|
173
|
+
* paths resolved to absolute (tsc emits paths relative to its cwd). Returns
|
|
174
|
+
* `null` only when tsc could not be spawned at all, so callers can warn once.
|
|
175
|
+
*/
|
|
176
|
+
async function checkProject(
|
|
177
|
+
tsconfigPath: string,
|
|
178
|
+
signal: AbortSignal | undefined,
|
|
179
|
+
timeoutMs: number,
|
|
180
|
+
): Promise<Diagnostic[] | null> {
|
|
181
|
+
const dir = path.dirname(tsconfigPath);
|
|
182
|
+
const cmd = resolveTscCommand(dir, process.env);
|
|
183
|
+
const args = [...cmd.args, ...buildTscArgs(tsconfigPath)];
|
|
184
|
+
const result = await runTsc(cmd.command, args, { cwd: dir, signal, timeoutMs });
|
|
185
|
+
if (result.spawnError) return null;
|
|
186
|
+
const parsed = parseTscDiagnostics(`${result.stdout}\n${result.stderr}`);
|
|
187
|
+
return parsed.map((d) => ({
|
|
188
|
+
...d,
|
|
189
|
+
file: path.isAbsolute(d.file) ? d.file : path.resolve(dir, d.file),
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// --------------------------------------------------------------------------
|
|
194
|
+
// Extension
|
|
195
|
+
// --------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export default function typescriptLspExtension(pi: ExtensionAPI): void {
|
|
198
|
+
let enabled = parseOnOff(process.env.PI_TS_LSP) ?? true;
|
|
199
|
+
let mode: FeedbackMode = parseMode(process.env.PI_TS_LSP_MODE) ?? "advisory";
|
|
200
|
+
let maxErrors = parseMax(process.env.PI_TS_LSP_MAX) ?? DEFAULT_MAX_ERRORS;
|
|
201
|
+
let autofix = parseOnOff(process.env.PI_TS_LSP_AUTOFIX) ?? false;
|
|
202
|
+
let scope: Scope = "touched";
|
|
203
|
+
|
|
204
|
+
// Per-prompt set of touched TS files (absolute). Tracked in tool_result,
|
|
205
|
+
// consumed and cleared in agent_end.
|
|
206
|
+
const touched = new Set<string>();
|
|
207
|
+
// Only one tsc check in flight at a time.
|
|
208
|
+
let running = false;
|
|
209
|
+
// Dedupe: last diagnostics key we surfaced (cleared when the project is clean).
|
|
210
|
+
let lastKey: string | undefined;
|
|
211
|
+
// Per-prompt autofix budget.
|
|
212
|
+
let autofixBudget = DEFAULT_AUTOFIX_BUDGET;
|
|
213
|
+
// One-time NO-OP warning (no tsconfig/tsc) per session.
|
|
214
|
+
let warnedNoEngine = false;
|
|
215
|
+
|
|
216
|
+
const notify = (ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void => {
|
|
217
|
+
if (ctx.mode === "print") {
|
|
218
|
+
(level === "info" ? console.log : console.error)(message);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (ctx.hasUI) {
|
|
222
|
+
ctx.ui.notify(message, level);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (level !== "info") console.error(message);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const warnNoEngine = (ctx: ExtensionContext): void => {
|
|
229
|
+
if (warnedNoEngine) return;
|
|
230
|
+
warnedNoEngine = true;
|
|
231
|
+
notify(
|
|
232
|
+
ctx,
|
|
233
|
+
"pi-typescript-lsp: no tsconfig.json or tsc found — TypeScript diagnostics disabled for this session.",
|
|
234
|
+
"warning",
|
|
235
|
+
);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Group `files` by nearest tsconfig, run tsc per group, and return diagnostics
|
|
240
|
+
* filtered to those files. `null` means there was no engine to run (no tsconfig
|
|
241
|
+
* found, or tsc could not be spawned) — distinct from "clean" (empty array).
|
|
242
|
+
*/
|
|
243
|
+
const runTouchedCheck = async (ctx: ExtensionContext, files: string[]): Promise<Diagnostic[] | null> => {
|
|
244
|
+
const groups = new Map<string, string[]>();
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
const tsconfig = findNearestTsconfig(file, ctx.cwd);
|
|
247
|
+
if (!existsSync(tsconfig)) continue;
|
|
248
|
+
const list = groups.get(tsconfig) ?? [];
|
|
249
|
+
list.push(file);
|
|
250
|
+
groups.set(tsconfig, list);
|
|
251
|
+
}
|
|
252
|
+
if (groups.size === 0) return null;
|
|
253
|
+
|
|
254
|
+
const all: Diagnostic[] = [];
|
|
255
|
+
let spawned = false;
|
|
256
|
+
for (const [tsconfig, groupFiles] of groups) {
|
|
257
|
+
const diags = await checkProject(tsconfig, ctx.signal, DEFAULT_TSC_TIMEOUT_MS);
|
|
258
|
+
if (diags === null) continue;
|
|
259
|
+
spawned = true;
|
|
260
|
+
all.push(...filterToTouched(diags, groupFiles));
|
|
261
|
+
}
|
|
262
|
+
return spawned ? all : null;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/** Run a whole-project check against `<cwd>/tsconfig.json` (no touched filter). */
|
|
266
|
+
const runProjectCheck = async (ctx: ExtensionContext): Promise<Diagnostic[] | null> => {
|
|
267
|
+
const tsconfig = path.join(ctx.cwd, "tsconfig.json");
|
|
268
|
+
if (!existsSync(tsconfig)) return null;
|
|
269
|
+
return checkProject(tsconfig, ctx.signal, DEFAULT_TSC_TIMEOUT_MS);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// --- tracker: record touched TS files. NEVER check here. ----------------
|
|
273
|
+
pi.on("tool_result", (event, ctx) => {
|
|
274
|
+
if (event.isError) return;
|
|
275
|
+
const name = event.toolName;
|
|
276
|
+
if (name !== "write" && name !== "edit" && name !== "multi_edit") return;
|
|
277
|
+
const raw = (event.input as { path?: unknown }).path;
|
|
278
|
+
if (typeof raw !== "string" || !isTsFile(raw)) return;
|
|
279
|
+
touched.add(path.isAbsolute(raw) ? raw : path.resolve(ctx.cwd, raw));
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// --- reset the per-prompt autofix budget at the start of each prompt. ----
|
|
283
|
+
pi.on("agent_start", () => {
|
|
284
|
+
autofixBudget = DEFAULT_AUTOFIX_BUDGET;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// --- coherent edge: run the check after the turn fully finishes. ---------
|
|
288
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
289
|
+
if (!enabled) {
|
|
290
|
+
touched.clear();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (touched.size === 0) return;
|
|
294
|
+
if (ctx.signal?.aborted) {
|
|
295
|
+
touched.clear();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Gate on the pure predicate so it stays unit-testable.
|
|
299
|
+
if (
|
|
300
|
+
!shouldRun({
|
|
301
|
+
touched: touched.size,
|
|
302
|
+
aborted: ctx.signal?.aborted ?? false,
|
|
303
|
+
idle: ctx.isIdle(),
|
|
304
|
+
pending: ctx.hasPendingMessages(),
|
|
305
|
+
})
|
|
306
|
+
) {
|
|
307
|
+
// Not idle or messages queued: keep `touched` and retry on a later edge.
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (running) return;
|
|
311
|
+
running = true;
|
|
312
|
+
try {
|
|
313
|
+
const files = [...touched];
|
|
314
|
+
const diags = await runTouchedCheck(ctx, files);
|
|
315
|
+
if (diags === null) {
|
|
316
|
+
warnNoEngine(ctx);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const formatted = formatDiagnostics(diags, { maxErrors });
|
|
320
|
+
if (!formatted.hasErrors) {
|
|
321
|
+
lastKey = undefined;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const key = diagnosticsKey(diags);
|
|
325
|
+
if (key === lastKey) return; // identical report already surfaced
|
|
326
|
+
|
|
327
|
+
if (mode === "autofix" && autofix) {
|
|
328
|
+
// Don't poison dedupe state when the budget blocks delivery: only
|
|
329
|
+
// remember a key for reports we actually send.
|
|
330
|
+
if (autofixBudget <= 0) return;
|
|
331
|
+
autofixBudget -= 1;
|
|
332
|
+
lastKey = key;
|
|
333
|
+
pi.sendMessage(
|
|
334
|
+
{
|
|
335
|
+
customType: CUSTOM_TYPE,
|
|
336
|
+
content: autofixMessage(formatted),
|
|
337
|
+
display: true,
|
|
338
|
+
details: { kind: "autofix", count: diags.length, diagnostics: diags },
|
|
339
|
+
},
|
|
340
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
341
|
+
);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
lastKey = key;
|
|
346
|
+
pi.sendMessage(
|
|
347
|
+
{
|
|
348
|
+
customType: CUSTOM_TYPE,
|
|
349
|
+
content: advisoryMessage(formatted),
|
|
350
|
+
display: true,
|
|
351
|
+
details: { kind: "advisory", count: diags.length, diagnostics: diags },
|
|
352
|
+
},
|
|
353
|
+
{ deliverAs: "nextTurn" },
|
|
354
|
+
);
|
|
355
|
+
} finally {
|
|
356
|
+
running = false;
|
|
357
|
+
touched.clear();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// --- tool: typescript_diagnostics (pull / on-demand) --------------------
|
|
362
|
+
pi.registerTool({
|
|
363
|
+
name: "typescript_diagnostics",
|
|
364
|
+
label: "TypeScript Diagnostics",
|
|
365
|
+
description:
|
|
366
|
+
"Run TypeScript diagnostics (tsc --noEmit) on demand and return the errors. scope='touched' (default) checks only files written/edited so far this turn; scope='project' type-checks the whole project (<cwd>/tsconfig.json). This is diagnostics feedback only — not a full language server (no hover/go-to-definition). tsc is invoked with an argv array, never a shell.",
|
|
367
|
+
promptSnippet: "Type-check touched files or the project with typescript_diagnostics.",
|
|
368
|
+
promptGuidelines: [
|
|
369
|
+
"Use typescript_diagnostics to verify your TypeScript edits compile (tsc --noEmit) instead of hand-writing `tsc` or `npx tsc` bash commands.",
|
|
370
|
+
"Prefer scope='touched' to check just the files you changed; use scope='project' for a full type-check before declaring done.",
|
|
371
|
+
"typescript_diagnostics reports diagnostics only — it cannot do hover, go-to-definition, or completions.",
|
|
372
|
+
],
|
|
373
|
+
parameters: Type.Object({
|
|
374
|
+
scope: Type.Optional(
|
|
375
|
+
StringEnum(["touched", "project"] as const, {
|
|
376
|
+
description:
|
|
377
|
+
"'touched' (default): only files edited so far this turn. 'project': the whole <cwd>/tsconfig.json.",
|
|
378
|
+
}),
|
|
379
|
+
),
|
|
380
|
+
}),
|
|
381
|
+
executionMode: "sequential",
|
|
382
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
383
|
+
const requested = parseScope(params.scope) ?? scope;
|
|
384
|
+
const effectiveCtx: ExtensionContext = { ...ctx, signal: signal ?? ctx.signal };
|
|
385
|
+
|
|
386
|
+
let diags: Diagnostic[] | null;
|
|
387
|
+
if (requested === "project") {
|
|
388
|
+
diags = await runProjectCheck(effectiveCtx);
|
|
389
|
+
} else {
|
|
390
|
+
if (touched.size === 0) {
|
|
391
|
+
return {
|
|
392
|
+
content: [{ type: "text" as const, text: "No TypeScript files have been touched this turn." }],
|
|
393
|
+
details: { scope: "touched", count: 0, diagnostics: [] },
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
diags = await runTouchedCheck(effectiveCtx, [...touched]);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (diags === null) {
|
|
400
|
+
return {
|
|
401
|
+
content: [
|
|
402
|
+
{
|
|
403
|
+
type: "text" as const,
|
|
404
|
+
text: "No tsconfig.json or tsc found — cannot run TypeScript diagnostics.",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
details: { isError: true, scope: requested },
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const formatted = formatDiagnostics(diags, { maxErrors });
|
|
412
|
+
const text = formatted.hasErrors
|
|
413
|
+
? `TypeScript diagnostics (${diags.length}):\n${formatted.text}`
|
|
414
|
+
: "No TypeScript diagnostics — clean.";
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: "text" as const, text }],
|
|
417
|
+
details: {
|
|
418
|
+
scope: requested,
|
|
419
|
+
hasErrors: formatted.hasErrors,
|
|
420
|
+
count: diags.length,
|
|
421
|
+
diagnostics: diags,
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// --- command: /tsc -------------------------------------------------------
|
|
428
|
+
const SUBCOMMANDS = ["status", "on", "off", "run", "scope", "autofix", "max"] as const;
|
|
429
|
+
|
|
430
|
+
pi.registerCommand("tsc", {
|
|
431
|
+
description:
|
|
432
|
+
"TypeScript diagnostics: status | on | off | run | scope <touched|project> | autofix <on|off> | max <n>",
|
|
433
|
+
getArgumentCompletions: (prefix: string) => {
|
|
434
|
+
const tokens = prefix.split(/\s+/);
|
|
435
|
+
if (tokens.length > 1) return null;
|
|
436
|
+
const needle = (tokens[0] ?? "").toLowerCase();
|
|
437
|
+
const items = SUBCOMMANDS.filter((sub) => sub.startsWith(needle));
|
|
438
|
+
return items.length > 0 ? items.map((sub) => ({ value: sub, label: sub })) : null;
|
|
439
|
+
},
|
|
440
|
+
handler: async (args, ctx) => {
|
|
441
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
442
|
+
const head = (tokens[0] ?? "status").toLowerCase();
|
|
443
|
+
|
|
444
|
+
if (head === "status") {
|
|
445
|
+
notify(
|
|
446
|
+
ctx,
|
|
447
|
+
`TypeScript diagnostics: ${enabled ? "on" : "off"}; mode: ${mode}; scope: ${scope}; autofix: ${autofix ? "on" : "off"}; max: ${maxErrors}`,
|
|
448
|
+
"info",
|
|
449
|
+
);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (head === "on") {
|
|
454
|
+
enabled = true;
|
|
455
|
+
lastKey = undefined;
|
|
456
|
+
notify(ctx, "TypeScript diagnostics enabled.", "info");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
if (head === "off") {
|
|
461
|
+
enabled = false;
|
|
462
|
+
touched.clear();
|
|
463
|
+
notify(ctx, "TypeScript diagnostics disabled.", "warning");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (head === "scope") {
|
|
468
|
+
const next = parseScope(tokens[1]);
|
|
469
|
+
if (!next) {
|
|
470
|
+
notify(ctx, "Usage: /tsc scope <touched|project>", "warning");
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
scope = next;
|
|
474
|
+
notify(ctx, `TypeScript diagnostics scope: ${scope}`, "info");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (head === "autofix") {
|
|
479
|
+
const next = parseOnOff(tokens[1]);
|
|
480
|
+
if (next === undefined) {
|
|
481
|
+
notify(ctx, "Usage: /tsc autofix <on|off>", "warning");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
autofix = next;
|
|
485
|
+
mode = autofix ? "autofix" : "advisory";
|
|
486
|
+
notify(ctx, `TypeScript diagnostics autofix: ${autofix ? "on" : "off"}`, "info");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (head === "max") {
|
|
491
|
+
const next = parseMax(tokens[1]);
|
|
492
|
+
if (next === undefined) {
|
|
493
|
+
notify(ctx, "Usage: /tsc max <positive integer>", "warning");
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
maxErrors = next;
|
|
497
|
+
notify(ctx, `TypeScript diagnostics max errors: ${maxErrors}`, "info");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (head === "run") {
|
|
502
|
+
const diags = scope === "project" ? await runProjectCheck(ctx) : await runTouchedCheck(ctx, [...touched]);
|
|
503
|
+
if (diags === null) {
|
|
504
|
+
notify(
|
|
505
|
+
ctx,
|
|
506
|
+
scope === "touched" && touched.size === 0
|
|
507
|
+
? "No TypeScript files touched this turn."
|
|
508
|
+
: "No tsconfig.json or tsc found — cannot run TypeScript diagnostics.",
|
|
509
|
+
"warning",
|
|
510
|
+
);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const formatted = formatDiagnostics(diags, { maxErrors });
|
|
514
|
+
notify(
|
|
515
|
+
ctx,
|
|
516
|
+
formatted.hasErrors
|
|
517
|
+
? `TypeScript diagnostics (${diags.length}):\n${formatted.text}`
|
|
518
|
+
: "No TypeScript diagnostics — clean.",
|
|
519
|
+
formatted.hasErrors ? "warning" : "info",
|
|
520
|
+
);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
notify(ctx, "Usage: /tsc [status|on|off|run|scope <touched|project>|autofix <on|off>|max <n>]", "warning");
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
package/messages.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FormatResult } from "./diagnostics.js";
|
|
2
|
+
|
|
3
|
+
/** Build the advisory feedback body (non-blocking, surfaced next turn). */
|
|
4
|
+
export function advisoryMessage(formatted: FormatResult): string {
|
|
5
|
+
return [
|
|
6
|
+
"TypeScript diagnostics on the files you just changed:",
|
|
7
|
+
"",
|
|
8
|
+
formatted.text,
|
|
9
|
+
"",
|
|
10
|
+
"Fix these when you continue; run typescript_diagnostics to re-check.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Build the autofix follow-up body (triggers a turn so the agent fixes them). */
|
|
15
|
+
export function autofixMessage(formatted: FormatResult): string {
|
|
16
|
+
return [
|
|
17
|
+
"TypeScript diagnostics were found on the files you just changed:",
|
|
18
|
+
"",
|
|
19
|
+
formatted.text,
|
|
20
|
+
"",
|
|
21
|
+
"Fix these type errors now, then re-run typescript_diagnostics to confirm a clean result.",
|
|
22
|
+
].join("\n");
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pandi-coding-agent/typescript-lsp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for TypeScript diagnostics feedback: runs tsc --noEmit on the coherent edge (agent_end) over touched files, plus a typescript_diagnostics tool and a /tsc command. Not a full LSP (no hover/go-to-def).",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/andrestobelem/pi-dynamic-workflows.git",
|
|
9
|
+
"directory": "extensions/pi-typescript-lsp"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"pi-package",
|
|
14
|
+
"pi",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"typescript",
|
|
17
|
+
"diagnostics",
|
|
18
|
+
"tsc"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"*.ts",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"pi": {
|
|
25
|
+
"extensions": [
|
|
26
|
+
"./index.ts"
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-ai": "*",
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"typebox": "*"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/settings.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-typescript-lsp settings: the small, pure setting parsers (env + subcommand
|
|
3
|
+
* share these — mirrors pi-auto-compact) plus the feedback-mode/scope
|
|
4
|
+
* value types they produce.
|
|
5
|
+
*
|
|
6
|
+
* Like diagnostics.ts, this module is deliberately free of pi's ExtensionContext
|
|
7
|
+
* / UI so it can be unit-tested in isolation against the same bundle the
|
|
8
|
+
* extension ships. No side effects.
|
|
9
|
+
*
|
|
10
|
+
* Depth-one sibling module imported by index.ts via "./settings.js".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type FeedbackMode = "advisory" | "autofix";
|
|
14
|
+
export type Scope = "touched" | "project";
|
|
15
|
+
|
|
16
|
+
/** Parse an on/off-style setting. Returns undefined for unrecognised input. */
|
|
17
|
+
export function parseOnOff(value: string | undefined): boolean | undefined {
|
|
18
|
+
if (value === undefined) return undefined;
|
|
19
|
+
const v = value.trim().toLowerCase();
|
|
20
|
+
if (v === "on" || v === "1" || v === "true" || v === "yes") return true;
|
|
21
|
+
if (v === "off" || v === "0" || v === "false" || v === "no") return false;
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse the feedback mode setting (advisory | autofix). */
|
|
26
|
+
export function parseMode(value: string | undefined): FeedbackMode | undefined {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
const v = value.trim().toLowerCase();
|
|
29
|
+
if (v === "advisory" || v === "autofix") return v;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parse a positive integer max-errors setting. */
|
|
34
|
+
export function parseMax(value: string | undefined): number | undefined {
|
|
35
|
+
if (value === undefined) return undefined;
|
|
36
|
+
const n = Number(value.trim());
|
|
37
|
+
if (!Number.isInteger(n) || n <= 0) return undefined;
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Parse a scope setting (touched | project). */
|
|
42
|
+
export function parseScope(value: string | undefined): Scope | undefined {
|
|
43
|
+
if (value === undefined) return undefined;
|
|
44
|
+
const v = value.trim().toLowerCase();
|
|
45
|
+
if (v === "touched" || v === "project") return v;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|