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.
- package/clients/architect-client.js +216 -0
- package/clients/architect-client.ts +273 -0
- package/clients/jscpd-client.js +1 -1
- package/clients/jscpd-client.ts +1 -1
- package/clients/scan-architectural-debt.js +53 -3
- package/clients/scan-architectural-debt.ts +58 -2
- package/index.ts +106 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/clients/jscpd-client.js
CHANGED
|
@@ -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,
|
package/clients/jscpd-client.ts
CHANGED
|
@@ -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
|
|
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([
|
|
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
|
|
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([
|
|
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
|
|
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.
|
|
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",
|