pi-lens 3.8.18 → 3.8.19

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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [3.8.19] - 2026-04-07
6
+
7
+ ### Fixed
8
+ - **Biome autofix gating** — Biome autofix/auto-install now runs only when the project has Biome configuration (`biome.json`/`biome.jsonc`) or `@biomejs/biome` in `devDependencies`, preventing unwanted Biome installs in non-Biome JS/TS projects.
9
+
5
10
  ## [3.8.18] - 2026-04-07
6
11
 
7
12
  ### Changed
package/README.md CHANGED
@@ -82,19 +82,25 @@ Some runners are language/config-gated and may skip when not applicable.
82
82
 
83
83
  ## Dependencies
84
84
 
85
- Auto-installed defaults:
86
-
87
- | Tool | Purpose | Auto-installed |
88
- |---|---|---|
89
- | `typescript-language-server` | LSP type diagnostics | Yes |
90
- | `pyright` | Python type diagnostics fallback | Yes |
91
- | `prettier` | Formatting fallback | Yes |
92
- | `ruff` | Python lint/format/autofix | Yes |
93
- | `@biomejs/biome` | JS/TS lint/format/autofix | Yes |
94
- | `madge` | Circular dependency analysis | Yes |
95
- | `jscpd` | Duplicate code detection | Yes |
96
- | `@ast-grep/cli` (`sg`) | AST search/replace and scans | Yes |
97
- | `knip` | Dead code analysis | Yes |
85
+ Auto-install behavior depends on gate type:
86
+
87
+ - **Config-gated**: installs only when project config/deps indicate usage.
88
+ - **Flow/language-gated**: installs when the runtime path needs it for the current file/session flow.
89
+ - **Operational prewarm**: installs during session warm scans / turn-end analysis paths.
90
+
91
+ | Tool | Purpose | Auto-installed | Gate |
92
+ |---|---|---|---|
93
+ | `@biomejs/biome` | JS/TS lint/format/autofix | Yes | Config-gated (`biome.json`/`biome.jsonc` or `@biomejs/biome` dep) |
94
+ | `prettier` | Formatting fallback | Yes | Config-gated (Prettier dep or `package.json#prettier`) |
95
+ | `yamllint` | YAML linting | Yes | Config-gated (`.yamllint*` / tool section / dep hint) |
96
+ | `sqlfluff` | SQL linting/formatting | Yes | Config-gated (`.sqlfluff` / tool section / dep hint) |
97
+ | `ruff` | Python lint/format/autofix | Yes | Flow/language-gated (Python file paths/runners) |
98
+ | `typescript-language-server` | Unified LSP diagnostics | Yes | Flow-gated (LSP enabled; default on unless `--no-lsp`) |
99
+ | `pyright` | Python type diagnostics fallback | Yes | Flow/language-gated (Python fallback paths) |
100
+ | `@ast-grep/cli` (`sg`) | AST scans/search/replace | Yes | Operational prewarm + analysis flows |
101
+ | `knip` | Dead code analysis | Yes | Operational prewarm + turn-end flows |
102
+ | `jscpd` | Duplicate code detection | Yes | Operational prewarm + turn-end flows |
103
+ | `madge` | Circular dependency analysis | Yes | Turn-end analysis flow |
98
104
 
99
105
  LSP is enabled by default. pi-lens includes many language-server definitions (including up to 31+ servers), and activates them when the server is installed and the project/root detection matches the file.
100
106
 
@@ -105,5 +111,5 @@ Optional safety switch:
105
111
 
106
112
  ## Notes
107
113
 
108
- - Some tools are auto-installed; others are config/availability-based.
114
+ - Not every auto-install runs in every project: gate type decides when install is attempted.
109
115
  - Rule packs are customizable via project-level rule directories.
@@ -1,3 +1,5 @@
1
+ import * as nodeFs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { ensureTool } from "../../installer/index.js";
2
4
  import { safeSpawn } from "../../safe-spawn.js";
3
5
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
@@ -10,6 +12,41 @@ import type {
10
12
 
11
13
  const sqlfluff = createAvailabilityChecker("sqlfluff", ".exe");
12
14
 
15
+ const SQLFLUFF_CONFIGS = [".sqlfluff", "pyproject.toml", "setup.cfg", "tox.ini"];
16
+
17
+ export function hasSqlfluffConfig(cwd: string): boolean {
18
+ for (const cfg of SQLFLUFF_CONFIGS) {
19
+ const cfgPath = path.join(cwd, cfg);
20
+ if (!nodeFs.existsSync(cfgPath)) continue;
21
+ if (cfg === "pyproject.toml") {
22
+ try {
23
+ const content = nodeFs.readFileSync(cfgPath, "utf-8");
24
+ if (content.includes("[tool.sqlfluff]")) return true;
25
+ } catch {}
26
+ continue;
27
+ }
28
+ if (cfg === "setup.cfg" || cfg === "tox.ini") {
29
+ try {
30
+ const content = nodeFs.readFileSync(cfgPath, "utf-8");
31
+ if (content.includes("[sqlfluff]")) return true;
32
+ } catch {}
33
+ continue;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ for (const depFile of ["requirements.txt", "Pipfile", "pyproject.toml"]) {
39
+ const depPath = path.join(cwd, depFile);
40
+ if (!nodeFs.existsSync(depPath)) continue;
41
+ try {
42
+ const content = nodeFs.readFileSync(depPath, "utf-8").toLowerCase();
43
+ if (content.includes("sqlfluff")) return true;
44
+ } catch {}
45
+ }
46
+
47
+ return false;
48
+ }
49
+
13
50
  type SqlfluffJson = Array<{
14
51
  filepath?: string;
15
52
  violations?: Array<{
@@ -59,6 +96,10 @@ const sqlfluffRunner: RunnerDefinition = {
59
96
 
60
97
  async run(ctx: DispatchContext): Promise<RunnerResult> {
61
98
  const cwd = ctx.cwd || process.cwd();
99
+ if (!hasSqlfluffConfig(cwd)) {
100
+ return { status: "skipped", diagnostics: [], semantic: "none" };
101
+ }
102
+
62
103
  let cmd: string | null = null;
63
104
  if (sqlfluff.isAvailable(cwd)) {
64
105
  cmd = sqlfluff.getCommand(cwd);
@@ -1,3 +1,5 @@
1
+ import * as nodeFs from "node:fs";
2
+ import * as path from "node:path";
1
3
  import { ensureTool } from "../../installer/index.js";
2
4
  import { safeSpawn } from "../../safe-spawn.js";
3
5
  import { createAvailabilityChecker } from "./utils/runner-helpers.js";
@@ -10,6 +12,49 @@ import type {
10
12
 
11
13
  const yamllint = createAvailabilityChecker("yamllint", ".exe");
12
14
 
15
+ const YAMLLINT_CONFIGS = [
16
+ ".yamllint",
17
+ ".yamllint.yml",
18
+ ".yamllint.yaml",
19
+ "pyproject.toml",
20
+ "setup.cfg",
21
+ "tox.ini",
22
+ ];
23
+
24
+ export function hasYamllintConfig(cwd: string): boolean {
25
+ for (const cfg of YAMLLINT_CONFIGS) {
26
+ const cfgPath = path.join(cwd, cfg);
27
+ if (!nodeFs.existsSync(cfgPath)) continue;
28
+ if (cfg === "pyproject.toml") {
29
+ try {
30
+ const content = nodeFs.readFileSync(cfgPath, "utf-8");
31
+ if (content.includes("[tool.yamllint]")) return true;
32
+ } catch {}
33
+ continue;
34
+ }
35
+ if (cfg === "setup.cfg" || cfg === "tox.ini") {
36
+ try {
37
+ const content = nodeFs.readFileSync(cfgPath, "utf-8");
38
+ if (content.includes("[yamllint]")) return true;
39
+ } catch {}
40
+ continue;
41
+ }
42
+ return true;
43
+ }
44
+
45
+ // Dependency hint fallback for Python projects.
46
+ for (const depFile of ["requirements.txt", "Pipfile", "pyproject.toml"]) {
47
+ const depPath = path.join(cwd, depFile);
48
+ if (!nodeFs.existsSync(depPath)) continue;
49
+ try {
50
+ const content = nodeFs.readFileSync(depPath, "utf-8").toLowerCase();
51
+ if (content.includes("yamllint")) return true;
52
+ } catch {}
53
+ }
54
+
55
+ return false;
56
+ }
57
+
13
58
  function parseYamllintParsable(raw: string, filePath: string): Diagnostic[] {
14
59
  const diagnostics: Diagnostic[] = [];
15
60
  for (const line of raw.split(/\r?\n/)) {
@@ -44,6 +89,10 @@ const yamllintRunner: RunnerDefinition = {
44
89
 
45
90
  async run(ctx: DispatchContext): Promise<RunnerResult> {
46
91
  const cwd = ctx.cwd || process.cwd();
92
+ if (!hasYamllintConfig(cwd)) {
93
+ return { status: "skipped", diagnostics: [], semantic: "none" };
94
+ }
95
+
47
96
  let cmd: string | null = null;
48
97
  if (yamllint.isAvailable(cwd)) {
49
98
  cmd = yamllint.getCommand(cwd);
@@ -18,24 +18,30 @@ import type { BiomeClient } from "./biome-client.js";
18
18
  import { getDiagnosticLogger } from "./diagnostic-logger.js";
19
19
  import { getDiagnosticTracker } from "./diagnostic-tracker.js";
20
20
  import { dispatchLintWithResult } from "./dispatch/integration.js";
21
+ import {
22
+ resolveRunnerPath,
23
+ toRunnerDisplayPath,
24
+ } from "./dispatch/runner-context.js";
21
25
  import type { PiAgentAPI } from "./dispatch/types.js";
22
26
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
23
27
  import type { FormatService } from "./format-service.js";
24
28
  import { logLatency } from "./latency-logger.js";
25
29
  import { getLSPService } from "./lsp/index.js";
26
30
  import type { MetricsClient } from "./metrics-client.js";
31
+ import { normalizeMapKey } from "./path-utils.js";
27
32
  import type { RuffClient } from "./ruff-client.js";
28
33
  import { RUNTIME_CONFIG } from "./runtime-config.js";
29
34
  import { safeSpawnAsync } from "./safe-spawn.js";
30
35
  import { formatSecrets, scanForSecrets } from "./secrets-scanner.js";
31
36
  import type { TestRunnerClient } from "./test-runner-client.js";
32
- import { normalizeMapKey } from "./path-utils.js";
33
- import { resolveRunnerPath, toRunnerDisplayPath } from "./dispatch/runner-context.js";
34
37
 
35
38
  const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
36
39
  const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
37
40
 
38
- function exceedsLspSyncLimits(filePath: string, content: string): {
41
+ function exceedsLspSyncLimits(
42
+ filePath: string,
43
+ content: string,
44
+ ): {
39
45
  tooLarge: boolean;
40
46
  reason: string;
41
47
  } {
@@ -140,6 +146,8 @@ function createPhaseTracker(toolName: string, filePath: string): PhaseTracker {
140
146
 
141
147
  // --- ESLint autofix helpers ---
142
148
 
149
+ const BIOME_CONFIGS = ["biome.json", "biome.jsonc"];
150
+
143
151
  const ESLINT_CONFIGS = [
144
152
  ".eslintrc",
145
153
  ".eslintrc.js",
@@ -158,6 +166,19 @@ function isJsTs(filePath: string): boolean {
158
166
  return JSTS_EXTS.has(path.extname(filePath).toLowerCase());
159
167
  }
160
168
 
169
+ function hasBiomeConfig(cwd: string): boolean {
170
+ for (const cfg of BIOME_CONFIGS) {
171
+ if (nodeFs.existsSync(path.join(cwd, cfg))) return true;
172
+ }
173
+ try {
174
+ const pkg = JSON.parse(
175
+ nodeFs.readFileSync(path.join(cwd, "package.json"), "utf-8"),
176
+ );
177
+ if (pkg.devDependencies?.["@biomejs/biome"]) return true;
178
+ } catch {}
179
+ return false;
180
+ }
181
+
161
182
  function hasEslintConfig(cwd: string): boolean {
162
183
  for (const cfg of ESLINT_CONFIGS) {
163
184
  if (nodeFs.existsSync(path.join(cwd, cfg))) return true;
@@ -171,7 +192,10 @@ function hasEslintConfig(cwd: string): boolean {
171
192
  return false;
172
193
  }
173
194
 
174
- const _eslintCache = new Map<string, { available: boolean; bin: string | null }>();
195
+ const _eslintCache = new Map<
196
+ string,
197
+ { available: boolean; bin: string | null }
198
+ >();
175
199
 
176
200
  function findEslintBin(cwd: string): string {
177
201
  const isWin = process.platform === "win32";
@@ -331,7 +355,7 @@ export async function runPipeline(
331
355
  const deferLspSync =
332
356
  !getFlag("no-autofix") &&
333
357
  (ruffClient.isPythonFile(filePath) ||
334
- biomeClient.isSupportedFile(filePath) ||
358
+ (biomeClient.isSupportedFile(filePath) && hasBiomeConfig(cwd)) ||
335
359
  isJsTs(filePath));
336
360
 
337
361
  if (deferLspSync) {
@@ -386,7 +410,9 @@ export async function runPipeline(
386
410
  !noAutofixRuff && ruffClient.isPythonFile(filePath)
387
411
  ? ruffClient.ensureAvailable()
388
412
  : Promise.resolve(false),
389
- !noAutofixBiome && biomeClient.isSupportedFile(filePath)
413
+ !noAutofixBiome &&
414
+ biomeClient.isSupportedFile(filePath) &&
415
+ hasBiomeConfig(cwd)
390
416
  ? biomeClient.ensureAvailable()
391
417
  : Promise.resolve(false),
392
418
  ]);
@@ -494,12 +520,16 @@ export async function runPipeline(
494
520
  );
495
521
  for (const d of dispatchResult.diagnostics) {
496
522
  const shownInline = inlineKeys.has(toKey(d));
497
- logger.logCaught(d, {
498
- model: ctx.telemetry?.model ?? "unknown",
499
- sessionId: ctx.telemetry?.sessionId ?? "unknown",
500
- turnIndex: ctx.telemetry?.turnIndex ?? 0,
501
- writeIndex: ctx.telemetry?.writeIndex ?? 0,
502
- }, shownInline);
523
+ logger.logCaught(
524
+ d,
525
+ {
526
+ model: ctx.telemetry?.model ?? "unknown",
527
+ sessionId: ctx.telemetry?.sessionId ?? "unknown",
528
+ turnIndex: ctx.telemetry?.turnIndex ?? 0,
529
+ writeIndex: ctx.telemetry?.writeIndex ?? 0,
530
+ },
531
+ shownInline,
532
+ );
503
533
  }
504
534
  }
505
535
 
@@ -552,34 +582,34 @@ export async function runPipeline(
552
582
  target.runner,
553
583
  target.config,
554
584
  );
555
- const testDuration = Date.now() - testStart;
556
- logLatency({
557
- type: "phase",
558
- toolName,
559
- filePath,
560
- phase: "test_runner",
561
- durationMs: testDuration,
562
- metadata: {
563
- testFile: target.testFile,
564
- runner: target.runner,
565
- strategy: target.strategy,
566
- success: !testResult?.error,
567
- },
568
- });
569
- if (testResult && !testResult.error) {
570
- testSummary = {
571
- passed: testResult.passed,
572
- total: testResult.passed + testResult.failed + testResult.skipped,
573
- failed: testResult.failed,
574
- };
575
- if (testSummary.failed > 0) {
576
- hasBlockers = true;
577
- }
578
- const testOutput = testRunnerClient.formatResult(testResult);
579
- if (testOutput) {
580
- output += `\n\n${testOutput}`;
581
- }
585
+ const testDuration = Date.now() - testStart;
586
+ logLatency({
587
+ type: "phase",
588
+ toolName,
589
+ filePath,
590
+ phase: "test_runner",
591
+ durationMs: testDuration,
592
+ metadata: {
593
+ testFile: target.testFile,
594
+ runner: target.runner,
595
+ strategy: target.strategy,
596
+ success: !testResult?.error,
597
+ },
598
+ });
599
+ if (testResult && !testResult.error) {
600
+ testSummary = {
601
+ passed: testResult.passed,
602
+ total: testResult.passed + testResult.failed + testResult.skipped,
603
+ failed: testResult.failed,
604
+ };
605
+ if (testSummary.failed > 0) {
606
+ hasBlockers = true;
607
+ }
608
+ const testOutput = testRunnerClient.formatResult(testResult);
609
+ if (testOutput) {
610
+ output += `\n\n${testOutput}`;
582
611
  }
612
+ }
583
613
  }
584
614
  }
585
615
  phase.end("test_runner", { found: testInfoFound, ran: testRunnerRan });
@@ -608,7 +638,8 @@ export async function runPipeline(
608
638
 
609
639
  for (const [diagPath, diags] of allDiags) {
610
640
  const normalizedDiagPath = resolveRunnerPath(cwd, diagPath);
611
- if (normalizeMapKey(normalizedDiagPath) === normalizedEditedPath) continue;
641
+ if (normalizeMapKey(normalizedDiagPath) === normalizedEditedPath)
642
+ continue;
612
643
 
613
644
  if (!nodeFs.existsSync(normalizedDiagPath)) {
614
645
  stalePathsSkipped++;
@@ -105,7 +105,7 @@ export class TypeCoverageClient {
105
105
  if (result.percentage >= 95) icon = "✓";
106
106
  else if (result.percentage >= 80) icon = "⚠";
107
107
 
108
- let output = `[type-coverage] ${icon} ${pct}% typed (${result.typed}/${result.total} identifiers)`;
108
+ let output = `[type-coverage] ${icon} ${pct}% typed (${result.typed}/${result.total} identifiers; any-typed flagged)`;
109
109
 
110
110
  if (result.untypedLocations.length === 0) {
111
111
  output += " — fully typed\n";
@@ -910,6 +910,8 @@ export async function handleBooboo(
910
910
  });
911
911
 
912
912
  let fullSection = `## Type Coverage\n\n**${tcResult.percentage.toFixed(1)}% typed** (${tcResult.typed}/${tcResult.total} identifiers)\n\n`;
913
+ fullSection +=
914
+ "Type coverage highlights identifiers that resolve to `any` (implicit or explicit). Inferred non-`any` types are treated as typed.\n\n";
913
915
  const byFile: Record<string, number> = {};
914
916
  for (const u of filteredLocations) {
915
917
  byFile[u.file] = (byFile[u.file] || 0) + 1;
@@ -920,7 +922,7 @@ export async function handleBooboo(
920
922
  .slice(0, 10);
921
923
 
922
924
  if (sortedFiles.length > 0) {
923
- fullSection += `### Top Files by Untyped Count\n\n| File | Untyped Count |\n|------|---------------|\n`;
925
+ fullSection += `### Top Files by Any-Typed Identifier Count\n\n| File | Any-Typed Count |\n|------|-----------------|\n`;
924
926
  for (const [file, count] of sortedFiles) {
925
927
  fullSection += `| ${file} | ${count} |\n`;
926
928
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "3.8.18",
3
+ "version": "3.8.19",
4
4
  "type": "module",
5
5
  "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo",
6
6
  "repository": {