@alint-js/cli 0.4.0 → 0.6.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/dist/index.d.mts CHANGED
@@ -6,11 +6,19 @@ interface CliIo {
6
6
  cwd: string;
7
7
  env?: NodeJS.ProcessEnv;
8
8
  stderr: CliWritable;
9
- stdin?: {
10
- isTTY?: boolean;
11
- };
9
+ stdin?: CliReadable;
12
10
  stdout: CliWritable;
13
11
  }
12
+ /**
13
+ * A readable stream plus the TTY flag that the interactive commands read.
14
+ *
15
+ * `StreamMessageReader` in `vscode-jsonrpc` requires a full `NodeJS.ReadableStream`, so a narrower
16
+ * structural type does not compile. `PassThrough` satisfies it, which is how the protocol tests
17
+ * supply an in-memory stream.
18
+ */
19
+ interface CliReadable extends NodeJS.ReadableStream {
20
+ isTTY?: boolean;
21
+ }
14
22
  interface CliWritable {
15
23
  columns?: number;
16
24
  isTTY?: boolean;
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-DFWnPI6m.mjs";
1
+ import { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-cQihuHqx.mjs";
2
2
  import { ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated } from "@alint-js/config";
3
3
  import { defineConfig } from "@alint-js/core";
4
4
  export { defineConfig, executeCli, formatDiagnostics, formatJson, formatStylish, ignorePatternsAIAgents, ignorePatternsBuildOutputs, ignorePatternsCaches, ignorePatternsCommon, ignorePatternsEslintDefaults, ignorePatternsGenerated };
@@ -0,0 +1,368 @@
1
+ import { a as createRunSession } from "./cli-cQihuHqx.mjs";
2
+ import { realpath } from "node:fs/promises";
3
+ import { AlintRunCancelledError, AlintRunError } from "@alint-js/core";
4
+ import { errorMessageFrom } from "@moeru/std";
5
+ import { join, relative, sep } from "node:path";
6
+ import { Writable } from "node:stream";
7
+ import { DidChangeWatchedFilesNotification } from "vscode-languageserver";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ //#region src/cli/commands/lsp/diagnostics.ts
10
+ /**
11
+ * End column for a whole-line range.
12
+ *
13
+ * NOTICE: LSP clamps a character above the line length back to the line length, so the server can
14
+ * mark a whole line without reading the file. Reading every file would cost a second pass over the
15
+ * workspace. The value must stay inside `uinteger` (0..2^31-1), so it is not `MAX_SAFE_INTEGER`.
16
+ * https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
17
+ */
18
+ const WHOLE_LINE = 2147483647;
19
+ /** alint reports 1-based lines and 0-based columns. LSP uses 0-based for both. */
20
+ function toLspDiagnostic(diagnostic) {
21
+ return {
22
+ code: diagnostic.ruleId,
23
+ message: diagnostic.message,
24
+ range: toRange(diagnostic),
25
+ severity: diagnostic.severity === "error" ? 1 : 2,
26
+ source: "alint"
27
+ };
28
+ }
29
+ function toLineIndex(line) {
30
+ return Math.max(line - 1, 0);
31
+ }
32
+ function toRange(diagnostic) {
33
+ const { loc } = diagnostic;
34
+ if (!loc) return {
35
+ end: {
36
+ character: 0,
37
+ line: 0
38
+ },
39
+ start: {
40
+ character: 0,
41
+ line: 0
42
+ }
43
+ };
44
+ const line = toLineIndex(loc.start.line);
45
+ if (!loc.end) return {
46
+ end: {
47
+ character: WHOLE_LINE,
48
+ line
49
+ },
50
+ start: {
51
+ character: 0,
52
+ line
53
+ }
54
+ };
55
+ return {
56
+ end: {
57
+ character: loc.end.column,
58
+ line: toLineIndex(loc.end.line)
59
+ },
60
+ start: {
61
+ character: loc.start.column,
62
+ line
63
+ }
64
+ };
65
+ }
66
+ //#endregion
67
+ //#region src/cli/commands/lsp/folder.ts
68
+ /** One config write arrives as several filesystem events. Wait for them before running a pass. */
69
+ const WORKSPACE_PASS_DEBOUNCE_MS = 500;
70
+ /**
71
+ * One workspace folder. It keeps two paths, and they are not interchangeable.
72
+ *
73
+ * Runs use `cwd`, the resolved path, because `process.cwd()` is always canonical and the lint
74
+ * command therefore always runs under a resolved path. Under any other path each target gets a
75
+ * different identity: the cache file is found, no entry matches, and no error is reported.
76
+ *
77
+ * Published diagnostics use `folderPath`, the path the client sent. See `toDocumentUri`.
78
+ */
79
+ async function createFolderSession(options) {
80
+ const folderPath = fileURLToPath(options.folderUri);
81
+ const cwd = await realpath(folderPath);
82
+ const runIo = {
83
+ ...options.io,
84
+ cwd
85
+ };
86
+ const paths = {
87
+ cwd,
88
+ folderPath
89
+ };
90
+ let session = await createRunSession(runIo);
91
+ let diagnostics = /* @__PURE__ */ new Map();
92
+ let scheduled;
93
+ const replaceMap = (next) => {
94
+ const changed = /* @__PURE__ */ new Set([...next.keys(), ...diagnostics.keys()]);
95
+ diagnostics = next;
96
+ return [...changed];
97
+ };
98
+ return {
99
+ cwd,
100
+ get diagnostics() {
101
+ return diagnostics;
102
+ },
103
+ dispose: async () => {
104
+ if (scheduled !== void 0) {
105
+ clearTimeout(scheduled);
106
+ scheduled = void 0;
107
+ }
108
+ await session.shutdown();
109
+ },
110
+ folderUri: options.folderUri,
111
+ refreshFile: async (uri) => {
112
+ const filePath = toRunPath(uri, paths);
113
+ if (filePath === void 0) return [];
114
+ const found = groupByUri(await runFromCache(session, { inputs: [filePath] }), paths);
115
+ const changed = /* @__PURE__ */ new Set([...found.keys()]);
116
+ const documentUri = toDocumentUri(filePath, paths);
117
+ if (!found.has(documentUri) && diagnostics.delete(documentUri)) changed.add(documentUri);
118
+ for (const [key, value] of found) diagnostics.set(key, value);
119
+ return [...changed];
120
+ },
121
+ refreshFromCache: async () => {
122
+ replaceMap(groupByUri(await runFromCache(session), paths));
123
+ },
124
+ reloadConfig: async () => {
125
+ const replaced = session;
126
+ session = await createRunSession(runIo);
127
+ await replaced.shutdown();
128
+ },
129
+ scheduleWorkspacePass: () => {
130
+ if (scheduled !== void 0) return;
131
+ scheduled = setTimeout(() => {
132
+ scheduled = void 0;
133
+ (async () => {
134
+ try {
135
+ options.onChanged?.(replaceMap(groupByUri(await runFromCache(session), paths)));
136
+ } catch (error) {
137
+ options.io.stderr.write(`alint lsp: workspace pass failed: ${errorMessageFrom(error) ?? "unknown error"}\n`);
138
+ }
139
+ })();
140
+ }, WORKSPACE_PASS_DEBOUNCE_MS);
141
+ }
142
+ };
143
+ }
144
+ /** A project rule reports diagnostics for files it did not target, so group by the named file. */
145
+ function groupByUri(result, paths) {
146
+ const grouped = /* @__PURE__ */ new Map();
147
+ for (const diagnostic of result.diagnostics) {
148
+ const uri = toDocumentUri(diagnostic.filePath, paths);
149
+ const existing = grouped.get(uri);
150
+ if (existing) {
151
+ existing.push(toLspDiagnostic(diagnostic));
152
+ continue;
153
+ }
154
+ grouped.set(uri, [toLspDiagnostic(diagnostic)]);
155
+ }
156
+ return grouped;
157
+ }
158
+ async function runFromCache(session, options = {}) {
159
+ try {
160
+ return await session.run({
161
+ cacheOnly: true,
162
+ inputs: options.inputs,
163
+ projectTargets: options.inputs === void 0 ? void 0 : false,
164
+ runner: {
165
+ ...session.runner,
166
+ stats: false
167
+ }
168
+ });
169
+ } catch (error) {
170
+ if (error instanceof AlintRunError || error instanceof AlintRunCancelledError) return error.result;
171
+ throw error;
172
+ }
173
+ }
174
+ /**
175
+ * Converts a run path back to the path the client sent.
176
+ *
177
+ * An editor matches diagnostics by exact URI. A resolved URI attaches them to a document the editor
178
+ * did not open.
179
+ */
180
+ function toDocumentUri(filePath, paths) {
181
+ if (paths.cwd === paths.folderPath || !filePath.startsWith(`${paths.cwd}${sep}`)) return pathToFileURL(filePath).toString();
182
+ return pathToFileURL(join(paths.folderPath, relative(paths.cwd, filePath))).toString();
183
+ }
184
+ /**
185
+ * Converts a document URI into the path a run uses. The inverse of `toDocumentUri`.
186
+ *
187
+ * Returns undefined for a URI outside this folder. A server with several folders uses that to find
188
+ * the folder that owns the file.
189
+ */
190
+ function toRunPath(uri, paths) {
191
+ const filePath = fileURLToPath(uri);
192
+ if (!filePath.startsWith(`${paths.folderPath}${sep}`)) return;
193
+ return join(paths.cwd, relative(paths.folderPath, filePath));
194
+ }
195
+ //#endregion
196
+ //#region src/cli/commands/lsp/publisher.ts
197
+ /**
198
+ * Collects document URIs and publishes each one once per window, open documents first.
199
+ *
200
+ * An LSP publish carries a whole document, so one publish per finding would send the same document
201
+ * many times. A workspace pass can queue thousands of documents, and the open one must not wait
202
+ * for the rest.
203
+ */
204
+ function createPublisher(options) {
205
+ const open = /* @__PURE__ */ new Set();
206
+ const pending = /* @__PURE__ */ new Set();
207
+ let timer;
208
+ const flush = () => {
209
+ timer = void 0;
210
+ const uris = [...pending];
211
+ pending.clear();
212
+ for (const uri of uris.filter((uri) => open.has(uri))) options.publish(uri);
213
+ for (const uri of uris.filter((uri) => !open.has(uri))) options.publish(uri);
214
+ };
215
+ return {
216
+ dispose: () => {
217
+ if (timer !== void 0) {
218
+ clearTimeout(timer);
219
+ timer = void 0;
220
+ }
221
+ pending.clear();
222
+ },
223
+ isOpen: (uri) => open.has(uri),
224
+ queue: (uri) => {
225
+ pending.add(uri);
226
+ timer ??= setTimeout(flush, options.flushMs);
227
+ },
228
+ setOpen: (uri, isOpen) => {
229
+ if (isOpen) {
230
+ open.add(uri);
231
+ return;
232
+ }
233
+ open.delete(uri);
234
+ }
235
+ };
236
+ }
237
+ //#endregion
238
+ //#region src/cli/commands/lsp/server.ts
239
+ /** Long enough to collect a workspace pass into few messages, short enough to keep a save prompt. */
240
+ const PUBLISH_FLUSH_MS = 200;
241
+ /** Two commands, because a workspace run can cost orders of magnitude more than one file. */
242
+ const RUN_FILE_COMMAND = "alint.runFile";
243
+ const RUN_WORKSPACE_COMMAND = "alint.runWorkspace";
244
+ async function startLspServer(io) {
245
+ const { stdin } = io;
246
+ if (!stdin) {
247
+ io.stderr.write("alint lsp needs a readable stdin to speak JSON-RPC over.\n");
248
+ return 2;
249
+ }
250
+ const { createConnection, TextDocumentSyncKind } = await import("vscode-languageserver/node");
251
+ const connection = createConnection(stdin, toWritableStream(io.stdout));
252
+ const folders = /* @__PURE__ */ new Map();
253
+ let folderUris = [];
254
+ let watchesConfig = false;
255
+ /** A document belongs to one folder at most, so the first folder that holds it is the owner. */
256
+ const diagnosticsFor = (uri) => {
257
+ for (const folder of folders.values()) {
258
+ const diagnostics = folder.diagnostics.get(uri);
259
+ if (diagnostics) return diagnostics;
260
+ }
261
+ return [];
262
+ };
263
+ const publisher = createPublisher({
264
+ flushMs: PUBLISH_FLUSH_MS,
265
+ publish: (uri) => connection.sendDiagnostics({
266
+ diagnostics: diagnosticsFor(uri),
267
+ uri
268
+ })
269
+ });
270
+ const openFolder = async (folderUri) => {
271
+ const folder = await createFolderSession({
272
+ folderUri,
273
+ io,
274
+ onChanged: (uris) => uris.forEach(publisher.queue)
275
+ });
276
+ folders.set(folderUri, folder);
277
+ await folder.refreshFromCache();
278
+ for (const uri of folder.diagnostics.keys()) publisher.queue(uri);
279
+ };
280
+ connection.onInitialize((params) => {
281
+ folderUris = resolveFolderUris(params);
282
+ watchesConfig = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
283
+ return { capabilities: {
284
+ executeCommandProvider: {
285
+ commands: [RUN_FILE_COMMAND, RUN_WORKSPACE_COMMAND],
286
+ workDoneProgress: true
287
+ },
288
+ textDocumentSync: {
289
+ change: TextDocumentSyncKind.None,
290
+ openClose: true,
291
+ save: true
292
+ },
293
+ workspace: { workspaceFolders: {
294
+ changeNotifications: true,
295
+ supported: true
296
+ } }
297
+ } };
298
+ });
299
+ connection.onInitialized(() => {
300
+ if (watchesConfig) connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [{ globPattern: "**/alint.config.*" }] });
301
+ Promise.all(folderUris.map(async (folderUri) => {
302
+ try {
303
+ await openFolder(folderUri);
304
+ } catch (error) {
305
+ connection.console.error(`alint: ${folderUri}: ${errorMessageFrom(error) ?? "could not read cached diagnostics"}`);
306
+ }
307
+ }));
308
+ });
309
+ connection.onDidSaveTextDocument(({ textDocument }) => {
310
+ Promise.all([...folders.values()].map(async (folder) => {
311
+ try {
312
+ for (const uri of await folder.refreshFile(textDocument.uri)) publisher.queue(uri);
313
+ } catch (error) {
314
+ connection.console.error(`alint: ${textDocument.uri}: ${errorMessageFrom(error) ?? "could not refresh the saved file"}`);
315
+ }
316
+ }));
317
+ });
318
+ connection.onDidOpenTextDocument(({ textDocument }) => {
319
+ publisher.setOpen(textDocument.uri, true);
320
+ publisher.queue(textDocument.uri);
321
+ });
322
+ connection.onDidCloseTextDocument(({ textDocument }) => {
323
+ publisher.setOpen(textDocument.uri, false);
324
+ });
325
+ connection.onDidChangeWatchedFiles(() => {
326
+ Promise.all([...folders.values()].map(async (folder) => {
327
+ try {
328
+ await folder.reloadConfig();
329
+ folder.scheduleWorkspacePass();
330
+ } catch (error) {
331
+ connection.console.error(`alint: ${folder.folderUri}: ${errorMessageFrom(error) ?? "could not reload the config"}`);
332
+ }
333
+ }));
334
+ connection.console.info("alint: config changed; cached diagnostics were cleared and need a new run");
335
+ });
336
+ connection.onShutdown(async () => {
337
+ publisher.dispose();
338
+ await Promise.all([...folders.values()].map((folder) => folder.dispose()));
339
+ folders.clear();
340
+ });
341
+ connection.listen();
342
+ return new Promise((resolve) => {
343
+ connection.onExit(() => resolve(0));
344
+ });
345
+ }
346
+ /**
347
+ * A client can send `workspaceFolders`, the older `rootUri`, or both.
348
+ *
349
+ * Only a `file:` URI has a directory. A remote or untitled root has no cwd and no cache.
350
+ */
351
+ function resolveFolderUris(params) {
352
+ const uris = params.workspaceFolders?.map((folder) => folder.uri) ?? (params.rootUri === null ? [] : [params.rootUri]);
353
+ return [...new Set(uris)].filter((uri) => uri.startsWith("file://"));
354
+ }
355
+ /**
356
+ * Adapts the CLI string output to the byte stream that `createConnection` requires.
357
+ *
358
+ * `String(chunk)` cannot split a UTF-8 sequence here. The message writer sends one ASCII header,
359
+ * then one complete body, so every chunk is whole.
360
+ */
361
+ function toWritableStream(sink) {
362
+ return new Writable({ write(chunk, _encoding, callback) {
363
+ sink.write(String(chunk));
364
+ callback();
365
+ } });
366
+ }
367
+ //#endregion
368
+ export { startLspServer };
package/package.json CHANGED
@@ -1,7 +1,22 @@
1
1
  {
2
2
  "name": "@alint-js/cli",
3
3
  "type": "module",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
+ "description": "Agentic code analysis CLI for model-backed lint rules",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/moeru-ai/alint.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "keywords": [
13
+ "alint",
14
+ "lint",
15
+ "linter",
16
+ "ai",
17
+ "code-analysis",
18
+ "cli"
19
+ ],
5
20
  "exports": {
6
21
  ".": {
7
22
  "types": "./dist/index.d.mts",
@@ -21,22 +36,25 @@
21
36
  "cac": "^7.0.0",
22
37
  "cli-spinners": "^3.4.0",
23
38
  "date-fns": "^4.4.0",
39
+ "diff": "^9.0.0",
24
40
  "fast-string-truncated-width": "^3.0.3",
25
41
  "fast-string-width": "^3.0.2",
26
42
  "gitignore-fs": "^2.2.3",
27
43
  "minimatch": "^10.2.6",
28
44
  "pathe": "^2.0.3",
29
45
  "table": "^6.9.0",
46
+ "tinyexec": "^1.2.4",
30
47
  "tinyrainbow": "^3.1.1",
31
- "@alint-js/model-adapter-acp": "0.4.0",
32
- "@alint-js/core": "0.4.0",
33
- "@alint-js/config": "0.4.0"
48
+ "vscode-languageserver": "^10.1.0",
49
+ "@alint-js/config": "0.6.0",
50
+ "@alint-js/core": "0.6.0",
51
+ "@alint-js/utils": "0.6.0",
52
+ "@alint-js/model-adapter-acp": "0.6.0"
34
53
  },
35
54
  "devDependencies": {
36
55
  "@pnpm/find-workspace-dir": "^1000.1.5",
37
56
  "local-pkg": "^1.2.1",
38
- "mlly": "^1.8.2",
39
- "tinyexec": "^1.2.4"
57
+ "mlly": "^1.8.2"
40
58
  },
41
59
  "scripts": {
42
60
  "alint": "node ./dist/bin/index.mjs",