pi-lens 2.0.29 → 2.0.36

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,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)
@@ -1,11 +1,12 @@
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";
7
7
  import type { AstGrepClient } from "./ast-grep-client.js";
8
8
  import type { ComplexityClient } from "./complexity-client.js";
9
+ import type { ArchitectClient } from "./architect-client.js";
9
10
 
10
11
  export type SkipIssue = { rule: string; line: number; note: string };
11
12
  export type FileMetrics = { mi: number; cognitive: number; nesting: number };
@@ -86,14 +87,64 @@ export function scanComplexityMetrics(
86
87
  return metricsByFile;
87
88
  }
88
89
 
90
+ /**
91
+ * Scan for architectural rule violations grouped by absolute file path.
92
+ * Returns map of absolute file path → list of violation messages.
93
+ */
94
+ export function scanArchitectViolations(
95
+ architectClient: ArchitectClient,
96
+ targetPath: string,
97
+ ): Map<string, string[]> {
98
+ const violationsByFile = new Map<string, string[]>();
99
+ if (!architectClient.hasConfig()) return violationsByFile;
100
+
101
+ const scanDir = (dir: string) => {
102
+ if (!fs.existsSync(dir)) return;
103
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
104
+ const full = path.join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ if (["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(entry.name)) continue;
107
+ scanDir(full);
108
+ } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
109
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
110
+ const content = fs.readFileSync(full, "utf-8");
111
+ const lineCount = content.split("\n").length;
112
+ const msgs: string[] = [];
113
+
114
+ // Check pattern violations
115
+ for (const v of architectClient.checkFile(relPath, content)) {
116
+ msgs.push(v.message);
117
+ }
118
+
119
+ // Check file size
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
+ }
129
+ }
130
+ };
131
+ scanDir(targetPath);
132
+ return violationsByFile;
133
+ }
134
+
89
135
  /**
90
136
  * Score each file by combined debt signal. Higher = worse.
91
137
  */
92
138
  export function scoreFiles(
93
139
  skipByFile: Map<string, SkipIssue[]>,
94
140
  metricsByFile: Map<string, FileMetrics>,
141
+ architectViolations?: Map<string, string[]>,
95
142
  ): { file: string; score: number }[] {
96
- const allFiles = new Set([...skipByFile.keys(), ...metricsByFile.keys()]);
143
+ const allFiles = new Set([
144
+ ...skipByFile.keys(),
145
+ ...metricsByFile.keys(),
146
+ ...(architectViolations?.keys() ?? []),
147
+ ]);
97
148
  return [...allFiles]
98
149
  .map((file) => {
99
150
  let score = 0;
@@ -108,6 +159,11 @@ export function scoreFiles(
108
159
  else if (issue.rule === "no-as-any") score += 2;
109
160
  else score += 1;
110
161
  }
162
+ // Architect violations are high-priority signals
163
+ const archMsgs = architectViolations?.get(file);
164
+ if (archMsgs && archMsgs.length > 0) {
165
+ score += archMsgs.length * 3; // Each violation = 3 points
166
+ }
111
167
  return { file, score };
112
168
  })
113
169
  .filter((f) => f.score > 0)
package/index.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  TypeSafetyClient,
36
36
  getTypeSafetyClient,
37
37
  } from "./clients/type-safety-client.js";
38
+ import { ArchitectClient } from "./clients/architect-client.js";
38
39
  import { DependencyChecker } from "./clients/dependency-checker.js";
39
40
  import { GoClient } from "./clients/go-client.js";
40
41
  import { JscpdClient } from "./clients/jscpd-client.js";
@@ -47,7 +48,7 @@ import { TodoScanner } from "./clients/todo-scanner.js";
47
48
  import { TypeCoverageClient } from "./clients/type-coverage-client.js";
48
49
  import { TypeScriptClient } from "./clients/typescript-client.js";
49
50
  import { buildInterviewer, type InterviewOption } from "./clients/interviewer.js";
50
- import { scanSkipViolations, scanComplexityMetrics, scoreFiles, extractCodeSnippet, type SkipIssue, type FileMetrics } from "./clients/scan-architectural-debt.js";
51
+ import { scanSkipViolations, scanComplexityMetrics, scanArchitectViolations, scoreFiles, extractCodeSnippet, type SkipIssue, type FileMetrics } from "./clients/scan-architectural-debt.js";
51
52
 
52
53
  const DEBUG_LOG = path.join(os.homedir(), "pi-lens-debug.log");
53
54
  function dbg(msg: string) {
@@ -62,6 +63,7 @@ function dbg(msg: string) {
62
63
  // --- State ---
63
64
 
64
65
  let _verbose = false;
66
+ let projectRoot = process.cwd();
65
67
 
66
68
  function log(msg: string) {
67
69
  if (_verbose) console.error(`[pi-lens] ${msg}`);
@@ -83,6 +85,7 @@ export default function (pi: ExtensionAPI) {
83
85
  const metricsClient = new MetricsClient();
84
86
  const complexityClient = new ComplexityClient();
85
87
  const typeSafetyClient = new TypeSafetyClient();
88
+ const architectClient = new ArchitectClient();
86
89
  const goClient = new GoClient();
87
90
  const rustClient = new RustClient();
88
91
 
@@ -578,6 +581,59 @@ export default function (pi: ExtensionAPI) {
578
581
  }
579
582
  }
580
583
 
584
+ // Part 9: Architectural Rules
585
+ // Reload config in case it was added after session start
586
+ if (!architectClient.hasConfig()) {
587
+ architectClient.loadConfig(projectRoot);
588
+ }
589
+ if (architectClient.hasConfig()) {
590
+ const archViolations: Array<{ file: string; message: string }> = [];
591
+ const archScanDir = (dir: string) => {
592
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
593
+ const full = path.join(dir, entry.name);
594
+ if (entry.isDirectory()) {
595
+ if (
596
+ ["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(
597
+ entry.name,
598
+ )
599
+ )
600
+ continue;
601
+ archScanDir(full);
602
+ } else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
603
+ const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
604
+ const content = fs.readFileSync(full, "utf-8");
605
+ const lineCount = content.split("\n").length;
606
+
607
+ // Check pattern violations
608
+ const violations = architectClient.checkFile(relPath, content);
609
+ for (const v of violations) {
610
+ archViolations.push({ file: relPath, message: v.message });
611
+ }
612
+
613
+ // Check file size
614
+ const sizeV = architectClient.checkFileSize(relPath, lineCount);
615
+ if (sizeV) {
616
+ archViolations.push({ file: relPath, message: sizeV.message });
617
+ }
618
+ }
619
+ }
620
+ };
621
+ archScanDir(targetPath);
622
+
623
+ if (archViolations.length > 0) {
624
+ parts.push(
625
+ `šŸ”“ ${archViolations.length} architectural violation(s) — fix before adding new code`,
626
+ );
627
+ let fullSection = `## Architectural Rules\n\n`;
628
+ fullSection += `**${archViolations.length} violation(s) found**\n\n`;
629
+ for (const v of archViolations) {
630
+ fullSection += `- **${v.file}**: ${v.message}\n`;
631
+ }
632
+ fullSection += "\n";
633
+ fullReport.push(fullSection);
634
+ }
635
+ }
636
+
581
637
  // Build and save full markdown report
582
638
  const fs = require("node:fs");
583
639
  fs.mkdirSync(reviewDir, { recursive: true });
@@ -1190,7 +1246,10 @@ export default function (pi: ExtensionAPI) {
1190
1246
 
1191
1247
  const skipByFile = scanSkipViolations(astGrepClient, configPath, targetPath, isTsProject, SKIP_RULES, RULE_ACTIONS);
1192
1248
  const metricsByFile = scanComplexityMetrics(complexityClient, targetPath, isTsProject);
1193
- const scored = scoreFiles(skipByFile, metricsByFile);
1249
+ const architectViolations = architectClient.hasConfig()
1250
+ ? scanArchitectViolations(architectClient, targetPath)
1251
+ : new Map<string, string[]>();
1252
+ const scored = scoreFiles(skipByFile, metricsByFile, architectViolations);
1194
1253
 
1195
1254
  if (scored.length === 0) {
1196
1255
  ctx.ui.notify("āœ… No architectural debt found — codebase is clean.", "info");
@@ -1201,6 +1260,7 @@ export default function (pi: ExtensionAPI) {
1201
1260
  const relFile = path.relative(targetPath, worstFile).replace(/\\/g, "/");
1202
1261
  const issues = skipByFile.get(worstFile) ?? [];
1203
1262
  const metrics = metricsByFile.get(worstFile);
1263
+ const archIssues = architectViolations.get(worstFile) ?? [];
1204
1264
 
1205
1265
  const snippetResult = issues.length > 0 ? extractCodeSnippet(worstFile, issues[0].line) : null;
1206
1266
  const snippet = snippetResult?.snippet ?? "";
@@ -1218,6 +1278,9 @@ export default function (pi: ExtensionAPI) {
1218
1278
  `- \`${r}\` (Ɨ${n})${RULE_ACTIONS[r] ? ` — ${RULE_ACTIONS[r].note}` : ""}`,
1219
1279
  )
1220
1280
  .join("\n");
1281
+ const archSummary = archIssues.length > 0
1282
+ ? archIssues.map((m) => `- ${m}`).join("\n")
1283
+ : "None";
1221
1284
  const metricsSummary = metrics
1222
1285
  ? `MI: ${metrics.mi.toFixed(1)}, Cognitive: ${metrics.cognitive}, Nesting: ${metrics.nesting}`
1223
1286
  : "";
@@ -1230,6 +1293,7 @@ export default function (pi: ExtensionAPI) {
1230
1293
  metrics ? `**Complexity**: ${metricsSummary}` : "",
1231
1294
  "",
1232
1295
  issues.length > 0 ? `**Violations**:\n${issuesSummary}` : "",
1296
+ archIssues.length > 0 ? `**Architectural rules violated**:\n${archSummary}` : "",
1233
1297
  "",
1234
1298
  `**Code** (\`${relFile}\` lines ${snippetStart}–${snippetEnd}):`,
1235
1299
  "```typescript",
@@ -1773,8 +1837,13 @@ export default function (pi: ExtensionAPI) {
1773
1837
  dbg(`session_start tools: ${tools.join(", ")}`);
1774
1838
 
1775
1839
  const cwd = ctx.cwd ?? process.cwd();
1840
+ projectRoot = cwd; // Module-level for architect client
1776
1841
  dbg(`session_start cwd: ${cwd}`);
1777
1842
 
1843
+ // Load architect rules if present
1844
+ const hasArchitectRules = architectClient.loadConfig(cwd);
1845
+ if (hasArchitectRules) tools.push("Architect rules");
1846
+
1778
1847
  // Log test runner if detected
1779
1848
  const detectedRunner = testRunnerClient.detectRunner(cwd);
1780
1849
  if (detectedRunner) {
@@ -1907,6 +1976,18 @@ export default function (pi: ExtensionAPI) {
1907
1976
  );
1908
1977
  }
1909
1978
 
1979
+ // Architectural rules pre-write hints
1980
+ if (architectClient.hasConfig()) {
1981
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
1982
+ const archHints = architectClient.getHints(relPath);
1983
+ if (archHints.length > 0) {
1984
+ hints.push(`šŸ“ Architectural rules for ${relPath}:`);
1985
+ for (const h of archHints) {
1986
+ hints.push(` → ${h}`);
1987
+ }
1988
+ }
1989
+ }
1990
+
1910
1991
  dbg(` pre-write hints: ${hints.length} — ${hints.join(" | ") || "none"}`);
1911
1992
  if (hints.length > 0) {
1912
1993
  preWriteHints.set(filePath, hints.join("\n"));
@@ -2052,6 +2133,29 @@ export default function (pi: ExtensionAPI) {
2052
2133
  }
2053
2134
  }
2054
2135
 
2136
+ // Architectural rule validation (post-write)
2137
+ if (architectClient.hasConfig() && fs.existsSync(filePath)) {
2138
+ const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
2139
+ const content = fs.readFileSync(filePath, "utf-8");
2140
+ const lineCount = content.split("\n").length;
2141
+
2142
+ // Check for violations
2143
+ const violations = architectClient.checkFile(relPath, content);
2144
+ if (violations.length > 0) {
2145
+ lspOutput += `\n\nšŸ”“ Architectural violation(s) in ${relPath}:\n`;
2146
+ for (const v of violations) {
2147
+ lspOutput += ` → ${v.message}\n`;
2148
+ }
2149
+ }
2150
+
2151
+ // Check file size limit — hard stop, file is too large to reason about
2152
+ const sizeViolation = architectClient.checkFileSize(relPath, lineCount);
2153
+ if (sizeViolation) {
2154
+ lspOutput += `\n\nšŸ”“ STOP — ${sizeViolation.message}\n`;
2155
+ lspOutput += ` → Split into smaller, focused modules before adding more code.\n`;
2156
+ }
2157
+ }
2158
+
2055
2159
  // ast-grep structural analysis — delta mode (only show new violations)
2056
2160
  if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
2057
2161
  const after = astGrepClient.scanFile(filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-lens",
3
- "version": "2.0.29",
3
+ "version": "2.0.36",
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",