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.
@@ -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
+ });