flecto 3.1.0 → 4.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/CHANGELOG.md +262 -1
- package/README.md +46 -3
- package/drift.js +226 -0
- package/index.js +527 -31
- package/package.json +24 -14
- package/src/alerter.js +272 -24
- package/src/config.js +208 -2
- package/src/drift-sources.js +444 -0
- package/src/explain.js +706 -0
- package/src/lsp-analysis.js +397 -0
- package/src/lsp-worker.js +13 -0
- package/src/lsp.js +407 -0
- package/src/mcp.js +487 -0
- package/src/parser.js +24 -14
- package/src/policy.js +71 -47
- package/src/positions.js +1063 -0
- package/src/pr-comment.js +33 -1
- package/src/regex-engine.js +138 -0
- package/src/renderer.js +24 -0
- package/src/snapshot-store.js +6 -2
package/src/lsp.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { dirname, sep } from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { Worker } from 'worker_threads';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `flecto lsp` (#142): a Language Server Protocol server over stdio that
|
|
7
|
+
* publishes Flecto's findings and semantic changes as diagnostics while a
|
|
8
|
+
* config file is being edited.
|
|
9
|
+
*
|
|
10
|
+
* The protocol loop does no analysis itself. Each document is analyzed in a
|
|
11
|
+
* worker thread (lsp-worker.js → lsp-analysis.js), which is what makes the two
|
|
12
|
+
* requirements hold at keystroke rate:
|
|
13
|
+
*
|
|
14
|
+
* - **Debounced**: edits to a document restart a short timer, and only the
|
|
15
|
+
* version that is current when it fires is analyzed.
|
|
16
|
+
* - **Cancellable**: an analysis still running when a newer version of the same
|
|
17
|
+
* document is ready is abandoned by terminating the worker — a synchronous
|
|
18
|
+
* parse of a large manifest cannot be interrupted any other way — and one
|
|
19
|
+
* that runs past the timeout is stopped the same way. A pack regex that
|
|
20
|
+
* backtracks forever therefore costs one warning diagnostic, not a wedged
|
|
21
|
+
* server.
|
|
22
|
+
*
|
|
23
|
+
* Results are published only if the document is still open at the version that
|
|
24
|
+
* was analyzed, so a slow answer never overwrites a newer one.
|
|
25
|
+
*
|
|
26
|
+
* Nothing but JSON-RPC is written to `output`: the worker's own stdout and
|
|
27
|
+
* stderr — a plugin that logs, a pack warning — are routed to `log`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** JSON-RPC error codes the server uses. */
|
|
31
|
+
const ERRORS = { methodNotFound: -32601, serverNotInitialized: -32002, invalidRequest: -32600 };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {{ diagnostics?: object[], error?: string, timedOut?: boolean, cancelled?: boolean }} RunResult
|
|
35
|
+
*
|
|
36
|
+
* @typedef {{
|
|
37
|
+
* run: (job: Record<string, unknown>) => Promise<RunResult>,
|
|
38
|
+
* cancel: () => void,
|
|
39
|
+
* dispose: () => void,
|
|
40
|
+
* }} Executor
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run analyses in a worker thread, one at a time.
|
|
45
|
+
* @param {{ timeoutMs: number, log: (message: string) => void }} options
|
|
46
|
+
* @returns {Executor}
|
|
47
|
+
*/
|
|
48
|
+
export function createWorkerExecutor({ timeoutMs, log }) {
|
|
49
|
+
/** @type {Worker | null} */
|
|
50
|
+
let worker = null;
|
|
51
|
+
/** @type {{ id: number, owner: Worker, resolve: (result: RunResult) => void, timer: NodeJS.Timeout } | null} */
|
|
52
|
+
let pending = null;
|
|
53
|
+
let nextId = 1;
|
|
54
|
+
|
|
55
|
+
const settle = (/** @type {RunResult} */ result) => {
|
|
56
|
+
if (!pending) return;
|
|
57
|
+
clearTimeout(pending.timer);
|
|
58
|
+
const { resolve } = pending;
|
|
59
|
+
pending = null;
|
|
60
|
+
resolve(result);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const stop = (/** @type {RunResult} */ result) => {
|
|
64
|
+
const current = worker;
|
|
65
|
+
worker = null;
|
|
66
|
+
settle(result);
|
|
67
|
+
if (current) void current.terminate();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const spawn = () => {
|
|
71
|
+
const created = new Worker(new URL('./lsp-worker.js', import.meta.url), { stdout: true, stderr: true });
|
|
72
|
+
created.stdout.on('data', (chunk) => log(String(chunk).trimEnd()));
|
|
73
|
+
created.stderr.on('data', (chunk) => log(String(chunk).trimEnd()));
|
|
74
|
+
created.on('message', (message) => {
|
|
75
|
+
if (pending && message?.id === pending.id) {
|
|
76
|
+
settle(message.error ? { error: message.error } : { diagnostics: message.diagnostics ?? [] });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// A terminated worker's `exit` arrives asynchronously, often after the next
|
|
80
|
+
// job has started on its replacement, so a worker only ever settles a job
|
|
81
|
+
// it was given.
|
|
82
|
+
created.on('error', (err) => {
|
|
83
|
+
if (worker === created) worker = null;
|
|
84
|
+
if (pending?.owner === created) settle({ error: err?.message ?? String(err) });
|
|
85
|
+
});
|
|
86
|
+
created.on('exit', () => {
|
|
87
|
+
if (worker === created) worker = null;
|
|
88
|
+
if (pending?.owner === created) settle({ error: 'the analysis worker exited unexpectedly' });
|
|
89
|
+
});
|
|
90
|
+
return created;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
run(job) {
|
|
95
|
+
worker ??= spawn();
|
|
96
|
+
const id = nextId++;
|
|
97
|
+
const target = worker;
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
pending = {
|
|
100
|
+
id,
|
|
101
|
+
owner: target,
|
|
102
|
+
resolve,
|
|
103
|
+
timer: setTimeout(() => stop({ timedOut: true }), timeoutMs),
|
|
104
|
+
};
|
|
105
|
+
target.postMessage({ ...job, id });
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
cancel() {
|
|
109
|
+
if (pending) stop({ cancelled: true });
|
|
110
|
+
},
|
|
111
|
+
dispose() {
|
|
112
|
+
stop({ cancelled: true });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} uri
|
|
119
|
+
* @returns {string | null}
|
|
120
|
+
*/
|
|
121
|
+
function uriToPath(uri) {
|
|
122
|
+
if (typeof uri !== 'string' || !uri.startsWith('file:')) return null;
|
|
123
|
+
try {
|
|
124
|
+
return fileURLToPath(uri);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Apply one `didChange` content change. The server asks for full-document
|
|
132
|
+
* sync, but a client that sends ranged edits anyway is handled rather than
|
|
133
|
+
* silently diverging.
|
|
134
|
+
* @param {string} text
|
|
135
|
+
* @param {{ text: string, range?: { start: { line: number, character: number }, end: { line: number, character: number } } }} change
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function applyChange(text, change) {
|
|
139
|
+
if (!change.range) return change.text;
|
|
140
|
+
const offsetOf = ({ line, character }) => {
|
|
141
|
+
let offset = 0;
|
|
142
|
+
for (let current = 0; current < line; current++) {
|
|
143
|
+
const next = text.indexOf('\n', offset);
|
|
144
|
+
if (next === -1) return text.length;
|
|
145
|
+
offset = next + 1;
|
|
146
|
+
}
|
|
147
|
+
return Math.min(offset + character, text.length);
|
|
148
|
+
};
|
|
149
|
+
return text.slice(0, offsetOf(change.range.start)) + change.text + text.slice(offsetOf(change.range.end));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Start the server on a pair of streams.
|
|
154
|
+
* @param {{
|
|
155
|
+
* input: NodeJS.ReadableStream,
|
|
156
|
+
* output: NodeJS.WritableStream,
|
|
157
|
+
* version: string,
|
|
158
|
+
* cwd: string,
|
|
159
|
+
* settings: import('./lsp-analysis.js').LspSettings,
|
|
160
|
+
* debounceMs?: number,
|
|
161
|
+
* timeoutMs?: number,
|
|
162
|
+
* executor?: Executor,
|
|
163
|
+
* log?: (message: string) => void,
|
|
164
|
+
* onExit?: (code: number) => void,
|
|
165
|
+
* }} options
|
|
166
|
+
* @returns {{ done: Promise<number> }} resolves with the exit code once the
|
|
167
|
+
* client sends `exit` or closes the stream
|
|
168
|
+
*/
|
|
169
|
+
export function startLanguageServer(options) {
|
|
170
|
+
const { input, output, version, settings } = options;
|
|
171
|
+
const debounceMs = options.debounceMs ?? 250;
|
|
172
|
+
const log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
|
|
173
|
+
const executor = options.executor ?? createWorkerExecutor({ timeoutMs: options.timeoutMs ?? 10_000, log });
|
|
174
|
+
|
|
175
|
+
/** @type {Map<string, { text: string, version: number }>} */
|
|
176
|
+
const docs = new Map();
|
|
177
|
+
/** @type {Map<string, NodeJS.Timeout>} */
|
|
178
|
+
const timers = new Map();
|
|
179
|
+
/** @type {Set<string>} */
|
|
180
|
+
const queued = new Set();
|
|
181
|
+
/** @type {{ uri: string, version: number } | null} */
|
|
182
|
+
let running = null;
|
|
183
|
+
let initialized = false;
|
|
184
|
+
let shutdownRequested = false;
|
|
185
|
+
let exited = false;
|
|
186
|
+
/** @type {string[]} */
|
|
187
|
+
let roots = [options.cwd];
|
|
188
|
+
let finish = (/** @type {number} */ _code) => {};
|
|
189
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
190
|
+
|
|
191
|
+
const send = (message) => {
|
|
192
|
+
const body = JSON.stringify({ jsonrpc: '2.0', ...message });
|
|
193
|
+
output.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
|
194
|
+
};
|
|
195
|
+
const respond = (id, result) => send({ id, result });
|
|
196
|
+
const fail = (id, code, message) => send({ id, error: { code, message } });
|
|
197
|
+
const publish = (uri, docVersion, diagnostics) => send({
|
|
198
|
+
method: 'textDocument/publishDiagnostics',
|
|
199
|
+
params: { uri, ...(docVersion === null ? {} : { version: docVersion }), diagnostics },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The workspace folder that owns a file: the longest one containing it, so
|
|
204
|
+
* `.flectorc` and `policies/` resolve from the right project in a multi-root
|
|
205
|
+
* workspace.
|
|
206
|
+
* @param {string} path
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
const rootFor = (path) => roots
|
|
210
|
+
.filter((root) => path === root || path.startsWith(root.endsWith(sep) ? root : root + sep))
|
|
211
|
+
.sort((a, b) => b.length - a.length)[0] ?? dirname(path);
|
|
212
|
+
|
|
213
|
+
const pump = () => {
|
|
214
|
+
if (running || queued.size === 0 || exited) return;
|
|
215
|
+
const [uri] = queued;
|
|
216
|
+
queued.delete(uri);
|
|
217
|
+
const doc = docs.get(uri);
|
|
218
|
+
const path = uriToPath(uri);
|
|
219
|
+
if (!doc || !path) {
|
|
220
|
+
pump();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const job = { root: rootFor(path), path, text: doc.text, settings };
|
|
224
|
+
running = { uri, version: doc.version };
|
|
225
|
+
const analyzed = running;
|
|
226
|
+
void executor.run(job).then((result) => {
|
|
227
|
+
running = null;
|
|
228
|
+
const current = docs.get(uri);
|
|
229
|
+
if (!result.cancelled && current && current.version === analyzed.version && !exited) {
|
|
230
|
+
if (result.timedOut) {
|
|
231
|
+
publish(uri, analyzed.version, [{
|
|
232
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
233
|
+
severity: 2,
|
|
234
|
+
source: 'flecto',
|
|
235
|
+
code: 'timeout',
|
|
236
|
+
message: 'Flecto stopped analyzing this file because it took too long. A policy pack regex that'
|
|
237
|
+
+ ' backtracks on a long value is the usual cause; `flecto ci` would hang on it too.',
|
|
238
|
+
}]);
|
|
239
|
+
} else if (result.error) {
|
|
240
|
+
publish(uri, analyzed.version, [{
|
|
241
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } },
|
|
242
|
+
severity: 1,
|
|
243
|
+
source: 'flecto',
|
|
244
|
+
code: 'error',
|
|
245
|
+
message: `Flecto could not analyze this file: ${result.error}`,
|
|
246
|
+
}]);
|
|
247
|
+
} else {
|
|
248
|
+
publish(uri, analyzed.version, result.diagnostics ?? []);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
pump();
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const enqueue = (uri) => {
|
|
256
|
+
queued.add(uri);
|
|
257
|
+
// A newer version of the document being analyzed makes that analysis
|
|
258
|
+
// worthless; stop it rather than wait for it.
|
|
259
|
+
if (running?.uri === uri && docs.get(uri)?.version !== running.version) executor.cancel();
|
|
260
|
+
pump();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const schedule = (uri) => {
|
|
264
|
+
clearTimeout(timers.get(uri));
|
|
265
|
+
timers.set(uri, setTimeout(() => {
|
|
266
|
+
timers.delete(uri);
|
|
267
|
+
enqueue(uri);
|
|
268
|
+
}, debounceMs));
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const exit = (code) => {
|
|
272
|
+
if (exited) return;
|
|
273
|
+
exited = true;
|
|
274
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
275
|
+
executor.dispose();
|
|
276
|
+
finish(code);
|
|
277
|
+
options.onExit?.(code);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** @param {any} message */
|
|
281
|
+
const handle = (message) => {
|
|
282
|
+
if (!message || typeof message !== 'object' || typeof message.method !== 'string') {
|
|
283
|
+
if (message && message.id !== undefined && message.method === undefined) return; // a response to us
|
|
284
|
+
if (message?.id !== undefined) fail(message.id, ERRORS.invalidRequest, 'Invalid request');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const { id, method, params } = message;
|
|
288
|
+
const isRequest = id !== undefined && id !== null;
|
|
289
|
+
|
|
290
|
+
if (method === 'initialize') {
|
|
291
|
+
const folders = Array.isArray(params?.workspaceFolders) ? params.workspaceFolders.map((f) => uriToPath(f?.uri)) : [];
|
|
292
|
+
const rootUriPath = uriToPath(params?.rootUri);
|
|
293
|
+
const found = [...folders, rootUriPath, typeof params?.rootPath === 'string' ? params.rootPath : null]
|
|
294
|
+
.filter((folder) => typeof folder === 'string' && folder.length > 0);
|
|
295
|
+
if (found.length > 0) roots = [...new Set(found)];
|
|
296
|
+
initialized = true;
|
|
297
|
+
respond(id, {
|
|
298
|
+
capabilities: {
|
|
299
|
+
positionEncoding: 'utf-16',
|
|
300
|
+
textDocumentSync: { openClose: true, change: 1, save: { includeText: false } },
|
|
301
|
+
},
|
|
302
|
+
serverInfo: { name: 'flecto', version },
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (method === 'exit') {
|
|
307
|
+
exit(shutdownRequested ? 0 : 1);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!initialized) {
|
|
311
|
+
if (isRequest) fail(id, ERRORS.serverNotInitialized, 'Server not initialized');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
switch (method) {
|
|
316
|
+
case 'initialized':
|
|
317
|
+
return;
|
|
318
|
+
case 'shutdown':
|
|
319
|
+
shutdownRequested = true;
|
|
320
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
321
|
+
timers.clear();
|
|
322
|
+
queued.clear();
|
|
323
|
+
executor.cancel();
|
|
324
|
+
respond(id, null);
|
|
325
|
+
return;
|
|
326
|
+
case 'textDocument/didOpen': {
|
|
327
|
+
const doc = params?.textDocument;
|
|
328
|
+
if (typeof doc?.uri !== 'string' || typeof doc.text !== 'string') return;
|
|
329
|
+
docs.set(doc.uri, { text: doc.text, version: Number(doc.version) || 0 });
|
|
330
|
+
schedule(doc.uri);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case 'textDocument/didChange': {
|
|
334
|
+
const uri = params?.textDocument?.uri;
|
|
335
|
+
const doc = docs.get(uri);
|
|
336
|
+
if (!doc || !Array.isArray(params.contentChanges)) return;
|
|
337
|
+
let { text } = doc;
|
|
338
|
+
for (const change of params.contentChanges) {
|
|
339
|
+
if (typeof change?.text === 'string') text = applyChange(text, change);
|
|
340
|
+
}
|
|
341
|
+
docs.set(uri, { text, version: Number(params.textDocument.version) || doc.version + 1 });
|
|
342
|
+
schedule(uri);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case 'textDocument/didSave':
|
|
346
|
+
case 'workspace/didChangeWatchedFiles': {
|
|
347
|
+
// A saved `.flectorc`, pack, or baseline changes what every open
|
|
348
|
+
// document should show, and the analysis reads those from disk.
|
|
349
|
+
for (const uri of docs.keys()) schedule(uri);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
case 'textDocument/didClose': {
|
|
353
|
+
const uri = params?.textDocument?.uri;
|
|
354
|
+
clearTimeout(timers.get(uri));
|
|
355
|
+
timers.delete(uri);
|
|
356
|
+
queued.delete(uri);
|
|
357
|
+
docs.delete(uri);
|
|
358
|
+
if (running?.uri === uri) executor.cancel();
|
|
359
|
+
if (typeof uri === 'string') publish(uri, null, []);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
default:
|
|
363
|
+
// Notifications we do not handle ($/cancelRequest, $/setTrace, …) are
|
|
364
|
+
// ignored, as the protocol allows; unknown requests get an error.
|
|
365
|
+
if (isRequest) fail(id, ERRORS.methodNotFound, `Unhandled method ${method}`);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
// Content-Length framing. Lengths are in bytes, so the buffer is kept as
|
|
370
|
+
// bytes and only a complete body is decoded.
|
|
371
|
+
let buffer = Buffer.alloc(0);
|
|
372
|
+
input.on('data', (chunk) => {
|
|
373
|
+
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8')]);
|
|
374
|
+
for (;;) {
|
|
375
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
376
|
+
if (headerEnd === -1) return;
|
|
377
|
+
const header = buffer.subarray(0, headerEnd).toString('ascii');
|
|
378
|
+
const length = /Content-Length:\s*(\d+)/iu.exec(header);
|
|
379
|
+
if (!length) {
|
|
380
|
+
// Unframed garbage: drop through the header terminator and resync.
|
|
381
|
+
buffer = buffer.subarray(headerEnd + 4);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const bodyStart = headerEnd + 4;
|
|
385
|
+
const bodyEnd = bodyStart + Number(length[1]);
|
|
386
|
+
if (buffer.length < bodyEnd) return;
|
|
387
|
+
const body = buffer.subarray(bodyStart, bodyEnd).toString('utf8');
|
|
388
|
+
buffer = buffer.subarray(bodyEnd);
|
|
389
|
+
let message;
|
|
390
|
+
try {
|
|
391
|
+
message = JSON.parse(body);
|
|
392
|
+
} catch {
|
|
393
|
+
send({ id: null, error: { code: -32700, message: 'Parse error' } });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
handle(message);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
log(`flecto lsp: ${err?.stack ?? err}`);
|
|
400
|
+
if (message?.id !== undefined) fail(message.id, -32603, 'Internal error');
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
input.on('end', () => exit(shutdownRequested ? 0 : 1));
|
|
405
|
+
|
|
406
|
+
return { done };
|
|
407
|
+
}
|