@alint-js/cli 0.4.0 → 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 +37 -0
- package/dist/bin/index.mjs +1 -1
- package/dist/{cli-DFWnPI6m.mjs → cli-ChymU1Wr.mjs} +163 -89
- package/dist/index.d.mts +11 -3
- package/dist/index.mjs +1 -1
- package/dist/server-DgJa6R5l.mjs +368 -0
- package/package.json +5 -4
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.
|
package/dist/bin/index.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import Gitignore from "gitignore-fs";
|
|
|
18
18
|
import { Minimatch, minimatch } from "minimatch";
|
|
19
19
|
import { errorMessageFrom as errorMessageFrom$1 } from "@moeru/std";
|
|
20
20
|
//#region package.json
|
|
21
|
-
var version = "0.
|
|
21
|
+
var version = "0.5.0";
|
|
22
22
|
//#endregion
|
|
23
23
|
//#region src/cli/output.ts
|
|
24
24
|
function escapeLineValue(value) {
|
|
@@ -47,6 +47,7 @@ function dispatchCommand(context, node, args, options, path) {
|
|
|
47
47
|
const [subcommand, ...restArgs] = args;
|
|
48
48
|
const child = node.children?.find((item) => item.name === subcommand || item.alias?.includes(subcommand ?? ""));
|
|
49
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));
|
|
50
51
|
if (!node.action) return Promise.resolve(reportUnknownCommand(context, path, args));
|
|
51
52
|
if (node.exactArguments === true) {
|
|
52
53
|
const unexpectedArgument = args[node.arguments?.split(/\s+/u).filter(Boolean).length ?? 0];
|
|
@@ -170,6 +171,19 @@ function registerRootCommand(cli, node, context, setPendingResult) {
|
|
|
170
171
|
return setPendingResult(node.children ? dispatchCommand(context, node, args[0] ?? [], options, [node.name]) : Promise.resolve(node.action?.(context, ...args.slice(0, -1), options) ?? 0));
|
|
171
172
|
});
|
|
172
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
|
+
}
|
|
173
187
|
function reportUnknownCommand(context, path, args) {
|
|
174
188
|
context.io.stderr.write(`unknown command: ${formatUnknownCommand(path, args)}\n`);
|
|
175
189
|
return 2;
|
|
@@ -3088,6 +3102,96 @@ async function* walkFiles(root, options) {
|
|
|
3088
3102
|
}
|
|
3089
3103
|
}
|
|
3090
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
|
|
3091
3195
|
//#region src/cli/commands/lint/errors.ts
|
|
3092
3196
|
const colors$1 = createColors({ force: true });
|
|
3093
3197
|
function formatCancelledError(error, color) {
|
|
@@ -3155,63 +3259,37 @@ function groupFailuresByRule(failures) {
|
|
|
3155
3259
|
}
|
|
3156
3260
|
//#endregion
|
|
3157
3261
|
//#region src/cli/commands/lint/runner.ts
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
function resolveRunnerConfig(
|
|
3165
|
-
const cache = resolveRunnerCacheConfig(setupConfig.runner?.cache, config.runner?.cache, options);
|
|
3166
|
-
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) {
|
|
3167
3269
|
const ruleConcurrency = parsePositiveIntegerOption(options.ruleConcurrency, "--rule-concurrency");
|
|
3168
3270
|
const timeoutMs = parsePositiveIntegerOption(options.timeoutMs, "--timeout-ms");
|
|
3169
3271
|
const runner = {
|
|
3170
|
-
...
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
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
|
|
3176
3277
|
};
|
|
3177
3278
|
return Object.values(runner).some((value) => value !== void 0) ? runner : void 0;
|
|
3178
3279
|
}
|
|
3179
|
-
function mergeRunnerCacheConfig(setupCache, configCache) {
|
|
3180
|
-
if (configCache === void 0) return setupCache;
|
|
3181
|
-
if (typeof configCache === "boolean") return configCache;
|
|
3182
|
-
if (typeof setupCache === "object") return {
|
|
3183
|
-
...setupCache,
|
|
3184
|
-
...configCache
|
|
3185
|
-
};
|
|
3186
|
-
return configCache;
|
|
3187
|
-
}
|
|
3188
|
-
function mergeRunnerStatsConfig(setupStats, configStats) {
|
|
3189
|
-
if (configStats === void 0) return setupStats;
|
|
3190
|
-
if (typeof configStats === "boolean") return configStats;
|
|
3191
|
-
if (typeof setupStats === "object") return {
|
|
3192
|
-
...setupStats,
|
|
3193
|
-
...configStats
|
|
3194
|
-
};
|
|
3195
|
-
return configStats;
|
|
3196
|
-
}
|
|
3197
3280
|
function parsePositiveIntegerOption(value, label) {
|
|
3198
3281
|
if (value === void 0) return;
|
|
3199
3282
|
const parsed = Number(value);
|
|
3200
3283
|
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer.`);
|
|
3201
3284
|
return parsed;
|
|
3202
3285
|
}
|
|
3203
|
-
function
|
|
3286
|
+
function resolveCacheOption(baseCache, options) {
|
|
3204
3287
|
if (options.cache === false) return false;
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
...configuredCache,
|
|
3288
|
+
if (options.cacheLocation !== void 0) return typeof baseCache === "object" ? {
|
|
3289
|
+
...baseCache,
|
|
3208
3290
|
location: options.cacheLocation
|
|
3209
3291
|
} : { location: options.cacheLocation };
|
|
3210
|
-
return
|
|
3211
|
-
}
|
|
3212
|
-
function resolveRunnerStatsConfig(setupStats, configStats, options) {
|
|
3213
|
-
if (options.stats === false) return false;
|
|
3214
|
-
return mergeRunnerStatsConfig(setupStats, configStats);
|
|
3292
|
+
return baseCache;
|
|
3215
3293
|
}
|
|
3216
3294
|
//#endregion
|
|
3217
3295
|
//#region src/cli/stats/ci.ts
|
|
@@ -3717,61 +3795,43 @@ function isNodeError(error) {
|
|
|
3717
3795
|
}
|
|
3718
3796
|
async function runLintCommand(files, options, io, interceptConsoleOutput) {
|
|
3719
3797
|
if (options.config) await assertConfigExists(io.cwd, options.config);
|
|
3720
|
-
const
|
|
3721
|
-
let lintTargets;
|
|
3798
|
+
const session = await createRunSession(io, { configPath: options.config });
|
|
3722
3799
|
try {
|
|
3723
|
-
|
|
3724
|
-
|
|
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,
|
|
3725
3804
|
cwd: io.cwd,
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
});
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
columns: io.stderr.columns ?? 80,
|
|
3741
|
-
cwd: io.cwd,
|
|
3742
|
-
isTty: io.stderr.isTTY === true,
|
|
3743
|
-
rows: io.stderr.rows,
|
|
3744
|
-
write: (chunk) => io.stderr.write(chunk)
|
|
3745
|
-
}) : void 0;
|
|
3746
|
-
const restoreProgressConsole = progress ? interceptConsoleOutput({ write: progress.write }) : void 0;
|
|
3747
|
-
const statsTarget = resolveStatsWrite(runner?.stats, io.env);
|
|
3748
|
-
const statsCollector = statsTarget ? createStatsCollector() : void 0;
|
|
3749
|
-
const persistStats = async (runResult) => {
|
|
3750
|
-
if (statsTarget && statsCollector) await writeRunStats(statsTarget, statsCollector, runResult, io.cwd);
|
|
3751
|
-
};
|
|
3752
|
-
const writeResult = (runResult) => {
|
|
3753
|
-
io.stdout.write(formatDiagnostics(options.format, runResult, { color: io.stdout.isTTY === true }));
|
|
3754
|
-
};
|
|
3755
|
-
const runtime = await startModelAdapters(setupConfig, io);
|
|
3756
|
-
let result;
|
|
3757
|
-
try {
|
|
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;
|
|
3758
3819
|
try {
|
|
3759
|
-
result = await
|
|
3820
|
+
result = await session.run({
|
|
3760
3821
|
cacheOnly: options.cacheOnly,
|
|
3761
|
-
|
|
3762
|
-
cwd: io.cwd,
|
|
3763
|
-
defaultModel,
|
|
3764
|
-
directories: lintTargets.directories,
|
|
3765
|
-
files: lintTargets.files,
|
|
3822
|
+
files,
|
|
3766
3823
|
modelOverride: options.model,
|
|
3767
3824
|
outputLanguage: options.outputLanguage,
|
|
3768
3825
|
progress: mergeProgressReporters(progress?.reporter, statsCollector?.reporter),
|
|
3769
|
-
runner
|
|
3770
|
-
setupConfig: runtime.setupConfig
|
|
3826
|
+
runner
|
|
3771
3827
|
});
|
|
3772
3828
|
} catch (error) {
|
|
3773
3829
|
restoreProgressConsole?.();
|
|
3774
3830
|
progress?.dispose();
|
|
3831
|
+
if (error instanceof NoFilesFoundError) {
|
|
3832
|
+
io.stderr.write(`${error.message}\n`);
|
|
3833
|
+
return 2;
|
|
3834
|
+
}
|
|
3775
3835
|
if (error instanceof AlintRunError) {
|
|
3776
3836
|
await persistStats(error.result);
|
|
3777
3837
|
writeResult(error.result);
|
|
@@ -3792,7 +3852,7 @@ async function runLintCommand(files, options, io, interceptConsoleOutput) {
|
|
|
3792
3852
|
writeResult(result);
|
|
3793
3853
|
return result.diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 1 : 0;
|
|
3794
3854
|
} finally {
|
|
3795
|
-
await
|
|
3855
|
+
await session.shutdown();
|
|
3796
3856
|
}
|
|
3797
3857
|
}
|
|
3798
3858
|
function shouldEnableProgress(options, io) {
|
|
@@ -3800,6 +3860,19 @@ function shouldEnableProgress(options, io) {
|
|
|
3800
3860
|
return options.format === "stylish" && io.stderr.isTTY === true;
|
|
3801
3861
|
}
|
|
3802
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
|
|
3803
3876
|
//#region src/cli/commands/output-inspect/inspect.ts
|
|
3804
3877
|
const inspect$1 = defineCommand({
|
|
3805
3878
|
action: (context, file, options) => inspectOutputFile(context.io, file, options),
|
|
@@ -4459,6 +4532,7 @@ const commandTree = [
|
|
|
4459
4532
|
}
|
|
4460
4533
|
]
|
|
4461
4534
|
}),
|
|
4535
|
+
lsp,
|
|
4462
4536
|
lint
|
|
4463
4537
|
];
|
|
4464
4538
|
//#endregion
|
|
@@ -4545,4 +4619,4 @@ function shouldCaptureHelp(argv) {
|
|
|
4545
4619
|
return argv.includes("--help") || argv.includes("-h");
|
|
4546
4620
|
}
|
|
4547
4621
|
//#endregion
|
|
4548
|
-
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,9 +28,10 @@
|
|
|
28
28
|
"pathe": "^2.0.3",
|
|
29
29
|
"table": "^6.9.0",
|
|
30
30
|
"tinyrainbow": "^3.1.1",
|
|
31
|
-
"
|
|
32
|
-
"@alint-js/
|
|
33
|
-
"@alint-js/
|
|
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"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"@pnpm/find-workspace-dir": "^1000.1.5",
|