pi-lens 3.8.19 → 3.8.21

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.
@@ -36,14 +36,17 @@
36
36
 
37
37
  import { spawn } from "node:child_process";
38
38
  import fs from "node:fs/promises";
39
+ import os from "node:os";
39
40
  import path from "node:path";
40
41
 
41
42
  // Global installation directory for pi-lens tools
42
- const TOOLS_DIR = path.join(process.cwd(), ".pi-lens", "tools");
43
+ const TOOLS_DIR = path.join(os.homedir(), ".pi-lens", "tools");
43
44
 
44
45
  // Debug flag - set via PI_LENS_DEBUG=1 or --debug
45
46
  const DEBUG =
46
47
  process.env.PI_LENS_DEBUG === "1" || process.argv.includes("--debug");
48
+ const SESSIONSTART_LOG_DIR = path.join(os.homedir(), ".pi-lens");
49
+ const SESSIONSTART_LOG = path.join(SESSIONSTART_LOG_DIR, "sessionstart.log");
47
50
 
48
51
  /**
49
52
  * Log debug messages only when DEBUG is enabled
@@ -54,6 +57,16 @@ function debugLog(...args: unknown[]): void {
54
57
  }
55
58
  }
56
59
 
60
+ function logSessionStart(msg: string): void {
61
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
62
+ void fs
63
+ .mkdir(SESSIONSTART_LOG_DIR, { recursive: true })
64
+ .then(() => fs.appendFile(SESSIONSTART_LOG, line))
65
+ .catch(() => {
66
+ // best-effort logging
67
+ });
68
+ }
69
+
57
70
  // --- Tool Definitions ---
58
71
 
59
72
  interface ToolDefinition {
@@ -80,6 +93,15 @@ const TOOLS: ToolDefinition[] = [
80
93
  packageName: "typescript-language-server",
81
94
  binaryName: "typescript-language-server",
82
95
  },
96
+ {
97
+ id: "typescript",
98
+ name: "TypeScript",
99
+ checkCommand: "tsc",
100
+ checkArgs: ["--version"],
101
+ installStrategy: "npm",
102
+ packageName: "typescript",
103
+ binaryName: "tsc",
104
+ },
83
105
  {
84
106
  id: "pyright",
85
107
  name: "Pyright",
@@ -175,6 +197,8 @@ const TOOLS: ToolDefinition[] = [
175
197
  },
176
198
  ];
177
199
 
200
+ const ensureInFlight = new Map<string, Promise<string | undefined>>();
201
+
178
202
  // --- Check Functions ---
179
203
 
180
204
  /**
@@ -202,27 +226,7 @@ async function isCommandAvailable(
202
226
  * Check if a tool is installed (globally or locally)
203
227
  */
204
228
  export async function isToolInstalled(toolId: string): Promise<boolean> {
205
- const tool = TOOLS.find((t) => t.id === toolId);
206
- if (!tool) return false;
207
-
208
- // Check global PATH
209
- if (await isCommandAvailable(tool.checkCommand, tool.checkArgs)) {
210
- return true;
211
- }
212
-
213
- // Check local tools directory
214
- const localPath = path.join(
215
- TOOLS_DIR,
216
- "node_modules",
217
- ".bin",
218
- tool.binaryName || tool.id,
219
- );
220
- try {
221
- await fs.access(localPath);
222
- return true;
223
- } catch {
224
- return false;
225
- }
229
+ return (await getToolPath(toolId)) !== undefined;
226
230
  }
227
231
 
228
232
  /**
@@ -237,6 +241,21 @@ export async function getToolPath(toolId: string): Promise<string | undefined> {
237
241
  return tool.checkCommand;
238
242
  }
239
243
 
244
+ if (tool.installStrategy === "npm") {
245
+ const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
246
+ if (npmPath) {
247
+ return npmPath;
248
+ }
249
+ }
250
+
251
+ // For pip tools, also probe user-level script locations
252
+ if (tool.installStrategy === "pip") {
253
+ const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
254
+ if (pipPath) {
255
+ return pipPath;
256
+ }
257
+ }
258
+
240
259
  // Check local
241
260
  const localPath = path.join(
242
261
  TOOLS_DIR,
@@ -252,6 +271,171 @@ export async function getToolPath(toolId: string): Promise<string | undefined> {
252
271
  }
253
272
  }
254
273
 
274
+ async function findNpmGlobalToolPath(
275
+ binaryName: string,
276
+ ): Promise<string | undefined> {
277
+ const isWindows = process.platform === "win32";
278
+ const binDirs = await getNpmGlobalBinCandidates();
279
+
280
+ for (const dir of binDirs) {
281
+ const candidates = isWindows
282
+ ? [
283
+ path.join(dir, `${binaryName}.cmd`),
284
+ path.join(dir, `${binaryName}.ps1`),
285
+ path.join(dir, `${binaryName}.exe`),
286
+ path.join(dir, binaryName),
287
+ ]
288
+ : [path.join(dir, binaryName)];
289
+
290
+ for (const candidate of candidates) {
291
+ try {
292
+ await fs.access(candidate);
293
+ if (await verifyToolBinary(candidate)) {
294
+ return candidate;
295
+ }
296
+ } catch {
297
+ // continue
298
+ }
299
+ }
300
+ }
301
+
302
+ return undefined;
303
+ }
304
+
305
+ async function getNpmGlobalBinCandidates(): Promise<string[]> {
306
+ const dirs: string[] = [];
307
+ const seen = new Set<string>();
308
+
309
+ const add = (value: string | undefined): void => {
310
+ if (!value) return;
311
+ const normalized = path.resolve(value.trim());
312
+ if (!normalized) return;
313
+ if (seen.has(normalized)) return;
314
+ seen.add(normalized);
315
+ dirs.push(normalized);
316
+ };
317
+
318
+ if (process.platform === "win32") {
319
+ add(path.join(process.env.APPDATA || "", "npm"));
320
+ } else {
321
+ add(path.join(os.homedir(), ".npm-global", "bin"));
322
+ }
323
+
324
+ const pm = process.platform === "win32" ? "npm.cmd" : "npm";
325
+ const prefix = await new Promise<string>((resolve) => {
326
+ const proc = spawn(pm, ["config", "get", "prefix"], {
327
+ stdio: ["ignore", "pipe", "pipe"],
328
+ shell: process.platform === "win32",
329
+ });
330
+
331
+ let stdout = "";
332
+ proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
333
+ proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
334
+ proc.on("error", () => resolve(""));
335
+ });
336
+
337
+ if (prefix) {
338
+ add(process.platform === "win32" ? prefix : path.join(prefix, "bin"));
339
+ }
340
+
341
+ return dirs;
342
+ }
343
+
344
+ async function findPipUserToolPath(
345
+ binaryName: string,
346
+ ): Promise<string | undefined> {
347
+ const isWindows = process.platform === "win32";
348
+ const userBaseCandidates = await getPythonUserBaseCandidates();
349
+
350
+ for (const userBase of userBaseCandidates) {
351
+ const scriptDirs: string[] = [
352
+ path.join(userBase, isWindows ? "Scripts" : "bin"),
353
+ ];
354
+
355
+ if (isWindows) {
356
+ try {
357
+ const children = await fs.readdir(userBase, { withFileTypes: true });
358
+ for (const entry of children) {
359
+ if (!entry.isDirectory()) continue;
360
+ if (!/^python\d+$/i.test(entry.name)) continue;
361
+ scriptDirs.push(path.join(userBase, entry.name, "Scripts"));
362
+ }
363
+ } catch {
364
+ // ignore
365
+ }
366
+ }
367
+
368
+ for (const dir of scriptDirs) {
369
+ const candidates = isWindows
370
+ ? [
371
+ path.join(dir, `${binaryName}.exe`),
372
+ path.join(dir, `${binaryName}.cmd`),
373
+ path.join(dir, binaryName),
374
+ ]
375
+ : [path.join(dir, binaryName)];
376
+
377
+ for (const candidate of candidates) {
378
+ try {
379
+ await fs.access(candidate);
380
+ if (await verifyToolBinary(candidate)) {
381
+ return candidate;
382
+ }
383
+ } catch {
384
+ // continue
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ return undefined;
391
+ }
392
+
393
+ async function getPythonUserBaseCandidates(): Promise<string[]> {
394
+ const candidates: string[] = [];
395
+ const seen = new Set<string>();
396
+
397
+ const add = (value: string | undefined): void => {
398
+ if (!value) return;
399
+ const normalized = value.trim();
400
+ if (!normalized) return;
401
+ if (seen.has(normalized)) return;
402
+ seen.add(normalized);
403
+ candidates.push(normalized);
404
+ };
405
+
406
+ if (process.platform === "win32") {
407
+ add(path.join(process.env.APPDATA || "", "Python"));
408
+ }
409
+
410
+ const probes: Array<{ command: string; args: string[] }> =
411
+ process.platform === "win32"
412
+ ? [
413
+ { command: "py", args: ["-m", "site", "--user-base"] },
414
+ { command: "python", args: ["-m", "site", "--user-base"] },
415
+ ]
416
+ : [
417
+ { command: "python3", args: ["-m", "site", "--user-base"] },
418
+ { command: "python", args: ["-m", "site", "--user-base"] },
419
+ ];
420
+
421
+ for (const probe of probes) {
422
+ const userBase = await new Promise<string>((resolve) => {
423
+ const proc = spawn(probe.command, probe.args, {
424
+ stdio: ["ignore", "pipe", "pipe"],
425
+ shell: process.platform === "win32",
426
+ });
427
+
428
+ let stdout = "";
429
+ proc.stdout?.on("data", (data: Buffer | string) => (stdout += data));
430
+ proc.on("exit", (code) => resolve(code === 0 ? stdout.trim() : ""));
431
+ proc.on("error", () => resolve(""));
432
+ });
433
+ add(userBase);
434
+ }
435
+
436
+ return candidates;
437
+ }
438
+
255
439
  // --- Verification Functions
256
440
 
257
441
  /**
@@ -262,8 +446,9 @@ async function verifyToolBinary(binPath: string): Promise<boolean> {
262
446
  return new Promise((resolve) => {
263
447
  // Add .cmd extension on Windows for the actual binary
264
448
  const isWindows = process.platform === "win32";
449
+ const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
265
450
  const execPath =
266
- isWindows && !binPath.endsWith(".cmd") ? `${binPath}.cmd` : binPath;
451
+ isWindows && !hasKnownWindowsExt ? `${binPath}.cmd` : binPath;
267
452
 
268
453
  const proc = spawn(execPath, ["--version"], {
269
454
  timeout: 10000, // 10 second timeout for verification
@@ -330,8 +515,6 @@ async function installNpmTool(
330
515
  );
331
516
  }
332
517
 
333
- console.error(`[auto-install] Installing ${packageName}...`);
334
-
335
518
  // Install via npm or bun (use .cmd on Windows)
336
519
  const isWindows = process.platform === "win32";
337
520
  const pm = process.env.BUN_INSTALL
@@ -427,27 +610,121 @@ async function installPipTool(
427
610
  packageName: string,
428
611
  ): Promise<string | undefined> {
429
612
  try {
430
- const pipCmd = process.platform === "win32" ? "pip" : "pip3";
431
613
  const isWindows = process.platform === "win32";
432
- const proc = spawn(pipCmd, ["install", "--user", packageName], {
433
- stdio: ["ignore", "pipe", "pipe"],
434
- shell: isWindows, // Required for .cmd files on Windows
435
- });
614
+ const pipCandidates = isWindows
615
+ ? [
616
+ { command: "pip", args: ["install", "--user", packageName] },
617
+ { command: "py", args: ["-m", "pip", "install", "--user", packageName] },
618
+ {
619
+ command: "python",
620
+ args: ["-m", "pip", "install", "--user", packageName],
621
+ },
622
+ ]
623
+ : [
624
+ { command: "pip3", args: ["install", "--user", packageName] },
625
+ { command: "pip", args: ["install", "--user", packageName] },
626
+ {
627
+ command: "python3",
628
+ args: ["-m", "pip", "install", "--user", packageName],
629
+ },
630
+ { command: "python", args: ["-m", "pip", "install", "--user", packageName] },
631
+ ];
632
+
633
+ let lastError = "";
634
+ for (const candidate of pipCandidates) {
635
+ const outcome = await new Promise<{ ok: boolean; error: string }>((resolve) => {
636
+ const proc = spawn(candidate.command, candidate.args, {
637
+ stdio: ["ignore", "pipe", "pipe"],
638
+ shell: isWindows, // Required for .cmd files on Windows
639
+ });
640
+
641
+ let stderr = "";
642
+ proc.stderr?.on("data", (data) => (stderr += data));
643
+
644
+ proc.on("exit", (code) => {
645
+ if (code === 0) {
646
+ resolve({ ok: true, error: "" });
647
+ } else {
648
+ resolve({ ok: false, error: stderr.trim() });
649
+ }
650
+ });
436
651
 
437
- return new Promise((resolve, reject) => {
438
- let stderr = "";
439
- proc.stderr?.on("data", (data) => (stderr += data));
652
+ proc.on("error", (err) => {
653
+ resolve({ ok: false, error: err.message });
654
+ });
655
+ });
440
656
 
441
- proc.on("exit", (code) => {
442
- if (code === 0) {
443
- resolve(packageName); // pip installs to PATH
444
- } else {
445
- reject(new Error(`Failed to install ${packageName}: ${stderr}`));
657
+ if (outcome.ok) {
658
+ // Ensure user-level scripts directory is available in current process PATH.
659
+ // This helps tools installed via `pip install --user` become immediately callable.
660
+ const userBaseResult = await new Promise<string>((resolve) => {
661
+ const probe = spawn(candidate.command, ["-m", "site", "--user-base"], {
662
+ stdio: ["ignore", "pipe", "pipe"],
663
+ shell: isWindows,
664
+ });
665
+ let stdout = "";
666
+ probe.stdout?.on("data", (data) => (stdout += data));
667
+ probe.on("exit", (code) => {
668
+ if (code === 0) resolve(stdout.trim());
669
+ else resolve("");
670
+ });
671
+ probe.on("error", () => resolve(""));
672
+ });
673
+
674
+ if (userBaseResult) {
675
+ const candidateScriptDirs: string[] = [
676
+ path.join(userBaseResult, isWindows ? "Scripts" : "bin"),
677
+ ];
678
+
679
+ if (isWindows) {
680
+ // Some Python setups report USER_BASE as ...\Roaming\Python,
681
+ // while scripts live in ...\Roaming\Python\PythonXY\Scripts.
682
+ try {
683
+ const children = await fs.readdir(userBaseResult, {
684
+ withFileTypes: true,
685
+ });
686
+ for (const entry of children) {
687
+ if (!entry.isDirectory()) continue;
688
+ if (!/^python\d+$/i.test(entry.name)) continue;
689
+ candidateScriptDirs.push(
690
+ path.join(userBaseResult, entry.name, "Scripts"),
691
+ );
692
+ }
693
+ } catch {
694
+ // ignore
695
+ }
696
+ }
697
+
698
+ const currentPath = process.env.PATH || "";
699
+ const separator = isWindows ? ";" : ":";
700
+ const normalizedPath = currentPath
701
+ .toLowerCase()
702
+ .split(separator)
703
+ .map((p) => p.trim());
704
+
705
+ for (const scriptsDir of candidateScriptDirs) {
706
+ try {
707
+ await fs.access(scriptsDir);
708
+ if (!normalizedPath.includes(scriptsDir.toLowerCase())) {
709
+ process.env.PATH = `${scriptsDir}${separator}${process.env.PATH || ""}`;
710
+ debugLog(`Added pip user scripts dir to PATH: ${scriptsDir}`);
711
+ }
712
+ } catch {
713
+ debugLog(`pip user scripts dir not accessible: ${scriptsDir}`);
714
+ }
715
+ }
446
716
  }
447
- });
448
717
 
449
- proc.on("error", (err) => reject(err));
450
- });
718
+ return packageName;
719
+ }
720
+
721
+ lastError = `${candidate.command} ${candidate.args.join(" ")}: ${outcome.error}`;
722
+ debugLog(`[pip-fallback] ${lastError}`);
723
+ }
724
+
725
+ throw new Error(
726
+ `Failed to install ${packageName}: no usable pip command found (${lastError || "unknown error"})`,
727
+ );
451
728
  } catch (err) {
452
729
  console.error(
453
730
  `[auto-install] Failed to install ${packageName}: ${(err as Error).message}`,
@@ -464,35 +741,52 @@ export async function installTool(toolId: string): Promise<boolean> {
464
741
  const tool = TOOLS.find((t) => t.id === toolId);
465
742
  if (!tool) {
466
743
  console.error(`[auto-install] Unknown tool: ${toolId}`);
744
+ logSessionStart(`auto-install ${toolId}: unknown tool id`);
467
745
  return false;
468
746
  }
469
747
 
470
748
  console.error(`[auto-install] Installing ${tool.name}...`);
749
+ const startedAt = Date.now();
750
+ logSessionStart(
751
+ `auto-install ${tool.id}: start strategy=${tool.installStrategy} package=${tool.packageName ?? "n/a"}`,
752
+ );
471
753
 
472
754
  try {
473
755
  switch (tool.installStrategy) {
474
756
  case "npm": {
475
757
  if (!tool.packageName || !tool.binaryName) return false;
476
758
  const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
477
- return npmPath !== undefined;
759
+ const ok = npmPath !== undefined;
760
+ logSessionStart(
761
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
762
+ );
763
+ return ok;
478
764
  }
479
765
 
480
766
  case "pip": {
481
767
  if (!tool.packageName) return false;
482
768
  const pipPath = await installPipTool(tool.packageName);
483
- return pipPath !== undefined;
769
+ const ok = pipPath !== undefined;
770
+ logSessionStart(
771
+ `auto-install ${tool.id}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`,
772
+ );
773
+ return ok;
484
774
  }
485
775
 
486
776
  default:
487
777
  console.error(
488
778
  `[auto-install] Unsupported strategy: ${tool.installStrategy}`,
489
779
  );
780
+ logSessionStart(`auto-install ${tool.id}: unsupported strategy`);
490
781
  return false;
491
782
  }
492
783
  } catch (err) {
493
784
  console.error(
494
785
  `[auto-install] Failed to install ${tool.name}: ${(err as Error).message}`,
495
786
  );
787
+ logSessionStart(
788
+ `auto-install ${tool.id}: exception ${(err as Error).message} (${Date.now() - startedAt}ms)`,
789
+ );
496
790
  debugLog("Full error:", err);
497
791
  return false;
498
792
  }
@@ -502,20 +796,48 @@ export async function installTool(toolId: string): Promise<boolean> {
502
796
  * Ensure a tool is installed (check first, install if missing)
503
797
  */
504
798
  export async function ensureTool(toolId: string): Promise<string | undefined> {
799
+ const ensureStartMs = Date.now();
800
+ logSessionStart(`auto-install ensure ${toolId}: start`);
505
801
  // Check if already installed
506
802
  const existingPath = await getToolPath(toolId);
507
803
  if (existingPath) {
804
+ logSessionStart(
805
+ `auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`,
806
+ );
508
807
  return existingPath;
509
808
  }
510
809
 
511
- // Try to install
512
- const installed = await installTool(toolId);
513
- if (!installed) {
514
- return undefined;
810
+ const inFlight = ensureInFlight.get(toolId);
811
+ if (inFlight) {
812
+ logSessionStart(`auto-install ensure ${toolId}: waiting for in-flight install`);
813
+ return inFlight;
515
814
  }
516
815
 
517
- // Return the path after installation
518
- return getToolPath(toolId);
816
+ const installPromise = (async () => {
817
+ const installed = await installTool(toolId);
818
+ if (!installed) {
819
+ return undefined;
820
+ }
821
+
822
+ return getToolPath(toolId);
823
+ })();
824
+
825
+ ensureInFlight.set(toolId, installPromise);
826
+ try {
827
+ const result = await installPromise;
828
+ if (result) {
829
+ logSessionStart(
830
+ `auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`,
831
+ );
832
+ } else {
833
+ logSessionStart(
834
+ `auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`,
835
+ );
836
+ }
837
+ return result;
838
+ } finally {
839
+ ensureInFlight.delete(toolId);
840
+ }
519
841
  }
520
842
 
521
843
  // --- Integration Helpers ---
@@ -0,0 +1,154 @@
1
+ import type { FileKind } from "./file-kinds.js";
2
+ import type { ProjectLanguageProfile } from "./language-profile.js";
3
+ import type { RunnerGroup } from "./dispatch/types.js";
4
+
5
+ interface StartupPolicy {
6
+ defaults?: string[];
7
+ heavyScansRequireConfig?: boolean;
8
+ }
9
+
10
+ interface LanguagePolicy {
11
+ lspCapable: boolean;
12
+ startup?: StartupPolicy;
13
+ }
14
+
15
+ export const LANGUAGE_POLICY: Record<FileKind, LanguagePolicy> = {
16
+ jsts: {
17
+ lspCapable: true,
18
+ startup: {
19
+ defaults: ["typescript-language-server"],
20
+ heavyScansRequireConfig: true,
21
+ },
22
+ },
23
+ python: {
24
+ lspCapable: true,
25
+ startup: {
26
+ defaults: ["pyright", "ruff"],
27
+ },
28
+ },
29
+ go: { lspCapable: true },
30
+ rust: { lspCapable: true },
31
+ cxx: { lspCapable: true },
32
+ cmake: { lspCapable: true },
33
+ shell: { lspCapable: true },
34
+ json: { lspCapable: true },
35
+ markdown: { lspCapable: true },
36
+ css: { lspCapable: true },
37
+ yaml: {
38
+ lspCapable: true,
39
+ startup: {
40
+ defaults: ["yamllint"],
41
+ heavyScansRequireConfig: true,
42
+ },
43
+ },
44
+ sql: {
45
+ lspCapable: false,
46
+ startup: {
47
+ defaults: ["sqlfluff"],
48
+ heavyScansRequireConfig: true,
49
+ },
50
+ },
51
+ ruby: { lspCapable: true },
52
+ };
53
+
54
+ const PRIMARY_DISPATCH_GROUPS: Partial<Record<FileKind, RunnerGroup>> = {
55
+ jsts: { mode: "fallback", runnerIds: ["lsp", "ts-lsp"], filterKinds: ["jsts"] },
56
+ python: {
57
+ mode: "fallback",
58
+ runnerIds: ["lsp", "pyright"],
59
+ filterKinds: ["python"],
60
+ },
61
+ go: { mode: "fallback", runnerIds: ["lsp", "go-vet"], filterKinds: ["go"] },
62
+ rust: {
63
+ mode: "fallback",
64
+ runnerIds: ["lsp", "rust-clippy"],
65
+ filterKinds: ["rust"],
66
+ },
67
+ ruby: {
68
+ mode: "fallback",
69
+ runnerIds: ["lsp", "rubocop"],
70
+ filterKinds: ["ruby"],
71
+ },
72
+ cxx: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["cxx"] },
73
+ cmake: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["cmake"] },
74
+ shell: {
75
+ mode: "fallback",
76
+ runnerIds: ["lsp", "shellcheck"],
77
+ filterKinds: ["shell"],
78
+ },
79
+ json: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["json"] },
80
+ markdown: {
81
+ mode: "fallback",
82
+ runnerIds: ["lsp", "spellcheck"],
83
+ filterKinds: ["markdown"],
84
+ },
85
+ css: { mode: "fallback", runnerIds: ["lsp"], filterKinds: ["css"] },
86
+ yaml: {
87
+ mode: "fallback",
88
+ runnerIds: ["lsp", "yamllint"],
89
+ filterKinds: ["yaml"],
90
+ },
91
+ sql: {
92
+ mode: "fallback",
93
+ runnerIds: ["sqlfluff"],
94
+ filterKinds: ["sql"],
95
+ },
96
+ };
97
+
98
+ export function getLspCapableKinds(): FileKind[] {
99
+ return (Object.keys(LANGUAGE_POLICY) as FileKind[]).filter(
100
+ (kind) => LANGUAGE_POLICY[kind].lspCapable,
101
+ );
102
+ }
103
+
104
+ export function getPrimaryDispatchGroup(
105
+ kind: FileKind,
106
+ lspEnabled: boolean,
107
+ ): RunnerGroup | undefined {
108
+ const base = PRIMARY_DISPATCH_GROUPS[kind];
109
+ if (!base) return undefined;
110
+
111
+ const ids = lspEnabled
112
+ ? [...base.runnerIds]
113
+ : base.runnerIds.filter((id) => id !== "lsp" && id !== "ts-lsp");
114
+ if (ids.length === 0) return undefined;
115
+
116
+ return {
117
+ mode: base.mode,
118
+ runnerIds: ids,
119
+ filterKinds: base.filterKinds,
120
+ semantic: base.semantic,
121
+ };
122
+ }
123
+
124
+ export function getStartupDefaultsForProfile(
125
+ profile: ProjectLanguageProfile,
126
+ ): string[] {
127
+ const tools = new Set<string>();
128
+
129
+ for (const kind of Object.keys(LANGUAGE_POLICY) as FileKind[]) {
130
+ if (!profile.present[kind]) continue;
131
+ const defaults = LANGUAGE_POLICY[kind].startup?.defaults ?? [];
132
+ for (const tool of defaults) {
133
+ if (
134
+ LANGUAGE_POLICY[kind].startup?.heavyScansRequireConfig &&
135
+ !profile.configured[kind]
136
+ ) {
137
+ continue;
138
+ }
139
+ tools.add(tool);
140
+ }
141
+ }
142
+
143
+ return [...tools];
144
+ }
145
+
146
+ export function canRunStartupHeavyScans(
147
+ profile: ProjectLanguageProfile,
148
+ kind: FileKind,
149
+ ): boolean {
150
+ if (!profile.present[kind]) return false;
151
+ const needsConfig = LANGUAGE_POLICY[kind].startup?.heavyScansRequireConfig;
152
+ if (!needsConfig) return true;
153
+ return !!profile.configured[kind];
154
+ }