@alint-js/cli 0.3.2 → 0.5.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 +66 -0
- package/dist/bin/index.mjs +1 -1
- package/dist/{cli-CP8pWPS7.mjs → cli-ChymU1Wr.mjs} +488 -127
- package/dist/index.d.mts +11 -3
- package/dist/index.mjs +1 -1
- package/dist/server-DgJa6R5l.mjs +368 -0
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -207,6 +207,43 @@ alint --format json src > alint-output.json
|
|
|
207
207
|
alint output inspect alint-output.json
|
|
208
208
|
```
|
|
209
209
|
|
|
210
|
+
### Editor Integration
|
|
211
|
+
|
|
212
|
+
> **Work in progress.** `alint lsp` currently publishes cached diagnostics when an editor opens a
|
|
213
|
+
> workspace, and nothing more. Saving a file does not refresh it, changing `alint.config.ts` does
|
|
214
|
+
> not reload it, and the run commands below are advertised but not yet implemented. Restart the
|
|
215
|
+
> server to pick up either kind of change.
|
|
216
|
+
|
|
217
|
+
`alint lsp` runs alint as a language server over stdin and stdout.
|
|
218
|
+
|
|
219
|
+
It is cache-first. The server reads diagnostics that earlier runs already stored and **never calls
|
|
220
|
+
a model on its own**, so opening a workspace costs nothing. A cold cache therefore shows nothing —
|
|
221
|
+
run `alint` once to populate it.
|
|
222
|
+
|
|
223
|
+
Point any LSP editor at the command. In Neovim:
|
|
224
|
+
|
|
225
|
+
```lua
|
|
226
|
+
vim.lsp.config.alint = {
|
|
227
|
+
cmd = { 'alint', 'lsp' },
|
|
228
|
+
root_markers = { 'alint.config.ts' },
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The server declares two commands, `alint.runFile` and `alint.runWorkspace`, which will start runs
|
|
233
|
+
that call models and spend tokens. Both return `MethodNotFound` today.
|
|
234
|
+
|
|
235
|
+
A VS Code extension lives in `apps/vscode`. It starts the server and shows the diagnostics; it is
|
|
236
|
+
not published to the marketplace yet. To run it from a checkout:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
pnpm -F @alint-js/vscode build
|
|
240
|
+
code --extensionDevelopmentPath=apps/vscode /path/to/your/project
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
It resolves the alint executable from the `alint.path` setting, then `node_modules/.bin/alint` in
|
|
244
|
+
the workspace folder, then `PATH`. The workspace install wins so that the server and the alint you
|
|
245
|
+
run in a terminal share one cache.
|
|
246
|
+
|
|
210
247
|
## Concepts
|
|
211
248
|
|
|
212
249
|
`alint` keeps the familiar lint shape: select targets, apply named rules, report diagnostics, and return an exit code that CI can understand. The difference is that a rule can reach its judgment through model calls or a tool-using agent when syntax-only checks are not enough.
|
|
@@ -244,6 +281,35 @@ thinking = { type = "disabled" }
|
|
|
244
281
|
|
|
245
282
|
`default_params` is merged into every chat request for that model. Use it for provider-specific request fields that are not covered by the rest of the model entry.
|
|
246
283
|
|
|
284
|
+
The CLI can also launch an ACP coding-agent command and expose it to rules as an ordinary model. Interactive `alint setup` provides presets for Claude Code, Codex, Gemini CLI, Kimi Code CLI, and OpenCode. Put a custom machine-specific command in the global setup config, or in `.alint/config.toml` when the repository standardizes the same command for every contributor:
|
|
285
|
+
|
|
286
|
+
```toml
|
|
287
|
+
version = 1
|
|
288
|
+
|
|
289
|
+
[[providers]]
|
|
290
|
+
id = "acp"
|
|
291
|
+
|
|
292
|
+
[[providers.models]]
|
|
293
|
+
id = "codex"
|
|
294
|
+
name = "Codex ACP"
|
|
295
|
+
driver = "acp"
|
|
296
|
+
aliases = [ "default" ]
|
|
297
|
+
capabilities = [ "tool-call" ]
|
|
298
|
+
command = "codex-acp"
|
|
299
|
+
args = []
|
|
300
|
+
cwd = "."
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Then select it like any other model:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
alint --model acp/codex src
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
`alint.config.ts` remains the shareable lint policy: it selects models, plugins, and rules but does not launch processes. The CLI merges global setup with `.alint/config.toml`, starts a loopback OpenAI-compatible adapter for the run, and shuts it down afterward. Each concurrent model request gets its own ACP process, so `--rule-concurrency` also bounds how many coding-agent processes a run can require.
|
|
310
|
+
|
|
311
|
+
The command inherits the environment that launched `alint`. Optional `[providers.models.env]` entries override individual variables, but setup TOML does not interpolate variables. Do not commit credentials into the table. Request tools require the ACP agent to support MCP over HTTP.
|
|
312
|
+
|
|
247
313
|
`alint` structured output forces a tool call (`tool_choice`). Some models, such as DeepSeek V4, reject that combination while thinking/reasoning is enabled. For those models, set `thinking = { type = "disabled" }` as above so structured output can run.
|
|
248
314
|
|
|
249
315
|
Note that `-N` stands for `--no-interactive`, which means this is not an interactive setup. TUI is not required, so you can ask Codex or Claude Code to run this command for you.
|
package/dist/bin/index.mjs
CHANGED
|
@@ -2,12 +2,13 @@ import process from "node:process";
|
|
|
2
2
|
import { inspect } from "node:util";
|
|
3
3
|
import { cac } from "cac";
|
|
4
4
|
import { appendFile, mkdir, opendir, readFile, readdir, rm, stat } from "node:fs/promises";
|
|
5
|
-
import { addProviderModels, getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, pruneProviderModels, removeProviderModels, replaceSetupProvider, setProviderEndpoint, setProviderHeader, unsetProviderHeader, writeSetupConfig } from "@alint-js/config";
|
|
5
|
+
import { addProviderModels, getGlobalSetupConfigPath, getProjectSetupConfigPath, getStatsDir, installStaticPlugins, isAcpModel, loadAlintConfig, loadSetupConfig, mergeSetupConfigs, normalizeSetupConfig, pruneProviderModels, removeProviderModels, replaceSetupProvider, setProviderEndpoint, setProviderHeader, unsetProviderHeader, writeSetupConfig } from "@alint-js/config";
|
|
6
6
|
import { AlintRunCancelledError, AlintRunError, benchmarkModels, hasDiscoveryFilePatterns, matchesDiscoveryFile, normalizeConfig, resolveConfigForDirectory, resolveConfigForFile, runAlint } from "@alint-js/core";
|
|
7
7
|
import { isAbsolute, join, relative, resolve } from "pathe";
|
|
8
8
|
import { errorMessageFrom } from "@moeru/std/error";
|
|
9
9
|
import { getBorderCharacters, table } from "table";
|
|
10
10
|
import { createColors } from "tinyrainbow";
|
|
11
|
+
import { createCommandModel, startGateway } from "@alint-js/model-adapter-acp";
|
|
11
12
|
import cliSpinners from "cli-spinners";
|
|
12
13
|
import fastStringWidth from "fast-string-width";
|
|
13
14
|
import { relative as relative$1 } from "node:path";
|
|
@@ -17,7 +18,7 @@ import Gitignore from "gitignore-fs";
|
|
|
17
18
|
import { Minimatch, minimatch } from "minimatch";
|
|
18
19
|
import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
|
|
19
20
|
//#region package.json
|
|
20
|
-
var version = "0.
|
|
21
|
+
var version = "0.5.0";
|
|
21
22
|
//#endregion
|
|
22
23
|
//#region src/cli/output.ts
|
|
23
24
|
function escapeLineValue(value) {
|
|
@@ -46,6 +47,7 @@ function dispatchCommand(context, node, args, options, path) {
|
|
|
46
47
|
const [subcommand, ...restArgs] = args;
|
|
47
48
|
const child = node.children?.find((item) => item.name === subcommand || item.alias?.includes(subcommand ?? ""));
|
|
48
49
|
if (child) return dispatchCommand(context, child, restArgs, options, [...path, child.name]);
|
|
50
|
+
if (subcommand === void 0 && node.children?.length && !node.action) return Promise.resolve(reportMissingSubcommand(context, path, node.children));
|
|
49
51
|
if (!node.action) return Promise.resolve(reportUnknownCommand(context, path, args));
|
|
50
52
|
if (node.exactArguments === true) {
|
|
51
53
|
const unexpectedArgument = args[node.arguments?.split(/\s+/u).filter(Boolean).length ?? 0];
|
|
@@ -169,6 +171,19 @@ function registerRootCommand(cli, node, context, setPendingResult) {
|
|
|
169
171
|
return setPendingResult(node.children ? dispatchCommand(context, node, args[0] ?? [], options, [node.name]) : Promise.resolve(node.action?.(context, ...args.slice(0, -1), options) ?? 0));
|
|
170
172
|
});
|
|
171
173
|
}
|
|
174
|
+
function reportMissingSubcommand(context, path, children) {
|
|
175
|
+
const command = path.join(" ");
|
|
176
|
+
context.io.stderr.write([
|
|
177
|
+
`missing subcommand for "${command}"`,
|
|
178
|
+
"",
|
|
179
|
+
"available subcommands:",
|
|
180
|
+
...children.map((child) => ` ${child.name}`),
|
|
181
|
+
"",
|
|
182
|
+
`run \`alint ${command} --help\` for more information`,
|
|
183
|
+
""
|
|
184
|
+
].join("\n"));
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
172
187
|
function reportUnknownCommand(context, path, args) {
|
|
173
188
|
context.io.stderr.write(`unknown command: ${formatUnknownCommand(path, args)}\n`);
|
|
174
189
|
return 2;
|
|
@@ -296,12 +311,14 @@ const providerSetupSources = [
|
|
|
296
311
|
benchmarkConcurrency: 2,
|
|
297
312
|
defaultEndpoint: "http://127.0.0.1:8317/v1",
|
|
298
313
|
defaultProviderId: "CLIProxyAPI",
|
|
314
|
+
kind: "openai-compatible",
|
|
299
315
|
label: "CLIProxyAPI",
|
|
300
316
|
probeModels: true,
|
|
301
317
|
value: "cliProxyApi"
|
|
302
318
|
},
|
|
303
319
|
{
|
|
304
320
|
benchmarkConcurrency: 2,
|
|
321
|
+
kind: "openai-compatible",
|
|
305
322
|
label: "Custom OpenAI-compatible provider",
|
|
306
323
|
probeModels: true,
|
|
307
324
|
value: "custom"
|
|
@@ -310,6 +327,7 @@ const providerSetupSources = [
|
|
|
310
327
|
benchmarkConcurrency: 20,
|
|
311
328
|
defaultEndpoint: "https://openrouter.ai/api/v1",
|
|
312
329
|
defaultProviderId: "openrouter",
|
|
330
|
+
kind: "openai-compatible",
|
|
313
331
|
label: "OpenRouter",
|
|
314
332
|
probeModels: true,
|
|
315
333
|
value: "openrouter"
|
|
@@ -318,6 +336,7 @@ const providerSetupSources = [
|
|
|
318
336
|
benchmarkConcurrency: 2,
|
|
319
337
|
defaultEndpoint: "https://api.cerebras.ai/v1",
|
|
320
338
|
defaultProviderId: "cerebras",
|
|
339
|
+
kind: "openai-compatible",
|
|
321
340
|
label: "Cerebras",
|
|
322
341
|
probeModels: true,
|
|
323
342
|
value: "cerebras"
|
|
@@ -326,6 +345,7 @@ const providerSetupSources = [
|
|
|
326
345
|
benchmarkConcurrency: 2,
|
|
327
346
|
defaultEndpoint: "https://api.groq.com/openai/v1",
|
|
328
347
|
defaultProviderId: "groq",
|
|
348
|
+
kind: "openai-compatible",
|
|
329
349
|
label: "Groq",
|
|
330
350
|
probeModels: true,
|
|
331
351
|
value: "groq"
|
|
@@ -333,15 +353,81 @@ const providerSetupSources = [
|
|
|
333
353
|
{
|
|
334
354
|
benchmarkConcurrency: 2,
|
|
335
355
|
defaultEndpoint: "http://localhost:11434/v1",
|
|
356
|
+
kind: "openai-compatible",
|
|
336
357
|
label: "Ollama",
|
|
337
358
|
probeModels: true,
|
|
338
359
|
value: "ollama"
|
|
339
360
|
},
|
|
340
361
|
{
|
|
341
362
|
benchmarkConcurrency: 2,
|
|
363
|
+
kind: "openai-compatible",
|
|
342
364
|
label: "Manual model entry",
|
|
343
365
|
probeModels: false,
|
|
344
366
|
value: "manual"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
args: ["-y", "@agentclientprotocol/claude-agent-acp"],
|
|
370
|
+
command: "npx",
|
|
371
|
+
defaultProviderId: "claude-code",
|
|
372
|
+
kind: "acp",
|
|
373
|
+
label: "Claude Code (ACP)",
|
|
374
|
+
model: {
|
|
375
|
+
id: "claude-code",
|
|
376
|
+
name: "Claude Code"
|
|
377
|
+
},
|
|
378
|
+
value: "claudeCodeAcp"
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
args: ["-y", "@agentclientprotocol/codex-acp"],
|
|
382
|
+
command: "npx",
|
|
383
|
+
defaultProviderId: "codex",
|
|
384
|
+
kind: "acp",
|
|
385
|
+
label: "Codex (ACP)",
|
|
386
|
+
model: {
|
|
387
|
+
id: "codex",
|
|
388
|
+
name: "Codex"
|
|
389
|
+
},
|
|
390
|
+
value: "codexAcp"
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
args: [
|
|
394
|
+
"-y",
|
|
395
|
+
"@google/gemini-cli",
|
|
396
|
+
"--acp"
|
|
397
|
+
],
|
|
398
|
+
command: "npx",
|
|
399
|
+
defaultProviderId: "gemini-cli",
|
|
400
|
+
kind: "acp",
|
|
401
|
+
label: "Gemini CLI (ACP)",
|
|
402
|
+
model: {
|
|
403
|
+
id: "gemini-cli",
|
|
404
|
+
name: "Gemini CLI"
|
|
405
|
+
},
|
|
406
|
+
value: "geminiCliAcp"
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
args: ["acp"],
|
|
410
|
+
command: "kimi",
|
|
411
|
+
defaultProviderId: "kimi-code",
|
|
412
|
+
kind: "acp",
|
|
413
|
+
label: "Kimi Code CLI (ACP)",
|
|
414
|
+
model: {
|
|
415
|
+
id: "kimi-code",
|
|
416
|
+
name: "Kimi Code CLI"
|
|
417
|
+
},
|
|
418
|
+
value: "kimiCliAcp"
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
args: ["acp"],
|
|
422
|
+
command: "opencode",
|
|
423
|
+
defaultProviderId: "opencode",
|
|
424
|
+
kind: "acp",
|
|
425
|
+
label: "OpenCode (ACP)",
|
|
426
|
+
model: {
|
|
427
|
+
id: "opencode",
|
|
428
|
+
name: "OpenCode"
|
|
429
|
+
},
|
|
430
|
+
value: "openCodeAcp"
|
|
345
431
|
}
|
|
346
432
|
];
|
|
347
433
|
function buildModelsUrl(endpoint) {
|
|
@@ -379,7 +465,7 @@ function findProviderSetupSource(value) {
|
|
|
379
465
|
}
|
|
380
466
|
function findProviderSetupSourceByEndpoint(endpoint) {
|
|
381
467
|
const normalizedEndpoint = endpoint instanceof URL ? endpoint.toString() : new URL(endpoint).toString();
|
|
382
|
-
return providerSetupSources.find((source) => source.defaultEndpoint !== void 0 && new URL(source.defaultEndpoint).toString() === normalizedEndpoint);
|
|
468
|
+
return providerSetupSources.find((source) => source.kind === "openai-compatible" && source.defaultEndpoint !== void 0 && new URL(source.defaultEndpoint).toString() === normalizedEndpoint);
|
|
383
469
|
}
|
|
384
470
|
function flattenModels(config) {
|
|
385
471
|
return config.providers.flatMap((provider) => provider.models.map((model) => ({
|
|
@@ -520,16 +606,16 @@ function formatProviderList(config) {
|
|
|
520
606
|
"models"
|
|
521
607
|
], ...config.providers.map((provider) => [
|
|
522
608
|
provider.id,
|
|
523
|
-
provider.type,
|
|
524
|
-
provider.endpoint,
|
|
609
|
+
provider.type ?? "model-driven",
|
|
610
|
+
provider.endpoint ?? "-",
|
|
525
611
|
String(provider.models.length)
|
|
526
612
|
])]);
|
|
527
613
|
}
|
|
528
614
|
function formatProviderShow(provider) {
|
|
529
615
|
const lines = [
|
|
530
616
|
`id: ${provider.id}`,
|
|
531
|
-
`type: ${provider.type}`,
|
|
532
|
-
`endpoint: ${provider.endpoint}`,
|
|
617
|
+
`type: ${provider.type ?? "model-driven"}`,
|
|
618
|
+
`endpoint: ${provider.endpoint ?? "-"}`,
|
|
533
619
|
`models: ${provider.models.map((model) => model.id).join(", ")}`
|
|
534
620
|
];
|
|
535
621
|
const headerKeys = Object.keys(provider.headers ?? {});
|
|
@@ -560,7 +646,7 @@ async function probeModels(endpoint, headers = {}) {
|
|
|
560
646
|
return body.data.map((model) => model.id);
|
|
561
647
|
}
|
|
562
648
|
function resolveProviderBenchmarkConcurrency(config, overrides = []) {
|
|
563
|
-
const concurrency = Object.fromEntries(config.providers.map((provider) => [provider.id, findProviderSetupSourceByEndpoint(provider.endpoint)?.benchmarkConcurrency ?? defaultBenchmarkConcurrency]));
|
|
649
|
+
const concurrency = Object.fromEntries(config.providers.map((provider) => [provider.id, provider.endpoint === void 0 ? defaultBenchmarkConcurrency : findProviderSetupSourceByEndpoint(provider.endpoint)?.benchmarkConcurrency ?? defaultBenchmarkConcurrency]));
|
|
564
650
|
for (const override of overrides) {
|
|
565
651
|
const separatorIndex = override.indexOf("=");
|
|
566
652
|
if (separatorIndex <= 0 || separatorIndex === override.length - 1) throw new Error("Invalid benchmark concurrency override. Expected <provider-id>=<limit>.");
|
|
@@ -645,6 +731,89 @@ function stripTrailingZeros(value) {
|
|
|
645
731
|
return String(Number(value));
|
|
646
732
|
}
|
|
647
733
|
//#endregion
|
|
734
|
+
//#region src/cli/runtime/model-adapter.ts
|
|
735
|
+
/**
|
|
736
|
+
* Normalizes file configuration and starts its model adapters.
|
|
737
|
+
*
|
|
738
|
+
* Triggering workflow:
|
|
739
|
+
*
|
|
740
|
+
* `runLintCommand | runListModelsCommand`
|
|
741
|
+
* -> {@link startModelAdapters}
|
|
742
|
+
* -> {@link createAcpAdapter}
|
|
743
|
+
* -> {@link normalizeSetupConfig}
|
|
744
|
+
* -> `AcpAdapter.provider`
|
|
745
|
+
* -> {@link startGateway}
|
|
746
|
+
*
|
|
747
|
+
* Upstream:
|
|
748
|
+
* - `runLintCommand`
|
|
749
|
+
* - `runListModelsCommand`
|
|
750
|
+
*
|
|
751
|
+
* Downstream:
|
|
752
|
+
* - {@link normalizeSetupConfig}
|
|
753
|
+
* - {@link startGateway}
|
|
754
|
+
*/
|
|
755
|
+
async function startModelAdapters(config, io) {
|
|
756
|
+
const acp = createAcpAdapter(io);
|
|
757
|
+
try {
|
|
758
|
+
return {
|
|
759
|
+
setupConfig: await normalizeSetupConfig(config, { acp: acp.provider }),
|
|
760
|
+
shutdown: acp.shutdown
|
|
761
|
+
};
|
|
762
|
+
} catch (error) {
|
|
763
|
+
await acp.shutdown();
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** Owns the ACP processes and loopback gateways used by one CLI operation. */
|
|
768
|
+
function createAcpAdapter(io) {
|
|
769
|
+
const shutdowns = [];
|
|
770
|
+
return {
|
|
771
|
+
provider: async (provider) => {
|
|
772
|
+
const gateway = await startGateway({
|
|
773
|
+
cwd: io.cwd,
|
|
774
|
+
models: provider.models.map((model) => createCommandModel({
|
|
775
|
+
args: model.args,
|
|
776
|
+
command: model.command,
|
|
777
|
+
cwd: resolve(io.cwd, model.cwd ?? "."),
|
|
778
|
+
env: {
|
|
779
|
+
...io.env ?? process.env,
|
|
780
|
+
...model.env
|
|
781
|
+
},
|
|
782
|
+
id: model.id,
|
|
783
|
+
name: model.name ?? model.id,
|
|
784
|
+
onStderr: createStderrWriter(io, model.id)
|
|
785
|
+
}))
|
|
786
|
+
});
|
|
787
|
+
shutdowns.push(gateway.shutdown);
|
|
788
|
+
return {
|
|
789
|
+
endpoint: gateway.endpoint,
|
|
790
|
+
type: "openai-compatible"
|
|
791
|
+
};
|
|
792
|
+
},
|
|
793
|
+
shutdown: async () => {
|
|
794
|
+
await Promise.all(shutdowns.map((shutdown) => shutdown()));
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Creates the CLI stderr sink for non-protocol output emitted by an ACP command.
|
|
800
|
+
*
|
|
801
|
+
* Triggering workflow:
|
|
802
|
+
*
|
|
803
|
+
* {@link createCommandModel}
|
|
804
|
+
* -> `ChildProcess.stderr:data`
|
|
805
|
+
* -> {@link createStderrWriter}
|
|
806
|
+
*
|
|
807
|
+
* Upstream:
|
|
808
|
+
* - {@link createAcpAdapter}
|
|
809
|
+
*
|
|
810
|
+
* Downstream:
|
|
811
|
+
* - {@link CliIo.stderr}
|
|
812
|
+
*/
|
|
813
|
+
function createStderrWriter(io, modelId) {
|
|
814
|
+
return (text) => io.stderr.write(`[acp:${modelId}] ${text}`);
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
648
817
|
//#region src/cli/commands/config/setup-config.ts
|
|
649
818
|
function formatUnknownProvider(providerId, scope) {
|
|
650
819
|
const hint = scope === "local" ? " Remove --local to inspect global configuration." : " Add --local to inspect project-local configuration.";
|
|
@@ -820,19 +989,21 @@ async function runListModelsCommand(context, options) {
|
|
|
820
989
|
context.io.stderr.write(`${errorMessageFrom(error) ?? "Invalid benchmark concurrency override."}\n`);
|
|
821
990
|
return 2;
|
|
822
991
|
}
|
|
992
|
+
const runtime = await startModelAdapters(config, context.io);
|
|
823
993
|
const progress = context.io.stderr.isTTY === true ? createModelBenchmarkProgressDisplay({
|
|
824
994
|
color: true,
|
|
825
|
-
config,
|
|
995
|
+
config: runtime.setupConfig,
|
|
826
996
|
output: context.io.stderr
|
|
827
997
|
}) : void 0;
|
|
828
998
|
let benchmarks;
|
|
829
999
|
try {
|
|
830
|
-
benchmarks = await benchmarkModels(
|
|
1000
|
+
benchmarks = await benchmarkModels(runtime.setupConfig, {
|
|
831
1001
|
onProgress: (snapshot) => progress?.update(snapshot),
|
|
832
1002
|
providerConcurrency
|
|
833
1003
|
});
|
|
834
1004
|
} finally {
|
|
835
1005
|
progress?.finish();
|
|
1006
|
+
await runtime.shutdown();
|
|
836
1007
|
}
|
|
837
1008
|
context.io.stdout.write(formatModelBenchmarkList(config, benchmarks, { color: context.io.stdout.isTTY === true }));
|
|
838
1009
|
return 0;
|
|
@@ -941,15 +1112,21 @@ async function runPruneModelsCommand(context, options, confirm = confirmPruneMod
|
|
|
941
1112
|
const plans = [];
|
|
942
1113
|
let hadFailure = false;
|
|
943
1114
|
for (const provider of providers) {
|
|
1115
|
+
const endpoint = provider.endpoint;
|
|
1116
|
+
if (endpoint === void 0) {
|
|
1117
|
+
hadFailure = true;
|
|
1118
|
+
context.io.stderr.write(`cannot prune provider "${escapeLineValue(provider.id)}" because it has no remote endpoint.\n`);
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
944
1121
|
let remoteModelIds;
|
|
945
1122
|
try {
|
|
946
|
-
remoteModelIds = new Set(await probeModels(
|
|
1123
|
+
remoteModelIds = new Set(await probeModels(endpoint, provider.headers));
|
|
947
1124
|
} catch (error) {
|
|
948
1125
|
hadFailure = true;
|
|
949
|
-
context.io.stderr.write(`failed to probe provider "${escapeLineValue(provider.id)}" at "${escapeLineValue(
|
|
1126
|
+
context.io.stderr.write(`failed to probe provider "${escapeLineValue(provider.id)}" at "${escapeLineValue(endpoint)}": ${classifyProbeFailure(error)}.\n`);
|
|
950
1127
|
continue;
|
|
951
1128
|
}
|
|
952
|
-
const removedModels = provider.models.filter((model) => !remoteModelIds.has(model.id));
|
|
1129
|
+
const removedModels = provider.models.filter((model) => !isAcpModel(model) && !remoteModelIds.has(model.id));
|
|
953
1130
|
const defaultModel = removedModels.find((model) => (model.aliases ?? []).includes("default"));
|
|
954
1131
|
if (defaultModel !== void 0) {
|
|
955
1132
|
hadFailure = true;
|
|
@@ -1669,6 +1846,7 @@ function textResult(prompts, result) {
|
|
|
1669
1846
|
*/
|
|
1670
1847
|
async function runProviderEditor(input, promptPort) {
|
|
1671
1848
|
const prompts = promptPort ?? createProviderEditorPrompts(await import("@clack/prompts"));
|
|
1849
|
+
if (input.source.kind === "acp") return runAcpProviderEditor(input, input.source, prompts);
|
|
1672
1850
|
const existingProvider = input.mode === "update" ? input.existingProvider : void 0;
|
|
1673
1851
|
const configuredHeaders = { ...existingProvider?.headers };
|
|
1674
1852
|
const initialHeaders = applyHeaderSelection(configuredHeaders, Object.keys(configuredHeaders), {}) ?? {};
|
|
@@ -1800,6 +1978,30 @@ async function runProviderEditor(input, promptPort) {
|
|
|
1800
1978
|
};
|
|
1801
1979
|
}
|
|
1802
1980
|
}
|
|
1981
|
+
function createAcpProvider(source, providerId) {
|
|
1982
|
+
return {
|
|
1983
|
+
id: providerId,
|
|
1984
|
+
models: [{
|
|
1985
|
+
...source.args === void 0 ? {} : { args: [...source.args] },
|
|
1986
|
+
capabilities: ["tool-call"],
|
|
1987
|
+
command: source.command,
|
|
1988
|
+
driver: "acp",
|
|
1989
|
+
...source.model
|
|
1990
|
+
}]
|
|
1991
|
+
};
|
|
1992
|
+
}
|
|
1993
|
+
function createAcpSummary(input, provider, defaultAliasTarget) {
|
|
1994
|
+
const model = provider.models[0];
|
|
1995
|
+
return [
|
|
1996
|
+
`${input.mode === "create" ? "Create" : "Update"} provider?`,
|
|
1997
|
+
`Provider: ${escapeLineValue(provider.id)}`,
|
|
1998
|
+
"Driver: ACP",
|
|
1999
|
+
`Command: ${escapeLineValue("command" in model ? model.command : "-")}`,
|
|
2000
|
+
`Arguments: ${"args" in model ? formatSummaryValues(model.args ?? []) : "(none)"}`,
|
|
2001
|
+
`Model: ${escapeLineValue(model.id)}`,
|
|
2002
|
+
`Default: ${defaultAliasTarget === void 0 ? "unchanged" : escapeLineValue(`${defaultAliasTarget.providerId}/${defaultAliasTarget.modelId}`)}`
|
|
2003
|
+
].join("\n");
|
|
2004
|
+
}
|
|
1803
2005
|
function createProvider(input, draft) {
|
|
1804
2006
|
return {
|
|
1805
2007
|
...input.existingProvider,
|
|
@@ -1819,7 +2021,7 @@ function createSummary(input, provider, defaultAliasTarget) {
|
|
|
1819
2021
|
return [
|
|
1820
2022
|
`${input.mode === "create" ? "Create" : "Update"} provider?`,
|
|
1821
2023
|
`Provider: ${escapeLineValue(provider.id)}`,
|
|
1822
|
-
`Endpoint: ${escapeLineValue(provider.endpoint)}`,
|
|
2024
|
+
`Endpoint: ${escapeLineValue(provider.endpoint ?? "-")}`,
|
|
1823
2025
|
`Headers: ${formatSummaryValues(Object.keys(provider.headers ?? {}))}`,
|
|
1824
2026
|
`Added models: ${formatSummaryValues(additions)}`,
|
|
1825
2027
|
`Removed models: ${formatSummaryValues(removals)}`,
|
|
@@ -1832,6 +2034,87 @@ function effectiveHeaders(draft) {
|
|
|
1832
2034
|
function formatSummaryValues(values) {
|
|
1833
2035
|
return values.length > 0 ? values.map(escapeLineValue).join(", ") : "(none)";
|
|
1834
2036
|
}
|
|
2037
|
+
/**
|
|
2038
|
+
* Runs the preset-backed ACP branch of the provider editor.
|
|
2039
|
+
*
|
|
2040
|
+
* Triggering workflow:
|
|
2041
|
+
*
|
|
2042
|
+
* {@link runInteractiveSetup}
|
|
2043
|
+
* -> {@link runProviderEditor}
|
|
2044
|
+
* -> `provider-editor.acp-preset`
|
|
2045
|
+
* -> {@link runAcpProviderEditor}
|
|
2046
|
+
*
|
|
2047
|
+
* Upstream:
|
|
2048
|
+
* - {@link runProviderEditor}
|
|
2049
|
+
*
|
|
2050
|
+
* Downstream:
|
|
2051
|
+
* - {@link ProviderEditorResult} and the caller-owned setup-config write
|
|
2052
|
+
*/
|
|
2053
|
+
async function runAcpProviderEditor(input, source, prompts) {
|
|
2054
|
+
let defaultAliasTarget;
|
|
2055
|
+
let providerId = input.existingProvider?.id ?? source.defaultProviderId;
|
|
2056
|
+
let step = "providerId";
|
|
2057
|
+
while (true) {
|
|
2058
|
+
if (step === "providerId") {
|
|
2059
|
+
const result = await prompts.providerId(providerId, input.mode === "create");
|
|
2060
|
+
if (result.status === "cancelled") return { status: "cancelled" };
|
|
2061
|
+
if (result.status === "back") return { status: "back" };
|
|
2062
|
+
providerId = result.value.trim();
|
|
2063
|
+
step = "defaultAction";
|
|
2064
|
+
continue;
|
|
2065
|
+
}
|
|
2066
|
+
const provider = createAcpProvider(source, providerId);
|
|
2067
|
+
if (step === "defaultAction") {
|
|
2068
|
+
const result = await prompts.defaultAction(source.model.id);
|
|
2069
|
+
if (result.status === "cancelled") return { status: "cancelled" };
|
|
2070
|
+
if (result.status === "back") {
|
|
2071
|
+
step = "providerId";
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
defaultAliasTarget = result.value === "yes" ? {
|
|
2075
|
+
modelId: source.model.id,
|
|
2076
|
+
providerId
|
|
2077
|
+
} : void 0;
|
|
2078
|
+
step = result.value === "selectAnother" ? "defaultModel" : "confirm";
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
if (step === "defaultModel") {
|
|
2082
|
+
const candidates = createDefaultModelCandidates(mergeSetupConfigs(input.config, {
|
|
2083
|
+
providers: [provider],
|
|
2084
|
+
version: 1
|
|
2085
|
+
}), provider.id, [source.model.id]);
|
|
2086
|
+
const selectedDefault = await prompts.defaultModel(candidates.map((candidate) => ({
|
|
2087
|
+
hint: candidate.isCurrentDefault ? "current default" : candidate.isNew ? "new" : void 0,
|
|
2088
|
+
label: candidate.label,
|
|
2089
|
+
value: candidate.value
|
|
2090
|
+
})));
|
|
2091
|
+
if (selectedDefault.status === "cancelled") return { status: "cancelled" };
|
|
2092
|
+
if (selectedDefault.status === "back") {
|
|
2093
|
+
step = "defaultAction";
|
|
2094
|
+
continue;
|
|
2095
|
+
}
|
|
2096
|
+
const candidate = candidates.find((item) => item.value === selectedDefault.value);
|
|
2097
|
+
if (candidate === void 0) return { status: "cancelled" };
|
|
2098
|
+
defaultAliasTarget = {
|
|
2099
|
+
modelId: candidate.modelId,
|
|
2100
|
+
providerId: candidate.providerId
|
|
2101
|
+
};
|
|
2102
|
+
step = "confirm";
|
|
2103
|
+
continue;
|
|
2104
|
+
}
|
|
2105
|
+
const confirmed = await prompts.confirm(createAcpSummary(input, provider, defaultAliasTarget));
|
|
2106
|
+
if (confirmed.status === "cancelled" || confirmed.status === "submitted" && confirmed.value === "no") return { status: "cancelled" };
|
|
2107
|
+
if (confirmed.status === "back") {
|
|
2108
|
+
step = "defaultAction";
|
|
2109
|
+
continue;
|
|
2110
|
+
}
|
|
2111
|
+
return {
|
|
2112
|
+
...defaultAliasTarget === void 0 ? {} : { defaultAliasTarget },
|
|
2113
|
+
provider,
|
|
2114
|
+
status: "confirmed"
|
|
2115
|
+
};
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
1835
2118
|
function splitInput(value) {
|
|
1836
2119
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1837
2120
|
}
|
|
@@ -1885,6 +2168,11 @@ function mergeProviderHeaders(existing, replacements) {
|
|
|
1885
2168
|
return Object.keys(headers).length > 0 ? headers : void 0;
|
|
1886
2169
|
}
|
|
1887
2170
|
async function runNonInteractiveUpdate(context, options, providerEndpoint, existingProvider, config, path) {
|
|
2171
|
+
const endpoint = providerEndpoint ?? existingProvider.endpoint;
|
|
2172
|
+
if (endpoint === void 0) {
|
|
2173
|
+
context.io.stderr.write(`provider "${existingProvider.id}" has no remote endpoint to update.\n`);
|
|
2174
|
+
return 2;
|
|
2175
|
+
}
|
|
1888
2176
|
let parsedHeaders;
|
|
1889
2177
|
try {
|
|
1890
2178
|
parsedHeaders = parseHeaderList(toArray$1(options.providerHeader));
|
|
@@ -1894,12 +2182,12 @@ async function runNonInteractiveUpdate(context, options, providerEndpoint, exist
|
|
|
1894
2182
|
}
|
|
1895
2183
|
const draft = {
|
|
1896
2184
|
...existingProvider,
|
|
1897
|
-
endpoint
|
|
2185
|
+
endpoint,
|
|
1898
2186
|
headers: mergeProviderHeaders(existingProvider.headers, parsedHeaders)
|
|
1899
2187
|
};
|
|
1900
2188
|
let remoteModelIds;
|
|
1901
2189
|
try {
|
|
1902
|
-
remoteModelIds = await probeModels(
|
|
2190
|
+
remoteModelIds = await probeModels(endpoint, draft.headers);
|
|
1903
2191
|
} catch (error) {
|
|
1904
2192
|
context.io.stderr.write(`failed to probe provider: ${errorMessageFrom(error) ?? "Unknown error."}\n`);
|
|
1905
2193
|
return 2;
|
|
@@ -1947,13 +2235,21 @@ async function runUpdateProviderCommand(context, options) {
|
|
|
1947
2235
|
context.io.stderr.write(formatUnknownProvider(providerId, scope));
|
|
1948
2236
|
return 2;
|
|
1949
2237
|
}
|
|
2238
|
+
const endpoint = providerEndpoint ?? existingProvider.endpoint;
|
|
2239
|
+
if (endpoint === void 0) {
|
|
2240
|
+
context.io.stderr.write(`provider "${existingProvider.id}" has no remote endpoint to update.\n`);
|
|
2241
|
+
return 2;
|
|
2242
|
+
}
|
|
1950
2243
|
if (context.setupNoInteractive) return runNonInteractiveUpdate(context, options, providerEndpoint, existingProvider, config, path);
|
|
1951
2244
|
const result = await runProviderEditor({
|
|
1952
2245
|
config,
|
|
1953
|
-
existingProvider
|
|
2246
|
+
existingProvider: {
|
|
2247
|
+
...existingProvider,
|
|
2248
|
+
endpoint
|
|
2249
|
+
},
|
|
1954
2250
|
io: context.io,
|
|
1955
2251
|
mode: "update",
|
|
1956
|
-
source: providerUpdateSource(
|
|
2252
|
+
source: providerUpdateSource(endpoint)
|
|
1957
2253
|
});
|
|
1958
2254
|
if (result.status !== "confirmed") return 0;
|
|
1959
2255
|
const replaced = replaceSetupProvider(config, result.provider);
|
|
@@ -2806,6 +3102,96 @@ async function* walkFiles(root, options) {
|
|
|
2806
3102
|
}
|
|
2807
3103
|
}
|
|
2808
3104
|
//#endregion
|
|
3105
|
+
//#region src/cli/runtime/runner.ts
|
|
3106
|
+
/**
|
|
3107
|
+
* Merges the setup config runner with the project config runner. No CLI flag is applied.
|
|
3108
|
+
*
|
|
3109
|
+
* `runner.cache` selects the cache file. The lint command and the language server must merge it
|
|
3110
|
+
* the same way, or the editor reads a different file and reports nothing.
|
|
3111
|
+
*/
|
|
3112
|
+
function mergeRunnerConfigs(setupRunner, configRunner) {
|
|
3113
|
+
const runner = {
|
|
3114
|
+
...setupRunner ?? {},
|
|
3115
|
+
...configRunner ?? {},
|
|
3116
|
+
cache: mergeCacheConfig(setupRunner?.cache, configRunner?.cache),
|
|
3117
|
+
ruleConcurrency: configRunner?.ruleConcurrency ?? setupRunner?.ruleConcurrency,
|
|
3118
|
+
stats: mergeStatsConfig(setupRunner?.stats, configRunner?.stats),
|
|
3119
|
+
timeoutMs: configRunner?.timeoutMs ?? setupRunner?.timeoutMs
|
|
3120
|
+
};
|
|
3121
|
+
return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
|
|
3122
|
+
}
|
|
3123
|
+
/** The last `runner` block in the flattened config wins, key by key. */
|
|
3124
|
+
function resolveConfigRunner(config) {
|
|
3125
|
+
return normalizeConfig(config).reduce((merged, item) => item.runner ? {
|
|
3126
|
+
...merged,
|
|
3127
|
+
...item.runner
|
|
3128
|
+
} : merged, void 0);
|
|
3129
|
+
}
|
|
3130
|
+
function mergeCacheConfig(setupCache, configCache) {
|
|
3131
|
+
if (configCache === void 0) return setupCache;
|
|
3132
|
+
if (typeof configCache === "boolean") return configCache;
|
|
3133
|
+
if (typeof setupCache === "object") return {
|
|
3134
|
+
...setupCache,
|
|
3135
|
+
...configCache
|
|
3136
|
+
};
|
|
3137
|
+
return configCache;
|
|
3138
|
+
}
|
|
3139
|
+
function mergeStatsConfig(setupStats, configStats) {
|
|
3140
|
+
if (configStats === void 0) return setupStats;
|
|
3141
|
+
if (typeof configStats === "boolean") return configStats;
|
|
3142
|
+
if (typeof setupStats === "object") return {
|
|
3143
|
+
...setupStats,
|
|
3144
|
+
...configStats
|
|
3145
|
+
};
|
|
3146
|
+
return configStats;
|
|
3147
|
+
}
|
|
3148
|
+
//#endregion
|
|
3149
|
+
//#region src/cli/runtime/session.ts
|
|
3150
|
+
/**
|
|
3151
|
+
* Loads config, setup config, and model adapters once, then runs any number of times.
|
|
3152
|
+
*
|
|
3153
|
+
* The lint command and the language server both use this function. A second implementation would
|
|
3154
|
+
* resolve config or cwd differently, and the editor would then read a different cache file.
|
|
3155
|
+
*/
|
|
3156
|
+
async function createRunSession(io, options = {}) {
|
|
3157
|
+
const [{ defaultModel, setupConfig: fileSetupConfig }, config] = await Promise.all([loadRunSetupConfig(io), loadAlintConfig(io.cwd, options.configPath)]);
|
|
3158
|
+
const runtime = await (options.startModelAdapters ?? startModelAdapters)(fileSetupConfig, io);
|
|
3159
|
+
const { setupConfig } = runtime;
|
|
3160
|
+
let shutdown;
|
|
3161
|
+
return {
|
|
3162
|
+
config,
|
|
3163
|
+
cwd: io.cwd,
|
|
3164
|
+
defaultModel,
|
|
3165
|
+
run: async (runOptions) => {
|
|
3166
|
+
const targets = await findLintTargets({
|
|
3167
|
+
config,
|
|
3168
|
+
cwd: io.cwd,
|
|
3169
|
+
errorOnUnmatchedPattern: true,
|
|
3170
|
+
globInputPaths: true,
|
|
3171
|
+
inputs: runOptions.files ?? []
|
|
3172
|
+
});
|
|
3173
|
+
return runAlint({
|
|
3174
|
+
cacheOnly: runOptions.cacheOnly,
|
|
3175
|
+
config,
|
|
3176
|
+
cwd: io.cwd,
|
|
3177
|
+
defaultModel,
|
|
3178
|
+
directories: targets.directories,
|
|
3179
|
+
files: targets.files,
|
|
3180
|
+
modelOverride: runOptions.modelOverride,
|
|
3181
|
+
outputLanguage: runOptions.outputLanguage,
|
|
3182
|
+
progress: runOptions.progress,
|
|
3183
|
+
projectTargets: runOptions.projectTargets,
|
|
3184
|
+
runner: runOptions.runner,
|
|
3185
|
+
setupConfig,
|
|
3186
|
+
signal: runOptions.signal
|
|
3187
|
+
});
|
|
3188
|
+
},
|
|
3189
|
+
runner: mergeRunnerConfigs(setupConfig.runner, resolveConfigRunner(config)),
|
|
3190
|
+
setupConfig,
|
|
3191
|
+
shutdown: () => shutdown ??= runtime.shutdown()
|
|
3192
|
+
};
|
|
3193
|
+
}
|
|
3194
|
+
//#endregion
|
|
2809
3195
|
//#region src/cli/commands/lint/errors.ts
|
|
2810
3196
|
const colors$1 = createColors({ force: true });
|
|
2811
3197
|
function formatCancelledError(error, color) {
|
|
@@ -2873,63 +3259,37 @@ function groupFailuresByRule(failures) {
|
|
|
2873
3259
|
}
|
|
2874
3260
|
//#endregion
|
|
2875
3261
|
//#region src/cli/commands/lint/runner.ts
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
function resolveRunnerConfig(
|
|
2883
|
-
const cache = resolveRunnerCacheConfig(setupConfig.runner?.cache, config.runner?.cache, options);
|
|
2884
|
-
const stats = resolveRunnerStatsConfig(setupConfig.runner?.stats, config.runner?.stats, options);
|
|
3262
|
+
/**
|
|
3263
|
+
* Applies this invocation's flags on top of the project's resolved runner.
|
|
3264
|
+
*
|
|
3265
|
+
* The setup/config merge itself lives in `runtime/runner.ts` because the language server needs the
|
|
3266
|
+
* same base without any flags; only the overrides below are CLI-shaped.
|
|
3267
|
+
*/
|
|
3268
|
+
function resolveRunnerConfig(baseRunner, options) {
|
|
2885
3269
|
const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
|
|
2886
3270
|
const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
|
|
2887
3271
|
const runner = {
|
|
2888
|
-
...
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
timeoutMs: timeoutMs ?? config.runner?.timeoutMs ?? setupConfig.runner?.timeoutMs
|
|
3272
|
+
...baseRunner ?? {},
|
|
3273
|
+
cache: resolveCacheOption(baseRunner?.cache, options),
|
|
3274
|
+
ruleConcurrency: ruleConcurrency ?? baseRunner?.ruleConcurrency,
|
|
3275
|
+
stats: options.stats === false ? false : baseRunner?.stats,
|
|
3276
|
+
timeoutMs: timeoutMs ?? baseRunner?.timeoutMs
|
|
2894
3277
|
};
|
|
2895
3278
|
return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
|
|
2896
3279
|
}
|
|
2897
|
-
function mergeRunnerCacheConfig(setupCache, configCache) {
|
|
2898
|
-
if (configCache === void 0) return setupCache;
|
|
2899
|
-
if (typeof configCache === "boolean") return configCache;
|
|
2900
|
-
if (typeof setupCache === "object") return {
|
|
2901
|
-
...setupCache,
|
|
2902
|
-
...configCache
|
|
2903
|
-
};
|
|
2904
|
-
return configCache;
|
|
2905
|
-
}
|
|
2906
|
-
function mergeRunnerStatsConfig(setupStats, configStats) {
|
|
2907
|
-
if (configStats === void 0) return setupStats;
|
|
2908
|
-
if (typeof configStats === "boolean") return configStats;
|
|
2909
|
-
if (typeof setupStats === "object") return {
|
|
2910
|
-
...setupStats,
|
|
2911
|
-
...configStats
|
|
2912
|
-
};
|
|
2913
|
-
return configStats;
|
|
2914
|
-
}
|
|
2915
3280
|
function parsePositiveIntegerOption(value, label) {
|
|
2916
3281
|
if (value === void 0) return;
|
|
2917
3282
|
const parsed = Number(value);
|
|
2918
3283
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer.`);
|
|
2919
3284
|
return parsed;
|
|
2920
3285
|
}
|
|
2921
|
-
function
|
|
3286
|
+
function resolveCacheOption(baseCache, options) {
|
|
2922
3287
|
if (options.cache === false) return false;
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
...configuredCache,
|
|
3288
|
+
if (options.cacheLocation !== void 0) return typeof baseCache === "object" ? {
|
|
3289
|
+
...baseCache,
|
|
2926
3290
|
location: options.cacheLocation
|
|
2927
3291
|
} : { location: options.cacheLocation };
|
|
2928
|
-
return
|
|
2929
|
-
}
|
|
2930
|
-
function resolveRunnerStatsConfig(setupStats, configStats, options) {
|
|
2931
|
-
if (options.stats === false) return false;
|
|
2932
|
-
return mergeRunnerStatsConfig(setupStats, configStats);
|
|
3292
|
+
return baseCache;
|
|
2933
3293
|
}
|
|
2934
3294
|
//#endregion
|
|
2935
3295
|
//#region src/cli/stats/ci.ts
|
|
@@ -3435,84 +3795,84 @@ function isNodeError(error) {
|
|
|
3435
3795
|
}
|
|
3436
3796
|
async function runLintCommand(files, options, io, interceptConsoleOutput) {
|
|
3437
3797
|
if (options.config) await assertConfigExists(io.cwd, options.config);
|
|
3438
|
-
const
|
|
3439
|
-
let lintTargets;
|
|
3798
|
+
const session = await createRunSession(io, { configPath: options.config });
|
|
3440
3799
|
try {
|
|
3441
|
-
|
|
3442
|
-
|
|
3800
|
+
const runner = resolveRunnerConfig(session.runner, options);
|
|
3801
|
+
const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
|
|
3802
|
+
color: io.stderr.isTTY === true,
|
|
3803
|
+
columns: io.stderr.columns ?? 80,
|
|
3443
3804
|
cwd: io.cwd,
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
});
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3805
|
+
isTty: io.stderr.isTTY === true,
|
|
3806
|
+
rows: io.stderr.rows,
|
|
3807
|
+
write: (chunk) => io.stderr.write(chunk)
|
|
3808
|
+
}) : void 0;
|
|
3809
|
+
const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
|
|
3810
|
+
const statsTarget = resolveStatsWrite(runner?.stats, io.env);
|
|
3811
|
+
const statsCollector = statsTarget ? createStatsCollector() : void 0;
|
|
3812
|
+
const persistStats = async (runResult) => {
|
|
3813
|
+
if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
|
|
3814
|
+
};
|
|
3815
|
+
const writeResult = (runResult) => {
|
|
3816
|
+
io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
|
|
3817
|
+
};
|
|
3818
|
+
let result;
|
|
3819
|
+
try {
|
|
3820
|
+
result = await session.run({
|
|
3821
|
+
cacheOnly: options.cacheOnly,
|
|
3822
|
+
files,
|
|
3823
|
+
modelOverride: options.model,
|
|
3824
|
+
outputLanguage: options.outputLanguage,
|
|
3825
|
+
progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
|
|
3826
|
+
runner
|
|
3827
|
+
});
|
|
3828
|
+
} catch (error) {
|
|
3829
|
+
restoreProgressConsole?.();
|
|
3830
|
+
progress?.dispose();
|
|
3831
|
+
if (error instanceof NoFilesFoundError) {
|
|
3832
|
+
io.stderr.write(`${error.message}\n`);
|
|
3833
|
+
return 2;
|
|
3834
|
+
}
|
|
3835
|
+
if (error instanceof AlintRunError) {
|
|
3836
|
+
await persistStats(error.result);
|
|
3837
|
+
writeResult(error.result);
|
|
3838
|
+
io.stderr.write(formatRunError(error, io.stderr.isTTY === true));
|
|
3839
|
+
return 2;
|
|
3840
|
+
}
|
|
3841
|
+
if (error instanceof AlintRunCancelledError) {
|
|
3842
|
+
await persistStats(error.result);
|
|
3843
|
+
writeResult(error.result);
|
|
3844
|
+
io.stderr.write(formatCancelledError(error, io.stderr.isTTY === true));
|
|
3845
|
+
return 2;
|
|
3846
|
+
}
|
|
3847
|
+
throw error;
|
|
3452
3848
|
}
|
|
3453
|
-
throw error;
|
|
3454
|
-
}
|
|
3455
|
-
const runner = resolveRunnerConfig(setupConfig, { runner: resolveConfigRunner(config) }, options);
|
|
3456
|
-
const progress = shouldEnableProgress(options, io) ? createCliProgressReporter({
|
|
3457
|
-
color: io.stderr.isTTY === true,
|
|
3458
|
-
columns: io.stderr.columns ?? 80,
|
|
3459
|
-
cwd: io.cwd,
|
|
3460
|
-
isTty: io.stderr.isTTY === true,
|
|
3461
|
-
rows: io.stderr.rows,
|
|
3462
|
-
write: (chunk) => io.stderr.write(chunk)
|
|
3463
|
-
}) : void 0;
|
|
3464
|
-
const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
|
|
3465
|
-
const statsTarget = resolveStatsWrite(runner?.stats, io.env);
|
|
3466
|
-
const statsCollector = statsTarget ? createStatsCollector() : void 0;
|
|
3467
|
-
const persistStats = async (runResult) => {
|
|
3468
|
-
if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
|
|
3469
|
-
};
|
|
3470
|
-
const writeResult = (runResult) => {
|
|
3471
|
-
io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
|
|
3472
|
-
};
|
|
3473
|
-
let result;
|
|
3474
|
-
try {
|
|
3475
|
-
result = await runAlint({
|
|
3476
|
-
cacheOnly: options.cacheOnly,
|
|
3477
|
-
config,
|
|
3478
|
-
cwd: io.cwd,
|
|
3479
|
-
defaultModel,
|
|
3480
|
-
directories: lintTargets.directories,
|
|
3481
|
-
files: lintTargets.files,
|
|
3482
|
-
modelOverride: options.model,
|
|
3483
|
-
outputLanguage: options.outputLanguage,
|
|
3484
|
-
progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
|
|
3485
|
-
runner,
|
|
3486
|
-
setupConfig
|
|
3487
|
-
});
|
|
3488
|
-
} catch (error) {
|
|
3489
3849
|
restoreProgressConsole?.();
|
|
3490
3850
|
progress?.dispose();
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
}
|
|
3497
|
-
if (error instanceof AlintRunCancelledError) {
|
|
3498
|
-
await persistStats(error.result);
|
|
3499
|
-
writeResult(error.result);
|
|
3500
|
-
io.stderr.write(formatCancelledError(error, io.stderr.isTTY === true));
|
|
3501
|
-
return 2;
|
|
3502
|
-
}
|
|
3503
|
-
throw error;
|
|
3851
|
+
await persistStats(result);
|
|
3852
|
+
writeResult(result);
|
|
3853
|
+
return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
|
|
3854
|
+
} finally {
|
|
3855
|
+
await session.shutdown();
|
|
3504
3856
|
}
|
|
3505
|
-
restoreProgressConsole?.();
|
|
3506
|
-
progress?.dispose();
|
|
3507
|
-
await persistStats(result);
|
|
3508
|
-
writeResult(result);
|
|
3509
|
-
return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
|
|
3510
3857
|
}
|
|
3511
3858
|
function shouldEnableProgress(options, io) {
|
|
3512
3859
|
if (options.progress !== void 0) return options.progress;
|
|
3513
3860
|
return options.format === "stylish" && io.stderr.isTTY === true;
|
|
3514
3861
|
}
|
|
3515
3862
|
//#endregion
|
|
3863
|
+
//#region src/cli/commands/lsp/index.ts
|
|
3864
|
+
const lsp = defineCommand({
|
|
3865
|
+
action: async (context) => (await import("./server-DgJa6R5l.mjs")).startLspServer(context.io),
|
|
3866
|
+
description: "Run alint as a language server over stdio",
|
|
3867
|
+
examples: [["# Point an editor at the project's own alint", "alint lsp"].join("\n")],
|
|
3868
|
+
help: ["Serve alint diagnostics over the Language Server Protocol on stdin/stdout.", "The server is cache-first: it publishes findings that are already cached and never calls a model on its own. Runs that spend tokens happen only through the `alint.runFile` and `alint.runWorkspace` commands."].join("\n\n"),
|
|
3869
|
+
name: "lsp",
|
|
3870
|
+
options: [{
|
|
3871
|
+
description: "Use stdio transport (default; accepted for client compatibility)",
|
|
3872
|
+
flags: "--stdio"
|
|
3873
|
+
}]
|
|
3874
|
+
});
|
|
3875
|
+
//#endregion
|
|
3516
3876
|
//#region src/cli/commands/output-inspect/inspect.ts
|
|
3517
3877
|
const inspect$1 = defineCommand({
|
|
3518
3878
|
action: (context, file, options) => inspectOutputFile(context.io, file, options),
|
|
@@ -4172,6 +4532,7 @@ const commandTree = [
|
|
|
4172
4532
|
}
|
|
4173
4533
|
]
|
|
4174
4534
|
}),
|
|
4535
|
+
lsp,
|
|
4175
4536
|
lint
|
|
4176
4537
|
];
|
|
4177
4538
|
//#endregion
|
|
@@ -4258,4 +4619,4 @@ function shouldCaptureHelp(argv) {
|
|
|
4258
4619
|
return argv.includes("--help") || argv.includes("-h");
|
|
4259
4620
|
}
|
|
4260
4621
|
//#endregion
|
|
4261
|
-
export { formatJson as i,
|
|
4622
|
+
export { formatJson as a, formatStylish as i, createRunSession as n, formatDiagnostics as r, executeCli as t };
|
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 {
|
|
1
|
+
import { a as formatJson, i as formatStylish, r as formatDiagnostics, t as executeCli } from "./cli-ChymU1Wr.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 { n as createRunSession } from "./cli-ChymU1Wr.mjs";
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { AlintRunCancelledError, AlintRunError } from "@alint-js/core";
|
|
4
|
+
import { join, relative, sep } from "node:path";
|
|
5
|
+
import { errorMessageFrom } from "@moeru/std";
|
|
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, { files: [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
|
+
files: options.files,
|
|
163
|
+
projectTargets: options.files === 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,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alint-js/cli",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
7
7
|
"types": "./dist/index.d.mts",
|
|
@@ -28,8 +28,10 @@
|
|
|
28
28
|
"pathe": "^2.0.3",
|
|
29
29
|
"table": "^6.9.0",
|
|
30
30
|
"tinyrainbow": "^3.1.1",
|
|
31
|
-
"
|
|
32
|
-
"@alint-js/config": "0.
|
|
31
|
+
"vscode-languageserver": "^10.1.0",
|
|
32
|
+
"@alint-js/config": "0.5.0",
|
|
33
|
+
"@alint-js/core": "0.5.0",
|
|
34
|
+
"@alint-js/model-adapter-acp": "0.5.0"
|
|
33
35
|
},
|
|
34
36
|
"devDependencies": {
|
|
35
37
|
"@pnpm/find-workspace-dir": "^1000.1.5",
|