@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 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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as executeCli } from "../cli-CP8pWPS7.mjs";
2
+ import { t as executeCli } from "../cli-ChymU1Wr.mjs";
3
3
  import process from "node:process";
4
4
  //#region src/bin/index.ts
5
5
  executeCli(process.argv, {
@@ -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.3.2";
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(config, {
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(provider.endpoint, provider.headers));
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(provider.endpoint)}": ${classifyProbeFailure(error)}.\n`);
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: providerEndpoint ?? existingProvider.endpoint,
2185
+ endpoint,
1898
2186
  headers: mergeProviderHeaders(existingProvider.headers, parsedHeaders)
1899
2187
  };
1900
2188
  let remoteModelIds;
1901
2189
  try {
1902
- remoteModelIds = await probeModels(draft.endpoint, draft.headers);
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(existingProvider.endpoint)
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
- function resolveConfigRunner(config) {
2877
- return normalizeConfig(config).reduce((merged, item) => item.runner ? {
2878
- ...merged,
2879
- ...item.runner
2880
- } : merged, void 0);
2881
- }
2882
- function resolveRunnerConfig(setupConfig, config, options) {
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
- ...setupConfig.runner ?? {},
2889
- ...config.runner ?? {},
2890
- cache,
2891
- ruleConcurrency: ruleConcurrency ?? config.runner?.ruleConcurrency ?? setupConfig.runner?.ruleConcurrency,
2892
- stats,
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 resolveRunnerCacheConfig(setupCache, configCache, options) {
3286
+ function resolveCacheOption(baseCache, options) {
2922
3287
  if (options.cache === false) return false;
2923
- const configuredCache = mergeRunnerCacheConfig(setupCache, configCache);
2924
- if (options.cacheLocation !== void 0) return typeof configuredCache === "object" ? {
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 configuredCache;
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 [{ defaultModel, setupConfig }, config] = await Promise.all([loadRunSetupConfig(io), loadAlintConfig(io.cwd, options.config)]);
3439
- let lintTargets;
3798
+ const session = await createRunSession(io, { configPath: options.config });
3440
3799
  try {
3441
- lintTargets = await findLintTargets({
3442
- config,
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
- errorOnUnmatchedPattern: true,
3445
- globInputPaths: true,
3446
- inputs: files
3447
- });
3448
- } catch (error) {
3449
- if (error instanceof NoFilesFoundError) {
3450
- io.stderr.write(`${error.message}\n`);
3451
- return 2;
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
- if (error instanceof AlintRunError) {
3492
- await persistStats(error.result);
3493
- writeResult(error.result);
3494
- io.stderr.write(formatRunError(error, io.stderr.isTTY === true));
3495
- return 2;
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, formatDiagnostics as n, formatStylish as r, executeCli as t };
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 { i as formatJson, n as formatDiagnostics, r as formatStylish, t as executeCli } from "./cli-CP8pWPS7.mjs";
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.3.2",
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
- "@alint-js/core": "0.3.2",
32
- "@alint-js/config": "0.3.2"
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",