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
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { realpathSync } from 'fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'path';
|
|
4
|
+
|
|
5
|
+
import { applyBaseline, baselineRelativePath, loadBaseline } from './baseline.js';
|
|
6
|
+
import {
|
|
7
|
+
assertSafeGitRef,
|
|
8
|
+
assertWriteDestinationContained,
|
|
9
|
+
loadRcConfig,
|
|
10
|
+
resolveEffectiveOptions,
|
|
11
|
+
resolveFiles,
|
|
12
|
+
resolvePolicyOptions,
|
|
13
|
+
} from './config.js';
|
|
14
|
+
import { diffTrees, secretMatchPath } from './differ.js';
|
|
15
|
+
import { displayEncrypted } from './encrypted.js';
|
|
16
|
+
import { evaluatePolicies } from './policy.js';
|
|
17
|
+
import { isSupported, parseContent } from './parser.js';
|
|
18
|
+
import { buildPositionIndex, locatePath, toLspRange } from './positions.js';
|
|
19
|
+
import { maskFindings, maskSensitiveValue } from './renderer.js';
|
|
20
|
+
import { maskState, resolveSnapshotStore } from './snapshot-store.js';
|
|
21
|
+
import { applySuppressions, parseSuppressions, suppressionFormat } from './suppressions.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One document in, diagnostics out (#142). Runs in the language server's
|
|
25
|
+
* worker thread, so a slow or hung analysis can be abandoned without touching
|
|
26
|
+
* the protocol loop.
|
|
27
|
+
*
|
|
28
|
+
* The rule this file keeps: **the editor must never disagree with the merge
|
|
29
|
+
* gate.** Findings come from the same `.flectorc`, the same packs and
|
|
30
|
+
* `severityRemap`, the same inline suppressions (a suppression missing its
|
|
31
|
+
* reason shows as the error CI would fail on), and the same `--baseline` file
|
|
32
|
+
* CI uses — so a finding shows in the editor exactly when `flecto ci` would
|
|
33
|
+
* gate on it. Where the two genuinely cannot agree, the diagnostic says so
|
|
34
|
+
* rather than quietly differing: plugins declared in `.flectorc` are not loaded
|
|
35
|
+
* here (below), and that is reported on the file.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** LSP DiagnosticSeverity. */
|
|
39
|
+
const SEVERITY = { error: 1, warn: 2, info: 3, hint: 4 };
|
|
40
|
+
const MAX_DOCUMENT_CHARS = 5 * 1024 * 1024;
|
|
41
|
+
const MAX_CHANGE_DIAGNOSTICS = 200;
|
|
42
|
+
const MAX_VALUE_CHARS = 60;
|
|
43
|
+
const SCOPE_CACHE_MS = 10_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {{
|
|
47
|
+
* profile?: string,
|
|
48
|
+
* plugins?: string[],
|
|
49
|
+
* snapshotRef?: string,
|
|
50
|
+
* snapshotStore?: string,
|
|
51
|
+
* snapshotDir?: string,
|
|
52
|
+
* changes?: 'hint' | 'info' | 'none',
|
|
53
|
+
* }} LspSettings
|
|
54
|
+
* Everything here comes from the `flecto lsp` command line — the editor
|
|
55
|
+
* configuration the user wrote — never from the repository.
|
|
56
|
+
*
|
|
57
|
+
* @typedef {{ root: string, path: string, text: string, settings: LspSettings }} AnalysisJob
|
|
58
|
+
*
|
|
59
|
+
* @typedef {{
|
|
60
|
+
* range: { start: { line: number, character: number }, end: { line: number, character: number } },
|
|
61
|
+
* severity: number,
|
|
62
|
+
* source: 'flecto',
|
|
63
|
+
* code?: string,
|
|
64
|
+
* message: string,
|
|
65
|
+
* data?: Record<string, unknown>,
|
|
66
|
+
* }} Diagnostic
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** @type {Map<string, { at: number, files: Set<string> | null }>} */
|
|
70
|
+
const scopeCache = new Map();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {AnalysisJob} job
|
|
74
|
+
* @returns {Promise<Diagnostic[]>}
|
|
75
|
+
*/
|
|
76
|
+
export async function analyzeDocument(job) {
|
|
77
|
+
const { root, path, text, settings } = job;
|
|
78
|
+
if (!isSupported(path)) return [];
|
|
79
|
+
if (text.length > MAX_DOCUMENT_CHARS) {
|
|
80
|
+
return [fileDiagnostic(SEVERITY.info, 'too-large', 'Flecto skips files over 5 MB in the editor; run `flecto ci` on it instead.')];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let config;
|
|
84
|
+
try {
|
|
85
|
+
({ config } = loadRcConfig(root));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
return [fileDiagnostic(SEVERITY.error, 'config', err.message)];
|
|
88
|
+
}
|
|
89
|
+
const profile = settings.profile ?? (process.env.FLECTO_PROFILE || undefined);
|
|
90
|
+
const effective = resolveEffectiveOptions(config, profile, {});
|
|
91
|
+
|
|
92
|
+
if (!(await inScope(root, config, path))) return [];
|
|
93
|
+
|
|
94
|
+
/** @type {Diagnostic[]} */
|
|
95
|
+
const diagnostics = [];
|
|
96
|
+
|
|
97
|
+
// Plugins execute code, and opening a repository in an editor is the same
|
|
98
|
+
// threat model as CI running an untrusted pull request — with the difference
|
|
99
|
+
// that FLECTO_ALLOW_RC_PLUGINS in a developer's shell profile would apply to
|
|
100
|
+
// every repository they ever open. So `.flectorc` plugins are never loaded
|
|
101
|
+
// here, whatever that variable says; only `--plugins` on the `flecto lsp`
|
|
102
|
+
// command line, which the user wrote into their own editor configuration.
|
|
103
|
+
const rcPlugins = effective.plugins;
|
|
104
|
+
const hasRcPlugins = Array.isArray(rcPlugins) ? rcPlugins.length > 0 : Boolean(rcPlugins);
|
|
105
|
+
let policyOptions;
|
|
106
|
+
try {
|
|
107
|
+
policyOptions = resolvePolicyOptions(
|
|
108
|
+
{ ...effective, plugins: settings.plugins ?? [] },
|
|
109
|
+
{ pluginsFromCli: true, cwd: root },
|
|
110
|
+
);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return [fileDiagnostic(SEVERITY.error, 'config', err.message)];
|
|
113
|
+
}
|
|
114
|
+
if (hasRcPlugins && settings.plugins === undefined) {
|
|
115
|
+
diagnostics.push(fileDiagnostic(
|
|
116
|
+
SEVERITY.warn,
|
|
117
|
+
'plugins-not-loaded',
|
|
118
|
+
'This repository declares policy plugins in .flectorc. The language server never loads plugins from a'
|
|
119
|
+
+ ' repository, so their findings are missing here — CI may still report them. Pass absolute paths'
|
|
120
|
+
+ ' with `flecto lsp --plugins` in your editor settings if you trust this repository.',
|
|
121
|
+
));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let after;
|
|
125
|
+
try {
|
|
126
|
+
after = parseContent(path, text);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
// `(line N)` from the parser's wrapper, else js-yaml's `(N:col)`.
|
|
129
|
+
const line = /\(line (\d+)\)/u.exec(err.message) ?? /\((\d+):\d+\)/u.exec(err.message);
|
|
130
|
+
const at = line ? Math.max(0, Number(line[1]) - 1) : 0;
|
|
131
|
+
return [{
|
|
132
|
+
range: { start: { line: at, character: 0 }, end: { line: at, character: 0 } },
|
|
133
|
+
severity: SEVERITY.info,
|
|
134
|
+
source: 'flecto',
|
|
135
|
+
code: 'parse',
|
|
136
|
+
message: `Flecto analyzes this file once it parses: ${err.message.replace(/^Parse error in "[^"]*"(?: \(line \d+\))?: /u, '')}`,
|
|
137
|
+
}];
|
|
138
|
+
}
|
|
139
|
+
const index = buildPositionIndex(path, text, after);
|
|
140
|
+
|
|
141
|
+
const dOpts = diffOptions(effective);
|
|
142
|
+
const baseline = readBaseline(root, path, settings, effective);
|
|
143
|
+
if (baseline.error) {
|
|
144
|
+
diagnostics.push(fileDiagnostic(SEVERITY.info, 'baseline', baseline.error));
|
|
145
|
+
}
|
|
146
|
+
const afterForDiff = baseline.maskHashes ? maskState(after) : after;
|
|
147
|
+
const events = diffTrees(baseline.state ?? {}, afterForDiff, dOpts);
|
|
148
|
+
|
|
149
|
+
const rawFindings = await evaluatePolicies(events, {
|
|
150
|
+
cwd: root,
|
|
151
|
+
file: path,
|
|
152
|
+
profile: profile ?? null,
|
|
153
|
+
source: 'diff',
|
|
154
|
+
policies: policyOptions.policies,
|
|
155
|
+
plugins: policyOptions.plugins,
|
|
156
|
+
severityRemap: policyOptions.severityRemap,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// Inline suppressions, exactly as `ci` applies them — including the refusal:
|
|
160
|
+
// a directive with no reason fails the CI run, so it is an error here.
|
|
161
|
+
const format = suppressionFormat(path);
|
|
162
|
+
const { suppressions, errors, warnings } = parseSuppressions(text, format);
|
|
163
|
+
for (const problem of errors) diagnostics.push(lineDiagnostic(problem.line, SEVERITY.error, 'suppression', `${problem.message} — \`flecto ci\` fails on this.`));
|
|
164
|
+
for (const problem of warnings) diagnostics.push(lineDiagnostic(problem.line, SEVERITY.warn, 'suppression', problem.message));
|
|
165
|
+
let { active } = applySuppressions(rawFindings, suppressions);
|
|
166
|
+
|
|
167
|
+
// The baseline file CI gates against: a finding it already accepts does not
|
|
168
|
+
// gate, so it does not show.
|
|
169
|
+
if (effective.baseline) {
|
|
170
|
+
try {
|
|
171
|
+
const baselinePath = resolve(root, String(effective.baseline));
|
|
172
|
+
assertWriteDestinationContained(baselinePath, { option: '--baseline', fromCli: false, cwd: root });
|
|
173
|
+
const { entries } = loadBaseline(baselinePath);
|
|
174
|
+
const relFile = baselineRelativePath(path, root);
|
|
175
|
+
active = applyBaseline(active.map((finding) => ({ file: relFile, finding })), entries)
|
|
176
|
+
.active.map((item) => item.finding);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
diagnostics.push(fileDiagnostic(SEVERITY.error, 'baseline', err.message));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const shown = effective.maskSecrets ? maskFindings(active, events) : active;
|
|
183
|
+
for (const finding of shown) {
|
|
184
|
+
const location = locatePath(index, String(finding.path ?? ''), { arrayIdKey: dOpts.arrayIdKey });
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
range: toLspRange(index, location),
|
|
187
|
+
severity: SEVERITY[finding.severity] ?? SEVERITY.info,
|
|
188
|
+
source: 'flecto',
|
|
189
|
+
code: String(finding.id),
|
|
190
|
+
message: `${location.precision === 'exact' ? '' : `${finding.path}: `}${finding.message}`,
|
|
191
|
+
data: { pack: finding.pack ?? null, path: finding.path, precision: location.precision },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const changeSeverity = settings.changes === 'none' ? null : settings.changes === 'info' ? SEVERITY.info : SEVERITY.hint;
|
|
196
|
+
if (changeSeverity !== null && baseline.state !== null) {
|
|
197
|
+
for (const event of events.slice(0, MAX_CHANGE_DIAGNOSTICS)) {
|
|
198
|
+
const location = locatePath(index, event.path, { arrayIdKey: dOpts.arrayIdKey });
|
|
199
|
+
diagnostics.push({
|
|
200
|
+
range: toLspRange(index, location),
|
|
201
|
+
severity: changeSeverity,
|
|
202
|
+
source: 'flecto',
|
|
203
|
+
code: event.type,
|
|
204
|
+
message: describeChange(event, baseline.label),
|
|
205
|
+
data: { path: event.path, precision: location.precision },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (events.length > MAX_CHANGE_DIAGNOSTICS) {
|
|
209
|
+
diagnostics.push(fileDiagnostic(changeSeverity, 'changed', `${events.length - MAX_CHANGE_DIAGNOSTICS} more changes from ${baseline.label} not shown.`));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return diagnostics;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Whether `flecto ci` would check this file: with `files`/`include` in
|
|
218
|
+
* `.flectorc`, only a file they match (minus `exclude`); without, any supported
|
|
219
|
+
* file. Flagging a file CI never looks at would be its own kind of
|
|
220
|
+
* disagreement. The glob expansion is cached briefly — it is a directory walk,
|
|
221
|
+
* and this runs at keystroke rate.
|
|
222
|
+
* @param {string} root
|
|
223
|
+
* @param {import('./config.js').FlectoRc | null} config
|
|
224
|
+
* @param {string} path
|
|
225
|
+
* @returns {Promise<boolean>}
|
|
226
|
+
*/
|
|
227
|
+
async function inScope(root, config, path) {
|
|
228
|
+
const patterns = [...(config?.files ?? []), ...(config?.include ?? [])];
|
|
229
|
+
if (patterns.length === 0) return true;
|
|
230
|
+
const key = JSON.stringify([root, patterns, config?.exclude ?? []]);
|
|
231
|
+
let cached = scopeCache.get(key);
|
|
232
|
+
if (!cached || Date.now() - cached.at > SCOPE_CACHE_MS) {
|
|
233
|
+
let files = null;
|
|
234
|
+
try {
|
|
235
|
+
files = new Set((await resolveFiles({ cwd: root, files: patterns, exclude: config?.exclude ?? [] })).map(canonical));
|
|
236
|
+
} catch {
|
|
237
|
+
files = null;
|
|
238
|
+
}
|
|
239
|
+
cached = { at: Date.now(), files };
|
|
240
|
+
scopeCache.set(key, cached);
|
|
241
|
+
}
|
|
242
|
+
return cached.files === null || cached.files.has(canonical(path));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string} path
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
function canonical(path) {
|
|
250
|
+
try {
|
|
251
|
+
return realpathSync.native(path);
|
|
252
|
+
} catch {
|
|
253
|
+
return resolve(path);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {Record<string, unknown>} effective
|
|
259
|
+
* @returns {{ ignorePaths: string[], arrayIdKey: string | null, arrayIdentity: boolean, arrayIgnoreOrder: boolean }}
|
|
260
|
+
*/
|
|
261
|
+
function diffOptions(effective) {
|
|
262
|
+
const ignore = effective.ignore;
|
|
263
|
+
const ignorePaths = Array.isArray(ignore)
|
|
264
|
+
? ignore.map(String)
|
|
265
|
+
: typeof ignore === 'string' ? ignore.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
|
266
|
+
const arrayIdKey = effective.arrayIdKey ? String(effective.arrayIdKey) : null;
|
|
267
|
+
return {
|
|
268
|
+
ignorePaths,
|
|
269
|
+
arrayIdKey,
|
|
270
|
+
arrayIdentity: arrayIdKey ? true : effective.arrayId !== false,
|
|
271
|
+
arrayIgnoreOrder: Boolean(effective.arrayIgnoreOrder),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The state the document is compared against: git (`--snapshot-ref`, default
|
|
277
|
+
* `HEAD`) or, with `--snapshot-store`, the snapshot store. Both are read-only
|
|
278
|
+
* here, and the ref comes from the editor configuration, never the repository.
|
|
279
|
+
* @param {string} root
|
|
280
|
+
* @param {string} path
|
|
281
|
+
* @param {LspSettings} settings
|
|
282
|
+
* @param {Record<string, unknown>} effective
|
|
283
|
+
* @returns {{ state: unknown | null, label: string, maskHashes: boolean, error?: string }}
|
|
284
|
+
*/
|
|
285
|
+
function readBaseline(root, path, settings, effective) {
|
|
286
|
+
if (settings.snapshotStore) {
|
|
287
|
+
try {
|
|
288
|
+
const store = resolveSnapshotStore({
|
|
289
|
+
store: settings.snapshotStore,
|
|
290
|
+
dir: settings.snapshotDir,
|
|
291
|
+
mask: effective.snapshotMask,
|
|
292
|
+
cwd: root,
|
|
293
|
+
});
|
|
294
|
+
const record = store.readLatest(path);
|
|
295
|
+
if (!record) {
|
|
296
|
+
return { state: null, label: store.label, maskHashes: false, error: `No snapshot of this file in ${store.label}, so only policy findings are shown; every key is treated as added.` };
|
|
297
|
+
}
|
|
298
|
+
return { state: record.state, label: 'the snapshot', maskHashes: store.maskMode === 'hash' };
|
|
299
|
+
} catch (err) {
|
|
300
|
+
return { state: null, label: 'the snapshot', maskHashes: false, error: err.message };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const ref = settings.snapshotRef ?? 'HEAD';
|
|
305
|
+
// Hoisted out of the try below: a refused ref is a different diagnosis from
|
|
306
|
+
// "this file is not in that ref", and reporting the latter would name the
|
|
307
|
+
// one explanation that is certainly wrong.
|
|
308
|
+
try {
|
|
309
|
+
assertSafeGitRef(ref);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
return { state: null, label: ref, maskHashes: false, error: err.message };
|
|
312
|
+
}
|
|
313
|
+
let top;
|
|
314
|
+
try {
|
|
315
|
+
top = execFileSync('git', ['-C', dirname(path), 'rev-parse', '--show-toplevel'], {
|
|
316
|
+
encoding: 'utf8',
|
|
317
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
318
|
+
}).trim();
|
|
319
|
+
} catch {
|
|
320
|
+
return {
|
|
321
|
+
state: null,
|
|
322
|
+
label: ref,
|
|
323
|
+
maskHashes: false,
|
|
324
|
+
error: 'This file is not in a git repository, so there is nothing to diff against and only policy findings'
|
|
325
|
+
+ ' are shown. Start the server with --snapshot-store to compare against saved snapshots instead.',
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
let raw;
|
|
329
|
+
try {
|
|
330
|
+
// Canonicalize the directory, keep the name: canonicalizing the file
|
|
331
|
+
// resolves its final symlink, which would read the link's destination
|
|
332
|
+
// out of the ref rather than the path the editor has open. Same fix as
|
|
333
|
+
// gitRepoRelativePath in index.js.
|
|
334
|
+
const nominal = join(canonical(dirname(path)), basename(path));
|
|
335
|
+
const rel = relative(canonical(top), nominal).replaceAll('\\', '/');
|
|
336
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw new Error('outside the repository');
|
|
337
|
+
raw = execFileSync('git', ['-C', top, 'show', '--end-of-options', `${ref}:${rel}`], {
|
|
338
|
+
encoding: 'utf8',
|
|
339
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
340
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
return {
|
|
344
|
+
state: null,
|
|
345
|
+
label: ref,
|
|
346
|
+
maskHashes: false,
|
|
347
|
+
error: `This file is not in ${ref}, so only policy findings are shown; every key is treated as added.`
|
|
348
|
+
+ ' `flecto ci --snapshot-ref` fails closed on a file its ref does not have.',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
return { state: parseContent(path, raw), label: ref, maskHashes: false };
|
|
353
|
+
} catch (err) {
|
|
354
|
+
return { state: null, label: ref, maskHashes: false, error: `The ${ref} version of this file does not parse: ${err.message}` };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {import('./differ.js').ChangeEvent} event
|
|
360
|
+
* @param {string} label
|
|
361
|
+
* @returns {string}
|
|
362
|
+
*/
|
|
363
|
+
function describeChange(event, label) {
|
|
364
|
+
// Change hints always mask: a hover that shows the password this line had at
|
|
365
|
+
// HEAD is a leak nobody asked for, and nothing gates on these.
|
|
366
|
+
const path = secretMatchPath(event);
|
|
367
|
+
const show = (value) => {
|
|
368
|
+
const masked = displayEncrypted(maskSensitiveValue(value, path));
|
|
369
|
+
const json = JSON.stringify(masked) ?? String(masked);
|
|
370
|
+
return json.length > MAX_VALUE_CHARS ? `${json.slice(0, MAX_VALUE_CHARS)}…` : json;
|
|
371
|
+
};
|
|
372
|
+
if (event.type === 'added') return `${event.path} added (not in ${label})`;
|
|
373
|
+
if (event.type === 'removed') return `${event.path} removed (was ${show(event.before)} in ${label})`;
|
|
374
|
+
return `${event.path} changed from ${show(event.before)} (${label}) to ${show(event.after)}${event.note ? ` — ${event.note}` : ''}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @param {number} severity
|
|
379
|
+
* @param {string} code
|
|
380
|
+
* @param {string} message
|
|
381
|
+
* @returns {Diagnostic}
|
|
382
|
+
*/
|
|
383
|
+
function fileDiagnostic(severity, code, message) {
|
|
384
|
+
return { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, severity, source: 'flecto', code, message };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @param {number} line 1-based
|
|
389
|
+
* @param {number} severity
|
|
390
|
+
* @param {string} code
|
|
391
|
+
* @param {string} message
|
|
392
|
+
* @returns {Diagnostic}
|
|
393
|
+
*/
|
|
394
|
+
function lineDiagnostic(line, severity, code, message) {
|
|
395
|
+
const at = Math.max(0, line - 1);
|
|
396
|
+
return { range: { start: { line: at, character: 0 }, end: { line: at, character: 0 } }, severity, source: 'flecto', code, message };
|
|
397
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parentPort } from 'worker_threads';
|
|
2
|
+
|
|
3
|
+
import { analyzeDocument } from './lsp-analysis.js';
|
|
4
|
+
|
|
5
|
+
// One job at a time: the server only posts the next once this one answers, and
|
|
6
|
+
// abandons a job by terminating the whole thread rather than asking it to stop.
|
|
7
|
+
parentPort?.on('message', async (job) => {
|
|
8
|
+
try {
|
|
9
|
+
parentPort?.postMessage({ id: job.id, diagnostics: await analyzeDocument(job) });
|
|
10
|
+
} catch (err) {
|
|
11
|
+
parentPort?.postMessage({ id: job.id, error: err?.message ?? String(err) });
|
|
12
|
+
}
|
|
13
|
+
});
|