pi-lens 2.0.37 → 2.0.39
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 +16 -0
- package/README.md +1 -1
- package/clients/architect-client.js +50 -20
- package/clients/architect-client.ts +61 -22
- package/clients/ast-grep-client.js +32 -127
- package/clients/ast-grep-client.test.js +2 -14
- package/clients/ast-grep-client.test.ts +2 -16
- package/clients/ast-grep-client.ts +34 -150
- package/clients/auto-loop.js +117 -0
- package/clients/auto-loop.ts +171 -0
- package/clients/biome-client.js +25 -22
- package/clients/biome-client.ts +28 -25
- package/clients/complexity-client.js +111 -74
- package/clients/complexity-client.ts +149 -105
- package/clients/dependency-checker.js +16 -30
- package/clients/dependency-checker.ts +15 -35
- package/clients/fix-scanners.js +195 -0
- package/clients/fix-scanners.ts +297 -0
- package/clients/interviewer-templates.js +75 -0
- package/clients/interviewer-templates.ts +90 -0
- package/clients/interviewer.js +73 -101
- package/clients/interviewer.ts +195 -140
- package/clients/knip-client.js +12 -4
- package/clients/knip-client.ts +21 -16
- package/clients/metrics-history.js +215 -0
- package/clients/metrics-history.ts +300 -0
- package/clients/scan-architectural-debt.js +62 -72
- package/clients/scan-architectural-debt.ts +79 -64
- package/clients/scan-utils.js +98 -0
- package/clients/scan-utils.ts +112 -0
- package/clients/sg-runner.js +138 -0
- package/clients/sg-runner.ts +168 -0
- package/clients/subprocess-client.js +0 -37
- package/clients/subprocess-client.ts +0 -60
- package/clients/ts-service.ts +1 -7
- package/clients/type-safety-client.js +3 -8
- package/clients/type-safety-client.ts +10 -14
- package/clients/typescript-client.js +55 -56
- package/clients/typescript-client.ts +94 -52
- package/default-architect.yaml +87 -0
- package/index.ts +143 -1165
- package/package.json +2 -1
- package/rules/ast-grep-rules/rules/large-class.yml +6 -2
- package/rules/ast-grep-rules/rules/long-method.yml +1 -1
- package/rules/ast-grep-rules/rules/no-single-char-var.yml +2 -2
- package/rules/ast-grep-rules/rules/switch-without-default.yml +0 -12
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
* Shared architectural debt scanning — used by booboo-fix and booboo-refactor.
|
|
3
3
|
* Scans ast-grep skip rules + complexity metrics + architect.yaml rules.
|
|
4
4
|
*/
|
|
5
|
+
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
5
7
|
import * as fs from "node:fs";
|
|
6
8
|
import * as path from "node:path";
|
|
9
|
+
import type { ArchitectClient } from "./architect-client.js";
|
|
7
10
|
import type { AstGrepClient } from "./ast-grep-client.js";
|
|
8
11
|
import type { ComplexityClient } from "./complexity-client.js";
|
|
9
|
-
import
|
|
12
|
+
import { getSourceFiles, parseAstGrepJson } from "./scan-utils.js";
|
|
10
13
|
|
|
11
14
|
export type SkipIssue = { rule: string; line: number; note: string };
|
|
12
15
|
export type FileMetrics = { mi: number; cognitive: number; nesting: number };
|
|
@@ -25,29 +28,41 @@ export function scanSkipViolations(
|
|
|
25
28
|
const skipByFile = new Map<string, SkipIssue[]>();
|
|
26
29
|
if (!astGrepClient.isAvailable()) return skipByFile;
|
|
27
30
|
|
|
28
|
-
const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
|
|
29
31
|
const sgResult = spawnSync(
|
|
30
32
|
"npx",
|
|
31
33
|
[
|
|
32
|
-
"sg",
|
|
33
|
-
"
|
|
34
|
-
"--
|
|
34
|
+
"sg",
|
|
35
|
+
"scan",
|
|
36
|
+
"--config",
|
|
37
|
+
configPath,
|
|
38
|
+
"--json",
|
|
39
|
+
"--globs",
|
|
40
|
+
"!**/*.test.ts",
|
|
41
|
+
"--globs",
|
|
42
|
+
"!**/*.spec.ts",
|
|
43
|
+
"--globs",
|
|
44
|
+
"!**/test-utils.ts",
|
|
45
|
+
"--globs",
|
|
46
|
+
"!**/.pi-lens/**",
|
|
35
47
|
...(isTsProject ? ["--globs", "!**/*.js"] : []),
|
|
36
48
|
targetPath,
|
|
37
49
|
],
|
|
38
|
-
{
|
|
50
|
+
{
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
timeout: 30000,
|
|
53
|
+
shell: true,
|
|
54
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
55
|
+
},
|
|
39
56
|
);
|
|
40
57
|
|
|
41
|
-
const
|
|
42
|
-
// biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
|
|
43
|
-
const items: Record<string, any>[] = raw.startsWith("[")
|
|
44
|
-
? (() => { try { return JSON.parse(raw); } catch { return []; } })()
|
|
45
|
-
: raw.split("\n").flatMap((l: string) => { try { return [JSON.parse(l)]; } catch { return []; } });
|
|
58
|
+
const items = parseAstGrepJson(sgResult.stdout?.trim() ?? "");
|
|
46
59
|
|
|
47
60
|
for (const item of items) {
|
|
48
61
|
const rule = item.ruleId || item.rule?.title || item.name || "unknown";
|
|
49
62
|
if (!skipRules.has(rule)) continue;
|
|
50
|
-
const line =
|
|
63
|
+
const line =
|
|
64
|
+
(item.labels?.[0]?.range?.start?.line ?? item.range?.start?.line ?? 0) +
|
|
65
|
+
1;
|
|
51
66
|
const absFile = path.resolve(item.file ?? "");
|
|
52
67
|
const list = skipByFile.get(absFile) ?? [];
|
|
53
68
|
list.push({ rule, line, note: ruleActions[rule]?.note ?? "" });
|
|
@@ -65,25 +80,22 @@ export function scanComplexityMetrics(
|
|
|
65
80
|
isTsProject: boolean,
|
|
66
81
|
): Map<string, FileMetrics> {
|
|
67
82
|
const metricsByFile = new Map<string, FileMetrics>();
|
|
83
|
+
const files = getSourceFiles(targetPath, isTsProject);
|
|
68
84
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const m = complexityClient.analyzeFile(full);
|
|
82
|
-
if (m) metricsByFile.set(full, { mi: m.maintainabilityIndex, cognitive: m.cognitiveComplexity, nesting: m.maxNestingDepth });
|
|
83
|
-
}
|
|
85
|
+
for (const full of files) {
|
|
86
|
+
if (
|
|
87
|
+
complexityClient.isSupportedFile(full) &&
|
|
88
|
+
!/\.(test|spec)\.[jt]sx?$/.test(path.basename(full))
|
|
89
|
+
) {
|
|
90
|
+
const m = complexityClient.analyzeFile(full);
|
|
91
|
+
if (m)
|
|
92
|
+
metricsByFile.set(full, {
|
|
93
|
+
mi: m.maintainabilityIndex,
|
|
94
|
+
cognitive: m.cognitiveComplexity,
|
|
95
|
+
nesting: m.maxNestingDepth,
|
|
96
|
+
});
|
|
84
97
|
}
|
|
85
|
-
}
|
|
86
|
-
scanDir(targetPath);
|
|
98
|
+
}
|
|
87
99
|
return metricsByFile;
|
|
88
100
|
}
|
|
89
101
|
|
|
@@ -98,37 +110,31 @@ export function scanArchitectViolations(
|
|
|
98
110
|
const violationsByFile = new Map<string, string[]>();
|
|
99
111
|
if (!architectClient.hasConfig()) return violationsByFile;
|
|
100
112
|
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
const sizeV = architectClient.checkFileSize(relPath, lineCount);
|
|
121
|
-
if (sizeV) {
|
|
122
|
-
msgs.push(sizeV.message);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (msgs.length > 0) {
|
|
126
|
-
violationsByFile.set(full, msgs);
|
|
127
|
-
}
|
|
128
|
-
}
|
|
113
|
+
const isTsProject = fs.existsSync(path.join(targetPath, "tsconfig.json"));
|
|
114
|
+
const files = getSourceFiles(targetPath, isTsProject);
|
|
115
|
+
|
|
116
|
+
for (const full of files) {
|
|
117
|
+
const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
|
|
118
|
+
const content = fs.readFileSync(full, "utf-8");
|
|
119
|
+
const lineCount = content.split("\n").length;
|
|
120
|
+
const msgs: string[] = [];
|
|
121
|
+
|
|
122
|
+
// Check pattern violations
|
|
123
|
+
for (const v of architectClient.checkFile(relPath, content)) {
|
|
124
|
+
const lineStr = v.line ? `L${v.line}: ` : "";
|
|
125
|
+
msgs.push(`${lineStr}${v.message}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check file size
|
|
129
|
+
const sizeV = architectClient.checkFileSize(relPath, lineCount);
|
|
130
|
+
if (sizeV) {
|
|
131
|
+
msgs.push(sizeV.message);
|
|
129
132
|
}
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
|
|
134
|
+
if (msgs.length > 0) {
|
|
135
|
+
violationsByFile.set(full, msgs);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
132
138
|
return violationsByFile;
|
|
133
139
|
}
|
|
134
140
|
|
|
@@ -150,9 +156,14 @@ export function scoreFiles(
|
|
|
150
156
|
let score = 0;
|
|
151
157
|
const m = metricsByFile.get(file);
|
|
152
158
|
if (m) {
|
|
153
|
-
if (m.mi < 20) score += 5;
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
if (m.mi < 20) score += 5;
|
|
160
|
+
else if (m.mi < 40) score += 3;
|
|
161
|
+
else if (m.mi < 60) score += 1;
|
|
162
|
+
if (m.cognitive > 300) score += 4;
|
|
163
|
+
else if (m.cognitive > 150) score += 2;
|
|
164
|
+
else if (m.cognitive > 80) score += 1;
|
|
165
|
+
if (m.nesting > 8) score += 2;
|
|
166
|
+
else if (m.nesting > 5) score += 1;
|
|
156
167
|
}
|
|
157
168
|
for (const issue of skipByFile.get(file) ?? []) {
|
|
158
169
|
if (issue.rule === "large-class") score += 5;
|
|
@@ -182,9 +193,13 @@ export function extractCodeSnippet(
|
|
|
182
193
|
): { snippet: string; start: number; end: number } | null {
|
|
183
194
|
try {
|
|
184
195
|
const fileLines = fs.readFileSync(filePath, "utf-8").split("\n");
|
|
185
|
-
const start = Math.max(0,
|
|
196
|
+
const start = Math.max(0, firstLine - 1 - contextLines);
|
|
186
197
|
const end = Math.min(fileLines.length, start + maxLines);
|
|
187
|
-
return {
|
|
198
|
+
return {
|
|
199
|
+
snippet: fileLines.slice(start, end).join("\n"),
|
|
200
|
+
start: start + 1,
|
|
201
|
+
end,
|
|
202
|
+
};
|
|
188
203
|
} catch {
|
|
189
204
|
return null;
|
|
190
205
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Common parsing logic for ast-grep JSON output (handles both array and NDJSON).
|
|
5
|
+
*/
|
|
6
|
+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
|
|
7
|
+
export function parseAstGrepJson(raw) {
|
|
8
|
+
if (!raw)
|
|
9
|
+
return [];
|
|
10
|
+
const trimmed = raw.trim();
|
|
11
|
+
if (trimmed.startsWith("[")) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(trimmed);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return trimmed.split("\n").flatMap((l) => {
|
|
20
|
+
try {
|
|
21
|
+
return [JSON.parse(l)];
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Check if a file should be ignored based on project type and common patterns.
|
|
30
|
+
*/
|
|
31
|
+
export function shouldIgnoreFile(filePath, isTsProject) {
|
|
32
|
+
const relPath = filePath.replace(/\\/g, "/");
|
|
33
|
+
const basename = path.basename(relPath);
|
|
34
|
+
// Ignore compiled JS in TS projects
|
|
35
|
+
const isJs = relPath.endsWith(".js") ||
|
|
36
|
+
relPath.endsWith(".mjs") ||
|
|
37
|
+
relPath.endsWith(".cjs");
|
|
38
|
+
if (isTsProject && isJs)
|
|
39
|
+
return true;
|
|
40
|
+
// Ignore test scripts and common test patterns
|
|
41
|
+
if (basename.startsWith("test-") ||
|
|
42
|
+
basename.includes(".test.") ||
|
|
43
|
+
basename.includes(".spec.")) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
// Ignore hidden directories and common build outputs
|
|
47
|
+
if (relPath.includes("/node_modules/") ||
|
|
48
|
+
relPath.includes("/.git/") ||
|
|
49
|
+
relPath.includes("/dist/") ||
|
|
50
|
+
relPath.includes("/build/") ||
|
|
51
|
+
relPath.includes("/.next/") ||
|
|
52
|
+
relPath.includes("/.pi-lens/")) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Recursively find source files in a directory, respecting common excludes.
|
|
59
|
+
*/
|
|
60
|
+
export function getSourceFiles(dir, isTsProject) {
|
|
61
|
+
const files = [];
|
|
62
|
+
if (!fs.existsSync(dir))
|
|
63
|
+
return files;
|
|
64
|
+
const scan = (d) => {
|
|
65
|
+
let entries = [];
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
const full = path.join(d, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
if ([
|
|
76
|
+
"node_modules",
|
|
77
|
+
".git",
|
|
78
|
+
"dist",
|
|
79
|
+
"build",
|
|
80
|
+
".next",
|
|
81
|
+
".pi-lens",
|
|
82
|
+
].includes(entry.name))
|
|
83
|
+
continue;
|
|
84
|
+
scan(full);
|
|
85
|
+
}
|
|
86
|
+
else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
|
|
87
|
+
// Skip compiled JS if it's a TS project
|
|
88
|
+
if (isTsProject &&
|
|
89
|
+
entry.name.endsWith(".js") &&
|
|
90
|
+
fs.existsSync(full.replace(/\.js$/, ".ts")))
|
|
91
|
+
continue;
|
|
92
|
+
files.push(full);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
scan(dir);
|
|
97
|
+
return files;
|
|
98
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Common parsing logic for ast-grep JSON output (handles both array and NDJSON).
|
|
6
|
+
*/
|
|
7
|
+
// biome-ignore lint/suspicious/noExplicitAny: ast-grep JSON output is untyped
|
|
8
|
+
export function parseAstGrepJson(raw: string): any[] {
|
|
9
|
+
if (!raw) return [];
|
|
10
|
+
const trimmed = raw.trim();
|
|
11
|
+
if (trimmed.startsWith("[")) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(trimmed);
|
|
14
|
+
} catch {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return trimmed.split("\n").flatMap((l) => {
|
|
19
|
+
try {
|
|
20
|
+
return [JSON.parse(l)];
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a file should be ignored based on project type and common patterns.
|
|
29
|
+
*/
|
|
30
|
+
export function shouldIgnoreFile(
|
|
31
|
+
filePath: string,
|
|
32
|
+
isTsProject: boolean,
|
|
33
|
+
): boolean {
|
|
34
|
+
const relPath = filePath.replace(/\\/g, "/");
|
|
35
|
+
const basename = path.basename(relPath);
|
|
36
|
+
|
|
37
|
+
// Ignore compiled JS in TS projects
|
|
38
|
+
const isJs =
|
|
39
|
+
relPath.endsWith(".js") ||
|
|
40
|
+
relPath.endsWith(".mjs") ||
|
|
41
|
+
relPath.endsWith(".cjs");
|
|
42
|
+
if (isTsProject && isJs) return true;
|
|
43
|
+
|
|
44
|
+
// Ignore test scripts and common test patterns
|
|
45
|
+
if (
|
|
46
|
+
basename.startsWith("test-") ||
|
|
47
|
+
basename.includes(".test.") ||
|
|
48
|
+
basename.includes(".spec.")
|
|
49
|
+
) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Ignore hidden directories and common build outputs
|
|
54
|
+
if (
|
|
55
|
+
relPath.includes("/node_modules/") ||
|
|
56
|
+
relPath.includes("/.git/") ||
|
|
57
|
+
relPath.includes("/dist/") ||
|
|
58
|
+
relPath.includes("/build/") ||
|
|
59
|
+
relPath.includes("/.next/") ||
|
|
60
|
+
relPath.includes("/.pi-lens/")
|
|
61
|
+
) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Recursively find source files in a directory, respecting common excludes.
|
|
70
|
+
*/
|
|
71
|
+
export function getSourceFiles(dir: string, isTsProject: boolean): string[] {
|
|
72
|
+
const files: string[] = [];
|
|
73
|
+
if (!fs.existsSync(dir)) return files;
|
|
74
|
+
|
|
75
|
+
const scan = (d: string) => {
|
|
76
|
+
let entries: fs.Dirent[] = [];
|
|
77
|
+
try {
|
|
78
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const full = path.join(d, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
if (
|
|
87
|
+
[
|
|
88
|
+
"node_modules",
|
|
89
|
+
".git",
|
|
90
|
+
"dist",
|
|
91
|
+
"build",
|
|
92
|
+
".next",
|
|
93
|
+
".pi-lens",
|
|
94
|
+
].includes(entry.name)
|
|
95
|
+
)
|
|
96
|
+
continue;
|
|
97
|
+
scan(full);
|
|
98
|
+
} else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
|
|
99
|
+
// Skip compiled JS if it's a TS project
|
|
100
|
+
if (
|
|
101
|
+
isTsProject &&
|
|
102
|
+
entry.name.endsWith(".js") &&
|
|
103
|
+
fs.existsSync(full.replace(/\.js$/, ".ts"))
|
|
104
|
+
)
|
|
105
|
+
continue;
|
|
106
|
+
files.push(full);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
scan(dir);
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SgRunner - encapsulates ast-grep subprocess management
|
|
3
|
+
*
|
|
4
|
+
* Extracted from AstGrepClient to simplify the main client.
|
|
5
|
+
* Handles: spawn, spawnSync, temp dir management, JSON parsing.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
export class SgRunner {
|
|
12
|
+
constructor(verbose = false) {
|
|
13
|
+
this.log = verbose ? (msg) => console.error(`[sg-runner] ${msg}`) : () => { };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Check if ast-grep CLI is available
|
|
17
|
+
*/
|
|
18
|
+
isAvailable() {
|
|
19
|
+
const result = spawnSync("npx", ["sg", "--version"], {
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
timeout: 10000,
|
|
22
|
+
shell: true,
|
|
23
|
+
});
|
|
24
|
+
return !result.error && result.status === 0;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Run ast-grep asynchronously, return parsed matches
|
|
28
|
+
*/
|
|
29
|
+
async exec(args) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const proc = spawn("npx", ["sg", ...args], {
|
|
32
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
33
|
+
shell: true,
|
|
34
|
+
});
|
|
35
|
+
let stdout = "";
|
|
36
|
+
let stderr = "";
|
|
37
|
+
proc.stdout.on("data", (data) => (stdout += data.toString()));
|
|
38
|
+
proc.stderr.on("data", (data) => (stderr += data.toString()));
|
|
39
|
+
proc.on("error", (err) => {
|
|
40
|
+
if (err.message.includes("ENOENT")) {
|
|
41
|
+
resolve({ matches: [], error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli" });
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
resolve({ matches: [], error: err.message });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
proc.on("close", (code) => {
|
|
48
|
+
if (code !== 0 && !stdout.trim()) {
|
|
49
|
+
resolve({
|
|
50
|
+
matches: [],
|
|
51
|
+
error: stderr.includes("No files found") ? undefined : stderr.trim() || `Exit code ${code}`,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!stdout.trim()) {
|
|
56
|
+
resolve({ matches: [] });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(stdout);
|
|
61
|
+
const matches = Array.isArray(parsed) ? parsed : [parsed];
|
|
62
|
+
resolve({ matches });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
resolve({ matches: [], error: "Failed to parse output" });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Run ast-grep synchronously (for simple scans)
|
|
72
|
+
*/
|
|
73
|
+
execSync(args) {
|
|
74
|
+
const result = spawnSync("npx", ["sg", ...args], {
|
|
75
|
+
encoding: "utf-8",
|
|
76
|
+
timeout: 30000,
|
|
77
|
+
shell: true,
|
|
78
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
if (result.error) {
|
|
81
|
+
return { output: "", error: result.error.message };
|
|
82
|
+
}
|
|
83
|
+
const output = result.stdout || result.stderr || "";
|
|
84
|
+
return { output };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Run a temporary rule scan (creates temp dir with rule file)
|
|
88
|
+
*/
|
|
89
|
+
tempScan(dir, ruleId, ruleYaml, timeout = 30000) {
|
|
90
|
+
const tmpDir = os.tmpdir();
|
|
91
|
+
const ts = Date.now();
|
|
92
|
+
const sessionDir = path.join(tmpDir, `pi-lens-temp-${ruleId}-${ts}`);
|
|
93
|
+
const rulesSubdir = path.join(sessionDir, "rules");
|
|
94
|
+
const ruleFile = path.join(rulesSubdir, `${ruleId}.yml`);
|
|
95
|
+
const configFile = path.join(sessionDir, ".sgconfig.yml");
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(rulesSubdir, { recursive: true });
|
|
98
|
+
fs.writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
|
|
99
|
+
fs.writeFileSync(ruleFile, ruleYaml);
|
|
100
|
+
const result = spawnSync("npx", ["sg", "scan", "--config", configFile, "--json", dir], { encoding: "utf-8", timeout, shell: true });
|
|
101
|
+
const output = result.stdout || result.stderr || "";
|
|
102
|
+
if (!output.trim())
|
|
103
|
+
return [];
|
|
104
|
+
const items = JSON.parse(output);
|
|
105
|
+
return Array.isArray(items) ? items : [items];
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
try {
|
|
112
|
+
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
this.log(`Cleanup failed: ${err.message}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Format matches for display
|
|
121
|
+
*/
|
|
122
|
+
formatMatches(matches, isDryRun = false, maxItems = 50) {
|
|
123
|
+
if (matches.length === 0)
|
|
124
|
+
return "No matches found";
|
|
125
|
+
const shown = matches.slice(0, maxItems);
|
|
126
|
+
const lines = shown.map((m) => {
|
|
127
|
+
const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
|
|
128
|
+
const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
|
|
129
|
+
return isDryRun && m.replacement
|
|
130
|
+
? `${loc}\n - ${text}\n + ${m.replacement}`
|
|
131
|
+
: `${loc}: ${text}`;
|
|
132
|
+
});
|
|
133
|
+
if (matches.length > maxItems) {
|
|
134
|
+
lines.unshift(`Found ${matches.length} matches (showing first ${maxItems}):`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
}
|