pi-lens 2.0.28 → 2.0.35

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to pi-lens will be documented in this file.
4
4
 
5
+ ## [2.0.29] - 2026-03-26
6
+
7
+ ### Added
8
+ - **`clients/ts-service.ts`**: Shared TypeScript service that creates one `ts.Program` per session. Both `complexity-client` and `type-safety-client` now share the same program instead of creating a new one per file. Significant performance improvement on large codebases.
9
+
10
+ ### Removed
11
+ - **3 redundant ast-grep rules** that overlap with Biome: `no-var`, `prefer-template`, `no-useless-concat`. Biome handles these natively with auto-fix. ast-grep no longer duplicates this coverage.
12
+ - **`prefer-const` from RULE_ACTIONS** — no longer needed (Biome handles directly).
13
+
14
+ ### Changed
15
+ - **Consolidated rule overlap**: Biome is now the single source of truth for style/format rules. ast-grep focuses on structural patterns Biome doesn't cover (security, design smells, AI slop).
16
+
5
17
  ## [2.0.27] - 2026-03-26
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -195,8 +195,8 @@ Each rule includes a `message` and `note` that are shown in diagnostics, so the
195
195
  **TypeScript**
196
196
  `no-any-type`, `no-as-any`, `no-non-null-assertion`
197
197
 
198
- **Style**
199
- `no-var`, `prefer-const`, `prefer-template`, `no-useless-concat`, `prefer-nullish-coalescing`, `prefer-optional-chain`, `nested-ternary`, `no-lonely-if`
198
+ **Style** (Biome handles `no-var`, `prefer-const`, `prefer-template`, `no-useless-concat` natively)
199
+ `prefer-nullish-coalescing`, `prefer-optional-chain`, `nested-ternary`, `no-lonely-if`
200
200
 
201
201
  **Correctness**
202
202
  `no-debugger`, `no-throw-string`, `no-return-await`, `no-await-in-loop`, `no-await-in-promise-all`, `require-await`, `empty-catch`, `strict-equality`, `strict-inequality`
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Architect Client for pi-lens
3
+ *
4
+ * Loads path-based architectural rules from .pi-lens/architect.yaml
5
+ * and checks file paths against them.
6
+ *
7
+ * Provides:
8
+ * - Pre-write hints: what rules apply before the agent edits
9
+ * - Post-write validation: check for violations after edits
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import { minimatch } from "minimatch";
14
+ // --- Client ---
15
+ export class ArchitectClient {
16
+ constructor(verbose = false) {
17
+ this.config = null;
18
+ this.configPath = null;
19
+ this.log = verbose
20
+ ? (msg) => console.error(`[architect] ${msg}`)
21
+ : () => { };
22
+ }
23
+ /**
24
+ * Load architect config from project root
25
+ */
26
+ loadConfig(projectRoot) {
27
+ // Try common locations
28
+ const candidates = [
29
+ path.join(projectRoot, ".pi-lens", "architect.yaml"),
30
+ path.join(projectRoot, "architect.yaml"),
31
+ path.join(projectRoot, ".pi-lens", "architect.yml"),
32
+ ];
33
+ for (const configPath of candidates) {
34
+ try {
35
+ const content = fs.readFileSync(configPath, "utf-8");
36
+ this.config = this.parseYaml(content);
37
+ this.configPath = configPath;
38
+ this.log(`Loaded architect config from ${configPath}`);
39
+ return true;
40
+ }
41
+ catch (error) {
42
+ this.log(`Could not read ${configPath}: ${error}`);
43
+ continue;
44
+ }
45
+ }
46
+ this.log("No architect.yaml found");
47
+ return false;
48
+ }
49
+ /**
50
+ * Check if config is loaded
51
+ */
52
+ hasConfig() {
53
+ return this.config !== null;
54
+ }
55
+ /**
56
+ * Get rules that apply to a file path
57
+ */
58
+ getRulesForFile(filePath) {
59
+ if (!this.config)
60
+ return [];
61
+ const matched = [];
62
+ for (const rule of this.config.rules) {
63
+ if (minimatch(filePath, rule.pattern, { matchBase: true })) {
64
+ matched.push(rule);
65
+ }
66
+ }
67
+ return matched;
68
+ }
69
+ /**
70
+ * Check code content against rules for a file path
71
+ * Returns violations found
72
+ */
73
+ checkFile(filePath, content) {
74
+ const rules = this.getRulesForFile(filePath);
75
+ const violations = [];
76
+ for (const rule of rules) {
77
+ if (!rule.must_not)
78
+ continue;
79
+ for (const check of rule.must_not) {
80
+ const regex = new RegExp(check.pattern, "i");
81
+ if (regex.test(content)) {
82
+ violations.push({
83
+ pattern: rule.pattern,
84
+ message: check.message,
85
+ });
86
+ }
87
+ }
88
+ }
89
+ return violations;
90
+ }
91
+ /**
92
+ * Check file size against max_lines rule
93
+ * Returns violation if file exceeds the limit
94
+ */
95
+ checkFileSize(filePath, lineCount) {
96
+ const rules = this.getRulesForFile(filePath);
97
+ for (const rule of rules) {
98
+ if (rule.max_lines && lineCount > rule.max_lines) {
99
+ return {
100
+ pattern: rule.pattern,
101
+ message: `File is ${lineCount} lines — exceeds ${rule.max_lines} line limit. Split into smaller modules.`,
102
+ };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+ /**
108
+ * Get pre-write hints for a file path
109
+ * Returns rules that will apply to the file being written
110
+ */
111
+ getHints(filePath) {
112
+ const rules = this.getRulesForFile(filePath);
113
+ const hints = [];
114
+ for (const rule of rules) {
115
+ if (rule.must_not) {
116
+ for (const check of rule.must_not) {
117
+ hints.push(check.message);
118
+ }
119
+ }
120
+ if (rule.must) {
121
+ for (const req of rule.must) {
122
+ hints.push(`Must: ${req}`);
123
+ }
124
+ }
125
+ }
126
+ return hints;
127
+ }
128
+ /**
129
+ * Simple YAML parser for architect.yaml format
130
+ * Handles the specific structure we need
131
+ */
132
+ parseYaml(content) {
133
+ const config = { rules: [] };
134
+ // Split into top-level rule blocks (4-space indent "- pattern:")
135
+ const ruleBlocks = content.split(/(?=^ - pattern:)/m);
136
+ for (const block of ruleBlocks) {
137
+ const lines = block.split("\n");
138
+ let rule = null;
139
+ let section = null;
140
+ let violation = null;
141
+ for (const line of lines) {
142
+ const trimmed = line.trim();
143
+ if (trimmed.startsWith("#") || !trimmed)
144
+ continue;
145
+ // Version (top-level)
146
+ if (trimmed.startsWith("version:") && !rule) {
147
+ config.version = trimmed.split(":")[1]?.trim().replace(/['"]/g, "");
148
+ continue;
149
+ }
150
+ // Rule pattern
151
+ const ruleMatch = trimmed.match(/^-?\s*pattern:\s*["'](.+?)["']/);
152
+ if (ruleMatch && trimmed.startsWith("-") && !section) {
153
+ rule = { pattern: ruleMatch[1], must_not: [], must: [] };
154
+ continue;
155
+ }
156
+ // Nested pattern inside must_not (may start with "- ")
157
+ if ((trimmed.startsWith("pattern:") || trimmed.startsWith("- pattern:")) && section === "must_not") {
158
+ // Extract everything after "pattern:" and unquote
159
+ const raw = trimmed.replace(/^-?\s*pattern:\s*/, "").trim();
160
+ const unquoted = raw.replace(/^["']|["']$/g, "");
161
+ if (unquoted) {
162
+ violation = { pattern: unquoted, message: "" };
163
+ }
164
+ continue;
165
+ }
166
+ // Section headers
167
+ if (trimmed === "must_not:" || trimmed.startsWith("must_not:")) {
168
+ section = "must_not";
169
+ continue;
170
+ }
171
+ if (trimmed === "must:") {
172
+ section = "must";
173
+ continue;
174
+ }
175
+ // Message for current violation
176
+ if (trimmed.startsWith("message:") && violation) {
177
+ const match = trimmed.match(/message:\s*["'](.+?)["']/);
178
+ if (match) {
179
+ violation.message = match[1];
180
+ if (rule) {
181
+ rule.must_not = rule.must_not ?? [];
182
+ rule.must_not.push(violation);
183
+ }
184
+ violation = null;
185
+ }
186
+ continue;
187
+ }
188
+ // Must items (simple strings)
189
+ if (section === "must" && trimmed.startsWith("- ") && rule) {
190
+ const item = trimmed.slice(2).replace(/^["']|["']$/g, "");
191
+ rule.must = rule.must ?? [];
192
+ rule.must.push(item);
193
+ }
194
+ // max_lines setting
195
+ if (trimmed.startsWith("max_lines:") && rule) {
196
+ const num = parseInt(trimmed.split(":")[1]?.trim(), 10);
197
+ if (!Number.isNaN(num)) {
198
+ rule.max_lines = num;
199
+ }
200
+ }
201
+ }
202
+ if (rule) {
203
+ config.rules.push(rule);
204
+ }
205
+ }
206
+ return config;
207
+ }
208
+ }
209
+ // --- Singleton ---
210
+ let instance = null;
211
+ export function getArchitectClient(verbose = false) {
212
+ if (!instance) {
213
+ instance = new ArchitectClient(verbose);
214
+ }
215
+ return instance;
216
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Architect Client for pi-lens
3
+ *
4
+ * Loads path-based architectural rules from .pi-lens/architect.yaml
5
+ * and checks file paths against them.
6
+ *
7
+ * Provides:
8
+ * - Pre-write hints: what rules apply before the agent edits
9
+ * - Post-write validation: check for violations after edits
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { minimatch } from "minimatch";
15
+
16
+ // --- Types ---
17
+
18
+ export interface ArchitectViolation {
19
+ pattern: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface ArchitectRule {
24
+ pattern: string;
25
+ must_not?: Array<{ pattern: string; message: string }>;
26
+ must?: string[];
27
+ max_lines?: number;
28
+ }
29
+
30
+ export interface ArchitectConfig {
31
+ version?: string;
32
+ inherits?: string[];
33
+ rules: ArchitectRule[];
34
+ }
35
+
36
+ export interface FileArchitectResult {
37
+ filePath: string;
38
+ matchedRules: ArchitectRule[];
39
+ violations: ArchitectViolation[];
40
+ }
41
+
42
+ // --- Client ---
43
+
44
+ export class ArchitectClient {
45
+ private config: ArchitectConfig | null = null;
46
+ private configPath: string | null = null;
47
+ private log: (msg: string) => void;
48
+
49
+ constructor(verbose = false) {
50
+ this.log = verbose
51
+ ? (msg: string) => console.error(`[architect] ${msg}`)
52
+ : () => {};
53
+ }
54
+
55
+ /**
56
+ * Load architect config from project root
57
+ */
58
+ loadConfig(projectRoot: string): boolean {
59
+ // Try common locations
60
+ const candidates = [
61
+ path.join(projectRoot, ".pi-lens", "architect.yaml"),
62
+ path.join(projectRoot, "architect.yaml"),
63
+ path.join(projectRoot, ".pi-lens", "architect.yml"),
64
+ ];
65
+
66
+ for (const configPath of candidates) {
67
+ try {
68
+ const content = fs.readFileSync(configPath, "utf-8");
69
+ this.config = this.parseYaml(content);
70
+ this.configPath = configPath;
71
+ this.log(`Loaded architect config from ${configPath}`);
72
+ return true;
73
+ } catch (error) {
74
+ this.log(`Could not read ${configPath}: ${error}`);
75
+ continue;
76
+ }
77
+ }
78
+
79
+ this.log("No architect.yaml found");
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * Check if config is loaded
85
+ */
86
+ hasConfig(): boolean {
87
+ return this.config !== null;
88
+ }
89
+
90
+ /**
91
+ * Get rules that apply to a file path
92
+ */
93
+ getRulesForFile(filePath: string): ArchitectRule[] {
94
+ if (!this.config) return [];
95
+
96
+ const matched: ArchitectRule[] = [];
97
+ for (const rule of this.config.rules) {
98
+ if (minimatch(filePath, rule.pattern, { matchBase: true })) {
99
+ matched.push(rule);
100
+ }
101
+ }
102
+ return matched;
103
+ }
104
+
105
+ /**
106
+ * Check code content against rules for a file path
107
+ * Returns violations found
108
+ */
109
+ checkFile(filePath: string, content: string): ArchitectViolation[] {
110
+ const rules = this.getRulesForFile(filePath);
111
+ const violations: ArchitectViolation[] = [];
112
+
113
+ for (const rule of rules) {
114
+ if (!rule.must_not) continue;
115
+
116
+ for (const check of rule.must_not) {
117
+ const regex = new RegExp(check.pattern, "i");
118
+ if (regex.test(content)) {
119
+ violations.push({
120
+ pattern: rule.pattern,
121
+ message: check.message,
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ return violations;
128
+ }
129
+
130
+ /**
131
+ * Check file size against max_lines rule
132
+ * Returns violation if file exceeds the limit
133
+ */
134
+ checkFileSize(filePath: string, lineCount: number): ArchitectViolation | null {
135
+ const rules = this.getRulesForFile(filePath);
136
+
137
+ for (const rule of rules) {
138
+ if (rule.max_lines && lineCount > rule.max_lines) {
139
+ return {
140
+ pattern: rule.pattern,
141
+ message: `File is ${lineCount} lines — exceeds ${rule.max_lines} line limit. Split into smaller modules.`,
142
+ };
143
+ }
144
+ }
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Get pre-write hints for a file path
150
+ * Returns rules that will apply to the file being written
151
+ */
152
+ getHints(filePath: string): string[] {
153
+ const rules = this.getRulesForFile(filePath);
154
+ const hints: string[] = [];
155
+
156
+ for (const rule of rules) {
157
+ if (rule.must_not) {
158
+ for (const check of rule.must_not) {
159
+ hints.push(check.message);
160
+ }
161
+ }
162
+ if (rule.must) {
163
+ for (const req of rule.must) {
164
+ hints.push(`Must: ${req}`);
165
+ }
166
+ }
167
+ }
168
+
169
+ return hints;
170
+ }
171
+
172
+ /**
173
+ * Simple YAML parser for architect.yaml format
174
+ * Handles the specific structure we need
175
+ */
176
+ private parseYaml(content: string): ArchitectConfig {
177
+ const config: ArchitectConfig = { rules: [] };
178
+
179
+ // Split into top-level rule blocks (4-space indent "- pattern:")
180
+ const ruleBlocks = content.split(/(?=^ - pattern:)/m);
181
+
182
+ for (const block of ruleBlocks) {
183
+ const lines = block.split("\n");
184
+ let rule: ArchitectRule | null = null;
185
+ let section: "must_not" | "must" | null = null;
186
+ let violation: { pattern: string; message: string } | null = null;
187
+
188
+ for (const line of lines) {
189
+ const trimmed = line.trim();
190
+ if (trimmed.startsWith("#") || !trimmed) continue;
191
+
192
+ // Version (top-level)
193
+ if (trimmed.startsWith("version:") && !rule) {
194
+ config.version = trimmed.split(":")[1]?.trim().replace(/['"]/g, "");
195
+ continue;
196
+ }
197
+
198
+ // Rule pattern
199
+ const ruleMatch = trimmed.match(/^-?\s*pattern:\s*["'](.+?)["']/);
200
+ if (ruleMatch && trimmed.startsWith("-") && !section) {
201
+ rule = { pattern: ruleMatch[1], must_not: [], must: [] };
202
+ continue;
203
+ }
204
+ // Nested pattern inside must_not (may start with "- ")
205
+ if ((trimmed.startsWith("pattern:") || trimmed.startsWith("- pattern:")) && section === "must_not") {
206
+ // Extract everything after "pattern:" and unquote
207
+ const raw = trimmed.replace(/^-?\s*pattern:\s*/, "").trim();
208
+ const unquoted = raw.replace(/^["']|["']$/g, "");
209
+ if (unquoted) {
210
+ violation = { pattern: unquoted, message: "" };
211
+ }
212
+ continue;
213
+ }
214
+
215
+ // Section headers
216
+ if (trimmed === "must_not:" || trimmed.startsWith("must_not:")) {
217
+ section = "must_not";
218
+ continue;
219
+ }
220
+ if (trimmed === "must:") {
221
+ section = "must";
222
+ continue;
223
+ }
224
+
225
+ // Message for current violation
226
+ if (trimmed.startsWith("message:") && violation) {
227
+ const match = trimmed.match(/message:\s*["'](.+?)["']/);
228
+ if (match) {
229
+ violation.message = match[1];
230
+ if (rule) {
231
+ rule.must_not = rule.must_not ?? [];
232
+ rule.must_not.push(violation);
233
+ }
234
+ violation = null;
235
+ }
236
+ continue;
237
+ }
238
+
239
+ // Must items (simple strings)
240
+ if (section === "must" && trimmed.startsWith("- ") && rule) {
241
+ const item = trimmed.slice(2).replace(/^["']|["']$/g, "");
242
+ rule.must = rule.must ?? [];
243
+ rule.must.push(item);
244
+ }
245
+
246
+ // max_lines setting
247
+ if (trimmed.startsWith("max_lines:") && rule) {
248
+ const num = parseInt(trimmed.split(":")[1]?.trim(), 10);
249
+ if (!Number.isNaN(num)) {
250
+ rule.max_lines = num;
251
+ }
252
+ }
253
+ }
254
+
255
+ if (rule) {
256
+ config.rules.push(rule);
257
+ }
258
+ }
259
+
260
+ return config;
261
+ }
262
+ }
263
+
264
+ // --- Singleton ---
265
+
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
+ }
@@ -57,7 +57,7 @@ export class JscpdClient {
57
57
  "--output",
58
58
  outDir,
59
59
  "--ignore",
60
- "**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.pi-lens/**,**/*.md,**/*.txt,**/*.json,**/*.yaml,**/*.yml,**/*.toml,**/*.lock",
60
+ "**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.pi-lens/**,**/*.md,**/*.txt,**/*.json,**/*.yaml,**/*.yml,**/*.toml,**/*.lock,**/*.test.*,**/*.spec.*",
61
61
  ], {
62
62
  encoding: "utf-8",
63
63
  timeout: 30000,
@@ -86,7 +86,7 @@ export class JscpdClient {
86
86
  "--output",
87
87
  outDir,
88
88
  "--ignore",
89
- "**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.pi-lens/**,**/*.md,**/*.txt,**/*.json,**/*.yaml,**/*.yml,**/*.toml,**/*.lock",
89
+ "**/node_modules/**,**/dist/**,**/build/**,**/.git/**,**/.pi-lens/**,**/*.md,**/*.txt,**/*.json,**/*.yaml,**/*.yml,**/*.toml,**/*.lock,**/*.test.*,**/*.spec.*",
90
90
  ],
91
91
  {
92
92
  encoding: "utf-8",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Shared architectural debt scanning — used by booboo-fix and booboo-refactor.
3
- * Scans ast-grep skip rules + complexity metrics, scores files by combined signal.
3
+ * Scans ast-grep skip rules + complexity metrics + architect.yaml rules.
4
4
  */
5
5
  import * as fs from "node:fs";
6
6
  import * as path from "node:path";
@@ -73,11 +73,56 @@ export function scanComplexityMetrics(complexityClient, targetPath, isTsProject)
73
73
  scanDir(targetPath);
74
74
  return metricsByFile;
75
75
  }
76
+ /**
77
+ * Scan for architectural rule violations grouped by absolute file path.
78
+ * Returns map of absolute file path → list of violation messages.
79
+ */
80
+ export function scanArchitectViolations(architectClient, targetPath) {
81
+ const violationsByFile = new Map();
82
+ if (!architectClient.hasConfig())
83
+ return violationsByFile;
84
+ const scanDir = (dir) => {
85
+ if (!fs.existsSync(dir))
86
+ return;
87
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
88
+ const full = path.join(dir, entry.name);
89
+ if (entry.isDirectory()) {
90
+ if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name))
91
+ continue;
92
+ scanDir(full);
93
+ }
94
+ else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
95
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
96
+ const content = fs.readFileSync(full, "utf-8");
97
+ const lineCount = content.split("\n").length;
98
+ const msgs = [];
99
+ // Check pattern violations
100
+ for (const v of architectClient.checkFile(relPath, content)) {
101
+ msgs.push(v.message);
102
+ }
103
+ // Check file size
104
+ const sizeV = architectClient.checkFileSize(relPath, lineCount);
105
+ if (sizeV) {
106
+ msgs.push(sizeV.message);
107
+ }
108
+ if (msgs.length > 0) {
109
+ violationsByFile.set(full, msgs);
110
+ }
111
+ }
112
+ }
113
+ };
114
+ scanDir(targetPath);
115
+ return violationsByFile;
116
+ }
76
117
  /**
77
118
  * Score each file by combined debt signal. Higher = worse.
78
119
  */
79
- export function scoreFiles(skipByFile, metricsByFile) {
80
- const allFiles = new Set([...skipByFile.keys(), ...metricsByFile.keys()]);
120
+ export function scoreFiles(skipByFile, metricsByFile, architectViolations) {
121
+ const allFiles = new Set([
122
+ ...skipByFile.keys(),
123
+ ...metricsByFile.keys(),
124
+ ...(architectViolations?.keys() ?? []),
125
+ ]);
81
126
  return [...allFiles]
82
127
  .map((file) => {
83
128
  let score = 0;
@@ -108,6 +153,11 @@ export function scoreFiles(skipByFile, metricsByFile) {
108
153
  else
109
154
  score += 1;
110
155
  }
156
+ // Architect violations are high-priority signals
157
+ const archMsgs = architectViolations?.get(file);
158
+ if (archMsgs && archMsgs.length > 0) {
159
+ score += archMsgs.length * 3; // Each violation = 3 points
160
+ }
111
161
  return { file, score };
112
162
  })
113
163
  .filter((f) => f.score > 0)