pi-lens 2.0.25 → 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
 
@@ -662,6 +667,30 @@ export default function (pi: ExtensionAPI) {
662
667
  type: "skip",
663
668
  note: "Renaming requires understanding all variable scopes.",
664
669
  },
670
+ "no-process-env": {
671
+ type: "skip",
672
+ note: "Using process.env directly makes code untestable. Use DI or a config module.",
673
+ },
674
+ "no-param-reassign": {
675
+ type: "agent",
676
+ note: "Create a new variable instead of reassigning the parameter.",
677
+ },
678
+ "no-single-char-var": {
679
+ type: "skip",
680
+ note: "Renaming requires understanding the variable's purpose.",
681
+ },
682
+ "switch-without-default": {
683
+ type: "agent",
684
+ note: "Add a default case to handle unexpected values.",
685
+ },
686
+ "no-architecture-violation": {
687
+ type: "skip",
688
+ note: "Layer boundary violations require architectural decisions.",
689
+ },
690
+ "switch-exhaustiveness": {
691
+ type: "agent",
692
+ note: "Add the missing case(s) or a default clause to handle all union values.",
693
+ },
665
694
  };
666
695
 
667
696
  // Derived from RULE_ACTIONS — used to suppress architectural rules from inline hard stops.
@@ -2005,6 +2034,28 @@ export default function (pi: ExtensionAPI) {
2005
2034
  }
2006
2035
  }
2007
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
+
2008
2059
  // ast-grep structural analysis — delta mode (only show new violations)
2009
2060
  if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
2010
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.25",
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",
@@ -0,0 +1,15 @@
1
+ id: no-architecture-violation
2
+ language: TypeScript
3
+ message: "Forbidden import — layer boundary violation: importing from database/model layer"
4
+ severity: error
5
+ note: |
6
+ This import crosses an architectural layer boundary.
7
+ Use dependency injection or a shared types/interfaces module instead.
8
+
9
+ BAD: import { User } from '../models/user';
10
+ GOOD: import { User } from '../types/user';
11
+ rule:
12
+ pattern: import { $$$ } from "$PATH"
13
+ constraints:
14
+ PATH:
15
+ regex: ".*models.*|.*database.*|.*prisma.*"
@@ -0,0 +1,16 @@
1
+ id: no-param-reassign
2
+ language: TypeScript
3
+ message: "Do not reassign function parameters — create a new variable instead"
4
+ severity: warning
5
+ note: |
6
+ Reassigning parameters makes code harder to debug and reason about.
7
+ It can cause unexpected side effects when the caller's reference changes.
8
+ Create a new variable with a descriptive name instead.
9
+
10
+ BAD: function update(user) { user = { ...user, active: true }; }
11
+ GOOD: function update(user) { const updated = { ...user, active: true }; }
12
+ rule:
13
+ pattern: |
14
+ function $FUNC($PARAM, $$$) {
15
+ $PARAM = $VALUE
16
+ }
@@ -0,0 +1,18 @@
1
+ id: no-process-env
2
+ language: TypeScript
3
+ message: "Do not access process.env directly — use dependency injection or a config module"
4
+ severity: error
5
+ note: |
6
+ Direct process.env access makes code untestable and tightly coupled to the
7
+ runtime environment. Use a configuration service or DI container instead.
8
+
9
+ BAD: const dbUrl = process.env.DATABASE_URL;
10
+ GOOD: constructor(private config: ConfigService) {}
11
+ const dbUrl = this.config.get('DATABASE_URL');
12
+
13
+ Grade 2.0 — critical architectural violation for testability.
14
+ rule:
15
+ any:
16
+ - pattern: process.env.$KEY
17
+ - pattern: process.env[$KEY]
18
+ - pattern: Deno.env.get($KEY)
@@ -0,0 +1,12 @@
1
+ id: no-single-char-var
2
+ language: TypeScript
3
+ message: "Avoid single-character variable names — use descriptive names"
4
+ severity: info
5
+ note: |
6
+ Single-character variable names reduce readability.
7
+ Exception: loop counters (i, j, k) in short loops are acceptable.
8
+ rule:
9
+ pattern: const $VAR = $VALUE
10
+ constraints:
11
+ VAR:
12
+ regex: "^[a-z]$"
@@ -0,0 +1,12 @@
1
+ id: switch-without-default
2
+ language: TypeScript
3
+ message: "Switch statement is missing a default case"
4
+ severity: warning
5
+ note: |
6
+ Every switch should have a default case. Without it, new enum values won't be caught at runtime.
7
+ rule:
8
+ all:
9
+ - pattern: switch ($EXPR) { $$$CASES }
10
+ - not:
11
+ has:
12
+ pattern: "default: $$$"