pi-lens 2.0.37 → 2.0.38

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.
Files changed (40) hide show
  1. package/clients/architect-client.js +50 -20
  2. package/clients/architect-client.ts +61 -22
  3. package/clients/ast-grep-client.js +15 -106
  4. package/clients/ast-grep-client.test.js +2 -14
  5. package/clients/ast-grep-client.test.ts +2 -16
  6. package/clients/ast-grep-client.ts +34 -150
  7. package/clients/auto-loop.js +117 -0
  8. package/clients/auto-loop.ts +171 -0
  9. package/clients/biome-client.ts +28 -25
  10. package/clients/complexity-client.js +27 -16
  11. package/clients/complexity-client.ts +149 -105
  12. package/clients/dependency-checker.ts +15 -35
  13. package/clients/fix-scanners.js +195 -0
  14. package/clients/fix-scanners.ts +297 -0
  15. package/clients/interviewer-templates.js +75 -0
  16. package/clients/interviewer-templates.ts +90 -0
  17. package/clients/interviewer.js +73 -101
  18. package/clients/interviewer.ts +195 -140
  19. package/clients/knip-client.js +12 -4
  20. package/clients/knip-client.ts +21 -16
  21. package/clients/scan-architectural-debt.js +62 -72
  22. package/clients/scan-architectural-debt.ts +79 -64
  23. package/clients/scan-utils.js +98 -0
  24. package/clients/scan-utils.ts +112 -0
  25. package/clients/sg-runner.js +138 -0
  26. package/clients/sg-runner.ts +168 -0
  27. package/clients/subprocess-client.js +0 -37
  28. package/clients/subprocess-client.ts +0 -60
  29. package/clients/ts-service.ts +1 -7
  30. package/clients/type-safety-client.js +3 -8
  31. package/clients/type-safety-client.ts +10 -14
  32. package/clients/typescript-client.js +55 -56
  33. package/clients/typescript-client.ts +94 -52
  34. package/default-architect.yaml +87 -0
  35. package/index.ts +107 -1162
  36. package/package.json +2 -1
  37. package/rules/ast-grep-rules/rules/large-class.yml +6 -2
  38. package/rules/ast-grep-rules/rules/long-method.yml +1 -1
  39. package/rules/ast-grep-rules/rules/no-single-char-var.yml +2 -2
  40. package/rules/ast-grep-rules/rules/switch-without-default.yml +0 -12
@@ -15,36 +15,60 @@ import { minimatch } from "minimatch";
15
15
  export class ArchitectClient {
16
16
  constructor(verbose = false) {
17
17
  this.config = null;
18
- this.configPath = null;
18
+ this.isUserConfig = false;
19
19
  this.log = verbose
20
20
  ? (msg) => console.error(`[architect] ${msg}`)
21
21
  : () => { };
22
22
  }
23
23
  /**
24
- * Load architect config from project root
24
+ * Load architect config from project root.
25
+ * Falls back to built-in default if no user config exists.
25
26
  */
26
27
  loadConfig(projectRoot) {
27
- // Try common locations
28
- const candidates = [
28
+ // Try user config locations first
29
+ const userCandidates = [
29
30
  path.join(projectRoot, ".pi-lens", "architect.yaml"),
30
31
  path.join(projectRoot, "architect.yaml"),
31
32
  path.join(projectRoot, ".pi-lens", "architect.yml"),
32
33
  ];
33
- for (const configPath of candidates) {
34
+ for (const configPath of userCandidates) {
34
35
  try {
35
36
  const content = fs.readFileSync(configPath, "utf-8");
36
37
  this.config = this.parseYaml(content);
37
38
  this.configPath = configPath;
38
- this.log(`Loaded architect config from ${configPath}`);
39
+ this.isUserConfig = true;
40
+ this.log(`Loaded user architect config from ${configPath}`);
39
41
  return true;
40
42
  }
41
43
  catch (error) {
42
44
  this.log(`Could not read ${configPath}: ${error}`);
43
- continue;
44
45
  }
45
46
  }
46
- this.log("No architect.yaml found");
47
- return false;
47
+ // Fall back to built-in default
48
+ try {
49
+ // Handle both CommonJS and ESM environments
50
+ let currentDir = ".";
51
+ if (typeof __dirname !== "undefined") {
52
+ currentDir = __dirname;
53
+ }
54
+ const defaultPath = path.join(currentDir, "..", "default-architect.yaml");
55
+ const content = fs.readFileSync(defaultPath, "utf-8");
56
+ this.config = this.parseYaml(content);
57
+ this.configPath = defaultPath;
58
+ this.isUserConfig = false;
59
+ this.log("Using default architect rules (create .pi-lens/architect.yaml to customize)");
60
+ return true;
61
+ }
62
+ catch {
63
+ this.log("No architect config available");
64
+ return false;
65
+ }
66
+ }
67
+ /**
68
+ * Check if the loaded config is user-defined (not default)
69
+ */
70
+ isUserDefined() {
71
+ return this.isUserConfig;
48
72
  }
49
73
  /**
50
74
  * Check if config is loaded
@@ -77,12 +101,22 @@ export class ArchitectClient {
77
101
  if (!rule.must_not)
78
102
  continue;
79
103
  for (const check of rule.must_not) {
80
- const regex = new RegExp(check.pattern, "i");
81
- if (regex.test(content)) {
104
+ // We use 'g' to find all occurrences and correctly report line numbers
105
+ const regex = new RegExp(check.pattern, "gi");
106
+ let match;
107
+ // biome-ignore lint/suspicious/noAssignInExpressions: RegExp.exec iteration
108
+ while ((match = regex.exec(content)) !== null) {
109
+ // Convert index to line number
110
+ const lineNum = content.slice(0, match.index).split("\n").length;
82
111
  violations.push({
83
112
  pattern: rule.pattern,
84
113
  message: check.message,
114
+ line: lineNum,
85
115
  });
116
+ // Prevent infinite loop on empty matches
117
+ if (match.index === regex.lastIndex) {
118
+ regex.lastIndex++;
119
+ }
86
120
  }
87
121
  }
88
122
  }
@@ -132,7 +166,7 @@ export class ArchitectClient {
132
166
  parseYaml(content) {
133
167
  const config = { rules: [] };
134
168
  // Split into top-level rule blocks (4-space indent "- pattern:")
135
- const ruleBlocks = content.split(/(?=^ - pattern:)/m);
169
+ const ruleBlocks = content.split(/(?=^ {2}- pattern:)/m);
136
170
  for (const block of ruleBlocks) {
137
171
  const lines = block.split("\n");
138
172
  let rule = null;
@@ -154,7 +188,9 @@ export class ArchitectClient {
154
188
  continue;
155
189
  }
156
190
  // Nested pattern inside must_not (may start with "- ")
157
- if ((trimmed.startsWith("pattern:") || trimmed.startsWith("- pattern:")) && section === "must_not") {
191
+ if ((trimmed.startsWith("pattern:") ||
192
+ trimmed.startsWith("- pattern:")) &&
193
+ section === "must_not") {
158
194
  // Extract everything after "pattern:" and unquote
159
195
  const raw = trimmed.replace(/^-?\s*pattern:\s*/, "").trim();
160
196
  const unquoted = raw.replace(/^["']|["']$/g, "");
@@ -207,10 +243,4 @@ export class ArchitectClient {
207
243
  }
208
244
  }
209
245
  // --- Singleton ---
210
- let instance = null;
211
- export function getArchitectClient(verbose = false) {
212
- if (!instance) {
213
- instance = new ArchitectClient(verbose);
214
- }
215
- return instance;
216
- }
246
+ const _instance = null;
@@ -18,6 +18,7 @@ import { minimatch } from "minimatch";
18
18
  export interface ArchitectViolation {
19
19
  pattern: string;
20
20
  message: string;
21
+ line?: number;
21
22
  }
22
23
 
23
24
  export interface ArchitectRule {
@@ -43,7 +44,7 @@ export interface FileArchitectResult {
43
44
 
44
45
  export class ArchitectClient {
45
46
  private config: ArchitectConfig | null = null;
46
- private configPath: string | null = null;
47
+ private isUserConfig: boolean = false;
47
48
  private log: (msg: string) => void;
48
49
 
49
50
  constructor(verbose = false) {
@@ -53,31 +54,57 @@ export class ArchitectClient {
53
54
  }
54
55
 
55
56
  /**
56
- * Load architect config from project root
57
+ * Load architect config from project root.
58
+ * Falls back to built-in default if no user config exists.
57
59
  */
58
60
  loadConfig(projectRoot: string): boolean {
59
- // Try common locations
60
- const candidates = [
61
+ // Try user config locations first
62
+ const userCandidates = [
61
63
  path.join(projectRoot, ".pi-lens", "architect.yaml"),
62
64
  path.join(projectRoot, "architect.yaml"),
63
65
  path.join(projectRoot, ".pi-lens", "architect.yml"),
64
66
  ];
65
67
 
66
- for (const configPath of candidates) {
68
+ for (const configPath of userCandidates) {
67
69
  try {
68
70
  const content = fs.readFileSync(configPath, "utf-8");
69
71
  this.config = this.parseYaml(content);
70
72
  this.configPath = configPath;
71
- this.log(`Loaded architect config from ${configPath}`);
73
+ this.isUserConfig = true;
74
+ this.log(`Loaded user architect config from ${configPath}`);
72
75
  return true;
73
76
  } catch (error) {
74
77
  this.log(`Could not read ${configPath}: ${error}`);
75
- continue;
76
78
  }
77
79
  }
78
80
 
79
- this.log("No architect.yaml found");
80
- return false;
81
+ // Fall back to built-in default
82
+ try {
83
+ // Handle both CommonJS and ESM environments
84
+ let currentDir = ".";
85
+ if (typeof __dirname !== "undefined") {
86
+ currentDir = __dirname;
87
+ }
88
+ const defaultPath = path.join(currentDir, "..", "default-architect.yaml");
89
+ const content = fs.readFileSync(defaultPath, "utf-8");
90
+ this.config = this.parseYaml(content);
91
+ this.configPath = defaultPath;
92
+ this.isUserConfig = false;
93
+ this.log(
94
+ "Using default architect rules (create .pi-lens/architect.yaml to customize)",
95
+ );
96
+ return true;
97
+ } catch {
98
+ this.log("No architect config available");
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Check if the loaded config is user-defined (not default)
105
+ */
106
+ isUserDefined(): boolean {
107
+ return this.isUserConfig;
81
108
  }
82
109
 
83
110
  /**
@@ -114,12 +141,24 @@ export class ArchitectClient {
114
141
  if (!rule.must_not) continue;
115
142
 
116
143
  for (const check of rule.must_not) {
117
- const regex = new RegExp(check.pattern, "i");
118
- if (regex.test(content)) {
144
+ // We use 'g' to find all occurrences and correctly report line numbers
145
+ const regex = new RegExp(check.pattern, "gi");
146
+ let match: RegExpExecArray | null;
147
+
148
+ // biome-ignore lint/suspicious/noAssignInExpressions: RegExp.exec iteration
149
+ while ((match = regex.exec(content)) !== null) {
150
+ // Convert index to line number
151
+ const lineNum = content.slice(0, match.index).split("\n").length;
119
152
  violations.push({
120
153
  pattern: rule.pattern,
121
154
  message: check.message,
155
+ line: lineNum,
122
156
  });
157
+
158
+ // Prevent infinite loop on empty matches
159
+ if (match.index === regex.lastIndex) {
160
+ regex.lastIndex++;
161
+ }
123
162
  }
124
163
  }
125
164
  }
@@ -131,7 +170,10 @@ export class ArchitectClient {
131
170
  * Check file size against max_lines rule
132
171
  * Returns violation if file exceeds the limit
133
172
  */
134
- checkFileSize(filePath: string, lineCount: number): ArchitectViolation | null {
173
+ checkFileSize(
174
+ filePath: string,
175
+ lineCount: number,
176
+ ): ArchitectViolation | null {
135
177
  const rules = this.getRulesForFile(filePath);
136
178
 
137
179
  for (const rule of rules) {
@@ -177,7 +219,7 @@ export class ArchitectClient {
177
219
  const config: ArchitectConfig = { rules: [] };
178
220
 
179
221
  // Split into top-level rule blocks (4-space indent "- pattern:")
180
- const ruleBlocks = content.split(/(?=^ - pattern:)/m);
222
+ const ruleBlocks = content.split(/(?=^ {2}- pattern:)/m);
181
223
 
182
224
  for (const block of ruleBlocks) {
183
225
  const lines = block.split("\n");
@@ -202,7 +244,11 @@ export class ArchitectClient {
202
244
  continue;
203
245
  }
204
246
  // Nested pattern inside must_not (may start with "- ")
205
- if ((trimmed.startsWith("pattern:") || trimmed.startsWith("- pattern:")) && section === "must_not") {
247
+ if (
248
+ (trimmed.startsWith("pattern:") ||
249
+ trimmed.startsWith("- pattern:")) &&
250
+ section === "must_not"
251
+ ) {
206
252
  // Extract everything after "pattern:" and unquote
207
253
  const raw = trimmed.replace(/^-?\s*pattern:\s*/, "").trim();
208
254
  const unquoted = raw.replace(/^["']|["']$/g, "");
@@ -263,11 +309,4 @@ export class ArchitectClient {
263
309
 
264
310
  // --- Singleton ---
265
311
 
266
- let instance: ArchitectClient | null = null;
267
-
268
- export function getArchitectClient(verbose = false): ArchitectClient {
269
- if (!instance) {
270
- instance = new ArchitectClient(verbose);
271
- }
272
- return instance;
273
- }
312
+ const _instance: ArchitectClient | null = null;
@@ -7,22 +7,28 @@
7
7
  * Requires: npm install -D @ast-grep/cli
8
8
  * Rules: ./rules/ directory
9
9
  */
10
- import { spawn, spawnSync } from "node:child_process";
10
+ import { spawnSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { AstGrepParser } from "./ast-grep-parser.js";
14
14
  import { AstGrepRuleManager } from "./ast-grep-rule-manager.js";
15
+ import { SgRunner } from "./sg-runner.js";
16
+ const getExtensionDir = () => {
17
+ if (typeof __dirname !== "undefined") {
18
+ return __dirname;
19
+ }
20
+ return ".";
21
+ };
15
22
  // --- Client ---
16
23
  export class AstGrepClient {
17
24
  constructor(ruleDir, verbose = false) {
18
25
  this.available = null;
19
- this.ruleDir =
20
- ruleDir ||
21
- path.join(typeof __dirname !== "undefined" ? __dirname : ".", "..", "rules");
26
+ this.ruleDir = ruleDir || path.join(process.cwd(), "rules");
22
27
  this.log = verbose
23
28
  ? (msg) => console.error(`[ast-grep] ${msg}`)
24
29
  : () => { };
25
30
  this.ruleManager = new AstGrepRuleManager(this.ruleDir, this.log);
31
+ this.runner = new SgRunner(verbose);
26
32
  }
27
33
  /**
28
34
  * Check if ast-grep CLI is available
@@ -30,12 +36,7 @@ export class AstGrepClient {
30
36
  isAvailable() {
31
37
  if (this.available !== null)
32
38
  return this.available;
33
- const result = spawnSync("npx", ["sg", "--version"], {
34
- encoding: "utf-8",
35
- timeout: 10000,
36
- shell: true,
37
- });
38
- this.available = !result.error && result.status === 0;
39
+ this.available = this.runner.isAvailable();
39
40
  if (this.available) {
40
41
  this.log("ast-grep available");
41
42
  }
@@ -45,7 +46,7 @@ export class AstGrepClient {
45
46
  * Search for AST patterns in files
46
47
  */
47
48
  async search(pattern, lang, paths) {
48
- return this.runSg([
49
+ return this.runner.exec([
49
50
  "run",
50
51
  "-p",
51
52
  pattern,
@@ -72,7 +73,7 @@ export class AstGrepClient {
72
73
  if (apply)
73
74
  args.push("--update-all");
74
75
  args.push(...paths);
75
- const result = await this.runSg(args);
76
+ const result = await this.runner.exec(args);
76
77
  return { matches: result.matches, applied: apply, error: result.error };
77
78
  }
78
79
  /**
@@ -81,39 +82,7 @@ export class AstGrepClient {
81
82
  runTempScan(dir, ruleId, ruleYaml, timeout = 30000) {
82
83
  if (!this.isAvailable())
83
84
  return [];
84
- const tmpDir = require("node:os").tmpdir();
85
- const ts = Date.now();
86
- const sessionDir = path.join(tmpDir, `pi-lens-temp-${ruleId}-${ts}`);
87
- const rulesSubdir = path.join(sessionDir, "rules");
88
- const ruleFile = path.join(rulesSubdir, `${ruleId}.yml`);
89
- const configFile = path.join(sessionDir, ".sgconfig.yml");
90
- try {
91
- fs.mkdirSync(rulesSubdir, { recursive: true });
92
- fs.writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
93
- fs.writeFileSync(ruleFile, ruleYaml);
94
- const result = spawnSync("npx", ["sg", "scan", "--config", configFile, "--json", dir], {
95
- encoding: "utf-8",
96
- timeout,
97
- shell: true,
98
- });
99
- const output = result.stdout || result.stderr || "";
100
- if (!output.trim())
101
- return [];
102
- const items = JSON.parse(output);
103
- return Array.isArray(items) ? items : [items];
104
- }
105
- catch (err) {
106
- void err;
107
- return [];
108
- }
109
- finally {
110
- try {
111
- fs.rmSync(sessionDir, { recursive: true, force: true });
112
- }
113
- catch (err) {
114
- void err;
115
- }
116
- }
85
+ return this.runner.tempScan(dir, ruleId, ruleYaml, timeout);
117
86
  }
118
87
  /**
119
88
  * Find similar functions by comparing normalized AST structure
@@ -199,68 +168,8 @@ message: found
199
168
  }
200
169
  return exports;
201
170
  }
202
- runSg(args) {
203
- return new Promise((resolve) => {
204
- const proc = spawn("npx", ["sg", ...args], {
205
- stdio: ["ignore", "pipe", "pipe"],
206
- shell: true,
207
- });
208
- let stdout = "";
209
- let stderr = "";
210
- proc.stdout.on("data", (data) => (stdout += data.toString()));
211
- proc.stderr.on("data", (data) => (stderr += data.toString()));
212
- proc.on("error", (err) => {
213
- if (err.message.includes("ENOENT")) {
214
- resolve({
215
- matches: [],
216
- error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
217
- });
218
- }
219
- else {
220
- resolve({ matches: [], error: err.message });
221
- }
222
- });
223
- proc.on("close", (code) => {
224
- if (code !== 0 && !stdout.trim()) {
225
- resolve({
226
- matches: [],
227
- error: stderr.includes("No files found")
228
- ? undefined
229
- : stderr.trim() || `Exit code ${code}`,
230
- });
231
- return;
232
- }
233
- if (!stdout.trim()) {
234
- resolve({ matches: [] });
235
- return;
236
- }
237
- try {
238
- const parsed = JSON.parse(stdout);
239
- const matches = Array.isArray(parsed) ? parsed : [parsed];
240
- resolve({ matches });
241
- }
242
- catch (err) {
243
- void err;
244
- resolve({ matches: [], error: "Failed to parse output" });
245
- }
246
- });
247
- });
248
- }
249
171
  formatMatches(matches, isDryRun = false) {
250
- if (matches.length === 0)
251
- return "No matches found";
252
- const MAX = 50;
253
- const shown = matches.slice(0, MAX);
254
- const lines = shown.map((m) => {
255
- const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
256
- const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
257
- return isDryRun && m.replacement
258
- ? `${loc}\n - ${text}\n + ${m.replacement}`
259
- : `${loc}: ${text}`;
260
- });
261
- if (matches.length > MAX)
262
- lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
263
- return lines.join("\n");
172
+ return this.runner.formatMatches(matches, isDryRun);
264
173
  }
265
174
  /**
266
175
  * Scan a file against all rules
@@ -25,18 +25,6 @@ describe("AstGrepClient", () => {
25
25
  const result = client.scanFile("/nonexistent/file.ts");
26
26
  expect(result).toEqual([]);
27
27
  });
28
- it("should detect var usage (no-var rule)", () => {
29
- if (!client.isAvailable())
30
- return;
31
- const content = `
32
- var x = 1;
33
- var y = 2;
34
- `;
35
- const filePath = createTempFile(tmpDir, "test.ts", content);
36
- const result = client.scanFile(filePath);
37
- // Should detect var usage
38
- expect(result.some((d) => d.rule === "no-var")).toBe(true);
39
- });
40
28
  it("should detect console.log usage", () => {
41
29
  if (!client.isAvailable())
42
30
  return;
@@ -45,8 +33,8 @@ console.log("test");
45
33
  `;
46
34
  const filePath = createTempFile(tmpDir, "test.ts", content);
47
35
  const result = client.scanFile(filePath);
48
- // May detect console.log depending on rules
49
- expect(Array.isArray(result)).toBe(true);
36
+ // Should detect console.log
37
+ expect(result.some((d) => d.rule === "no-console-log")).toBe(true);
50
38
  });
51
39
  });
52
40
  describe("formatDiagnostics", () => {
@@ -30,20 +30,6 @@ describe("AstGrepClient", () => {
30
30
  expect(result).toEqual([]);
31
31
  });
32
32
 
33
- it("should detect var usage (no-var rule)", () => {
34
- if (!client.isAvailable()) return;
35
-
36
- const content = `
37
- var x = 1;
38
- var y = 2;
39
- `;
40
- const filePath = createTempFile(tmpDir, "test.ts", content);
41
- const result = client.scanFile(filePath);
42
-
43
- // Should detect var usage
44
- expect(result.some((d) => d.rule === "no-var")).toBe(true);
45
- });
46
-
47
33
  it("should detect console.log usage", () => {
48
34
  if (!client.isAvailable()) return;
49
35
 
@@ -53,8 +39,8 @@ console.log("test");
53
39
  const filePath = createTempFile(tmpDir, "test.ts", content);
54
40
  const result = client.scanFile(filePath);
55
41
 
56
- // May detect console.log depending on rules
57
- expect(Array.isArray(result)).toBe(true);
42
+ // Should detect console.log
43
+ expect(result.some((d) => d.rule === "no-console-log")).toBe(true);
58
44
  });
59
45
  });
60
46