pi-lens 2.0.26 → 2.0.27

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Type Safety Client for pi-lens
3
+ *
4
+ * Detects type safety violations that can cause runtime bugs.
5
+ * Uses the TypeScript compiler API for type-aware analysis.
6
+ *
7
+ * Checks:
8
+ * - Switch Exhaustiveness: Missing cases in union type switches
9
+ * - Null Safety: Potential null/undefined dereferences (future)
10
+ * - Exhaustive Type Guards: Incomplete instanceof checks (future)
11
+ */
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as ts from "typescript";
15
+ // --- Client ---
16
+ export class TypeSafetyClient {
17
+ constructor(verbose = false) {
18
+ this.log = verbose
19
+ ? (msg) => console.error(`[type-safety] ${msg}`)
20
+ : () => { };
21
+ }
22
+ /**
23
+ * Check if file is supported (TS/JS)
24
+ */
25
+ isSupportedFile(filePath) {
26
+ const ext = path.extname(filePath).toLowerCase();
27
+ return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
28
+ }
29
+ /**
30
+ * Analyze type safety issues for a file
31
+ */
32
+ analyzeFile(filePath) {
33
+ const absolutePath = path.resolve(filePath);
34
+ if (!fs.existsSync(absolutePath))
35
+ return null;
36
+ try {
37
+ const content = fs.readFileSync(absolutePath, "utf-8");
38
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
39
+ const issues = [];
40
+ // Check switch exhaustiveness
41
+ this.checkSwitchExhaustiveness(sourceFile, issues);
42
+ return { filePath: absolutePath, issues };
43
+ }
44
+ catch (error) {
45
+ this.log(`Error analyzing ${filePath}: ${error}`);
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Check for switch statements that don't exhaust all union cases
51
+ */
52
+ checkSwitchExhaustiveness(sourceFile, issues) {
53
+ const checker = this.getTypeChecker(sourceFile);
54
+ if (!checker)
55
+ return;
56
+ const visit = (node) => {
57
+ if (ts.isSwitchStatement(node)) {
58
+ const exprType = checker.getTypeAtLocation(node.expression);
59
+ // Only check union types (literal unions and object unions)
60
+ if (exprType.isUnion()) {
61
+ const unionTypes = exprType.types;
62
+ // Get all literal values from the union
63
+ const literalValues = unionTypes
64
+ .filter((t) => t.isLiteral() || t.flags & ts.TypeFlags.BooleanLiteral)
65
+ .map((t) => {
66
+ if (t.isLiteral()) {
67
+ return String(t.value);
68
+ }
69
+ // Boolean literals
70
+ if (t.flags & ts.TypeFlags.BooleanLiteral) {
71
+ return checker.typeToString(t);
72
+ }
73
+ return null;
74
+ })
75
+ .filter((v) => v !== null);
76
+ // Skip if no literal union (e.g., string | number)
77
+ if (literalValues.length === 0)
78
+ return;
79
+ // Get all case clauses
80
+ const coveredCases = new Set();
81
+ for (const clause of node.caseBlock.clauses) {
82
+ if (ts.isCaseClause(clause)) {
83
+ const caseType = checker.getTypeAtLocation(clause.expression);
84
+ if (caseType.isLiteral()) {
85
+ coveredCases.add(String(caseType.value));
86
+ }
87
+ else if (caseType.flags & ts.TypeFlags.BooleanLiteral) {
88
+ coveredCases.add(checker.typeToString(caseType));
89
+ }
90
+ }
91
+ }
92
+ // Check for hasDefault
93
+ const hasDefault = node.caseBlock.clauses.some((c) => ts.isDefaultClause(c));
94
+ // Find missing cases
95
+ const missingCases = literalValues.filter((v) => !coveredCases.has(v));
96
+ if (missingCases.length > 0 && !hasDefault) {
97
+ const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
98
+ const exprText = node.expression.getText(sourceFile);
99
+ const typeStr = missingCases.map((c) => `'${c}'`).join(", ");
100
+ issues.push({
101
+ filePath: sourceFile.fileName,
102
+ rule: "switch-exhaustiveness",
103
+ line,
104
+ message: `Switch on '${exprText}' is not exhaustive. Missing cases: ${typeStr}`,
105
+ severity: "error",
106
+ context: `Type has ${literalValues.length} cases, ${coveredCases.size} covered, ${missingCases.length} missing`,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ ts.forEachChild(node, visit);
112
+ };
113
+ ts.forEachChild(sourceFile, visit);
114
+ }
115
+ /**
116
+ * Get type checker for the source file
117
+ */
118
+ getTypeChecker(sourceFile) {
119
+ try {
120
+ const compilerOptions = {
121
+ target: ts.ScriptTarget.Latest,
122
+ module: ts.ModuleKind.ESNext,
123
+ strict: true,
124
+ noEmit: true,
125
+ skipLibCheck: true,
126
+ };
127
+ // Create a host that uses our pre-parsed source file
128
+ const host = ts.createCompilerHost(compilerOptions);
129
+ const originalGetSourceFile = host.getSourceFile;
130
+ host.getSourceFile = (fileName, languageVersion) => {
131
+ if (fileName === sourceFile.fileName)
132
+ return sourceFile;
133
+ return originalGetSourceFile(fileName, languageVersion);
134
+ };
135
+ const program = ts.createProgram([sourceFile.fileName], compilerOptions, host);
136
+ return program.getTypeChecker();
137
+ }
138
+ catch {
139
+ this.log("Could not create type checker, skipping exhaustiveness check");
140
+ return null;
141
+ }
142
+ }
143
+ }
144
+ // --- Singleton ---
145
+ let instance = null;
146
+ export function getTypeSafetyClient(verbose = false) {
147
+ if (!instance) {
148
+ instance = new TypeSafetyClient(verbose);
149
+ }
150
+ return instance;
151
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Type Safety Client for pi-lens
3
+ *
4
+ * Detects type safety violations that can cause runtime bugs.
5
+ * Uses the TypeScript compiler API for type-aware analysis.
6
+ *
7
+ * Checks:
8
+ * - Switch Exhaustiveness: Missing cases in union type switches
9
+ * - Null Safety: Potential null/undefined dereferences (future)
10
+ * - Exhaustive Type Guards: Incomplete instanceof checks (future)
11
+ */
12
+
13
+ import * as fs from "node:fs";
14
+ import * as path from "node:path";
15
+ import * as ts from "typescript";
16
+
17
+ // --- Types ---
18
+
19
+ export interface TypeSafetyIssue {
20
+ filePath: string;
21
+ rule: "switch-exhaustiveness" | "null-safety" | "exhaustive-type-guard";
22
+ line: number;
23
+ message: string;
24
+ severity: "error" | "warning";
25
+ context: string; // Code snippet or type info
26
+ }
27
+
28
+ export interface TypeSafetyReport {
29
+ filePath: string;
30
+ issues: TypeSafetyIssue[];
31
+ }
32
+
33
+ // --- Client ---
34
+
35
+ export class TypeSafetyClient {
36
+ private log: (msg: string) => void;
37
+
38
+ constructor(verbose = false) {
39
+ this.log = verbose
40
+ ? (msg: string) => console.error(`[type-safety] ${msg}`)
41
+ : () => {};
42
+ }
43
+
44
+ /**
45
+ * Check if file is supported (TS/JS)
46
+ */
47
+ isSupportedFile(filePath: string): boolean {
48
+ const ext = path.extname(filePath).toLowerCase();
49
+ return [".ts", ".tsx", ".js", ".jsx"].includes(ext);
50
+ }
51
+
52
+ /**
53
+ * Analyze type safety issues for a file
54
+ */
55
+ analyzeFile(filePath: string): TypeSafetyReport | null {
56
+ const absolutePath = path.resolve(filePath);
57
+ if (!fs.existsSync(absolutePath)) return null;
58
+
59
+ try {
60
+ const content = fs.readFileSync(absolutePath, "utf-8");
61
+ const sourceFile = ts.createSourceFile(
62
+ filePath,
63
+ content,
64
+ ts.ScriptTarget.Latest,
65
+ true,
66
+ );
67
+
68
+ const issues: TypeSafetyIssue[] = [];
69
+
70
+ // Check switch exhaustiveness
71
+ this.checkSwitchExhaustiveness(sourceFile, issues);
72
+
73
+ return { filePath: absolutePath, issues };
74
+ } catch (error) {
75
+ this.log(`Error analyzing ${filePath}: ${error}`);
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Check for switch statements that don't exhaust all union cases
82
+ */
83
+ private checkSwitchExhaustiveness(
84
+ sourceFile: ts.SourceFile,
85
+ issues: TypeSafetyIssue[],
86
+ ): void {
87
+ const checker = this.getTypeChecker(sourceFile);
88
+ if (!checker) return;
89
+
90
+ const visit = (node: ts.Node) => {
91
+ if (ts.isSwitchStatement(node)) {
92
+ const exprType = checker.getTypeAtLocation(node.expression);
93
+
94
+ // Only check union types (literal unions and object unions)
95
+ if (exprType.isUnion()) {
96
+ const unionTypes = exprType.types;
97
+
98
+ // Get all literal values from the union
99
+ const literalValues = unionTypes
100
+ .filter((t) => t.isLiteral() || t.flags & ts.TypeFlags.BooleanLiteral)
101
+ .map((t) => {
102
+ if (t.isLiteral()) {
103
+ return String(t.value);
104
+ }
105
+ // Boolean literals
106
+ if (t.flags & ts.TypeFlags.BooleanLiteral) {
107
+ return checker.typeToString(t);
108
+ }
109
+ return null;
110
+ })
111
+ .filter((v): v is string => v !== null);
112
+
113
+ // Skip if no literal union (e.g., string | number)
114
+ if (literalValues.length === 0) return;
115
+
116
+ // Get all case clauses
117
+ const coveredCases = new Set<string>();
118
+ for (const clause of node.caseBlock.clauses) {
119
+ if (ts.isCaseClause(clause)) {
120
+ const caseType = checker.getTypeAtLocation(clause.expression);
121
+ if (caseType.isLiteral()) {
122
+ coveredCases.add(String(caseType.value));
123
+ } else if (caseType.flags & ts.TypeFlags.BooleanLiteral) {
124
+ coveredCases.add(checker.typeToString(caseType));
125
+ }
126
+ }
127
+ }
128
+
129
+ // Check for hasDefault
130
+ const hasDefault = node.caseBlock.clauses.some((c) =>
131
+ ts.isDefaultClause(c),
132
+ );
133
+
134
+ // Find missing cases
135
+ const missingCases = literalValues.filter(
136
+ (v) => !coveredCases.has(v),
137
+ );
138
+
139
+ if (missingCases.length > 0 && !hasDefault) {
140
+ const line = sourceFile.getLineAndCharacterOfPosition(
141
+ node.getStart(),
142
+ ).line + 1;
143
+
144
+ const exprText = node.expression.getText(sourceFile);
145
+ const typeStr = missingCases.map((c) => `'${c}'`).join(", ");
146
+
147
+ issues.push({
148
+ filePath: sourceFile.fileName,
149
+ rule: "switch-exhaustiveness",
150
+ line,
151
+ message: `Switch on '${exprText}' is not exhaustive. Missing cases: ${typeStr}`,
152
+ severity: "error",
153
+ context: `Type has ${literalValues.length} cases, ${coveredCases.size} covered, ${missingCases.length} missing`,
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ ts.forEachChild(node, visit);
160
+ };
161
+
162
+ ts.forEachChild(sourceFile, visit);
163
+ }
164
+
165
+ /**
166
+ * Get type checker for the source file
167
+ */
168
+ private getTypeChecker(sourceFile: ts.SourceFile): ts.TypeChecker | null {
169
+ try {
170
+ const compilerOptions: ts.CompilerOptions = {
171
+ target: ts.ScriptTarget.Latest,
172
+ module: ts.ModuleKind.ESNext,
173
+ strict: true,
174
+ noEmit: true,
175
+ skipLibCheck: true,
176
+ };
177
+
178
+ // Create a host that uses our pre-parsed source file
179
+ const host = ts.createCompilerHost(compilerOptions);
180
+ const originalGetSourceFile = host.getSourceFile;
181
+ host.getSourceFile = (fileName, languageVersion) => {
182
+ if (fileName === sourceFile.fileName) return sourceFile;
183
+ return originalGetSourceFile(fileName, languageVersion);
184
+ };
185
+
186
+ const program = ts.createProgram(
187
+ [sourceFile.fileName],
188
+ compilerOptions,
189
+ host,
190
+ );
191
+
192
+ return program.getTypeChecker();
193
+ } catch {
194
+ this.log("Could not create type checker, skipping exhaustiveness check");
195
+ return null;
196
+ }
197
+ }
198
+
199
+ }
200
+
201
+ // --- Singleton ---
202
+
203
+ let instance: TypeSafetyClient | null = null;
204
+
205
+ export function getTypeSafetyClient(verbose = false): TypeSafetyClient {
206
+ if (!instance) {
207
+ instance = new TypeSafetyClient(verbose);
208
+ }
209
+ return instance;
210
+ }
package/index.ts CHANGED
@@ -31,6 +31,10 @@ import { Type } from "@sinclair/typebox";
31
31
  import { AstGrepClient } from "./clients/ast-grep-client.js";
32
32
  import { BiomeClient } from "./clients/biome-client.js";
33
33
  import { ComplexityClient } from "./clients/complexity-client.js";
34
+ import {
35
+ TypeSafetyClient,
36
+ getTypeSafetyClient,
37
+ } from "./clients/type-safety-client.js";
34
38
  import { DependencyChecker } from "./clients/dependency-checker.js";
35
39
  import { GoClient } from "./clients/go-client.js";
36
40
  import { JscpdClient } from "./clients/jscpd-client.js";
@@ -78,6 +82,7 @@ export default function (pi: ExtensionAPI) {
78
82
  const testRunnerClient = new TestRunnerClient();
79
83
  const metricsClient = new MetricsClient();
80
84
  const complexityClient = new ComplexityClient();
85
+ const typeSafetyClient = new TypeSafetyClient();
81
86
  const goClient = new GoClient();
82
87
  const rustClient = new RustClient();
83
88
 
@@ -682,6 +687,10 @@ export default function (pi: ExtensionAPI) {
682
687
  type: "skip",
683
688
  note: "Layer boundary violations require architectural decisions.",
684
689
  },
690
+ "switch-exhaustiveness": {
691
+ type: "agent",
692
+ note: "Add the missing case(s) or a default clause to handle all union values.",
693
+ },
685
694
  };
686
695
 
687
696
  // Derived from RULE_ACTIONS — used to suppress architectural rules from inline hard stops.
@@ -2025,6 +2034,28 @@ export default function (pi: ExtensionAPI) {
2025
2034
  }
2026
2035
  }
2027
2036
 
2037
+ // Type safety checks (switch exhaustiveness, etc.)
2038
+ if (typeSafetyClient.isSupportedFile(filePath)) {
2039
+ const report = typeSafetyClient.analyzeFile(filePath);
2040
+ if (report && report.issues.length > 0) {
2041
+ const errors = report.issues.filter((i) => i.severity === "error");
2042
+ const warnings = report.issues.filter((i) => i.severity === "warning");
2043
+ if (errors.length > 0) {
2044
+ lspOutput += `\n\nšŸ”“ STOP — ${errors.length} type safety violation(s). Fix before continuing:\n`;
2045
+ for (const issue of errors) {
2046
+ lspOutput += ` L${issue.line}: ${issue.message}\n`;
2047
+ lspOutput += ` → Add missing cases or add a default clause\n`;
2048
+ }
2049
+ }
2050
+ if (warnings.length > 0) {
2051
+ lspOutput += `\n\n🟔 ${warnings.length} type safety warning(s):\n`;
2052
+ for (const issue of warnings) {
2053
+ lspOutput += ` L${issue.line}: ${issue.message}\n`;
2054
+ }
2055
+ }
2056
+ }
2057
+ }
2058
+
2028
2059
  // ast-grep structural analysis — delta mode (only show new violations)
2029
2060
  if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
2030
2061
  const after = astGrepClient.scanFile(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "2.0.26",
3
+ "version": "2.0.27",
4
4
  "description": "Real-time code quality feedback for pi — TypeScript LSP, Biome, ast-grep, Ruff, complexity metrics, duplicate detection. Includes automated fix loop (/lens-booboo-fix) and interactive architectural refactoring (/lens-booboo-refactor) with browser-based interviews.",
5
5
  "repository": {
6
6
  "type": "git",