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.
- package/clients/type-safety-client.js +151 -0
- package/clients/type-safety-client.ts +210 -0
- package/index.ts +31 -0
- package/package.json +1 -1
|
@@ -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.
|
|
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",
|