pi-lens 2.0.29 ā 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/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 +102 -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,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
581
|
}
|
|
579
582
|
}
|
|
580
583
|
|
|
584
|
+
// Part 9: Architectural Rules
|
|
585
|
+
if (architectClient.hasConfig()) {
|
|
586
|
+
const archViolations: Array<{ file: string; message: string }> = [];
|
|
587
|
+
const archScanDir = (dir: string) => {
|
|
588
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
589
|
+
const full = path.join(dir, entry.name);
|
|
590
|
+
if (entry.isDirectory()) {
|
|
591
|
+
if (
|
|
592
|
+
["node_modules", ".git", "dist", "build", ".next", ".pi-lens"].includes(
|
|
593
|
+
entry.name,
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
continue;
|
|
597
|
+
archScanDir(full);
|
|
598
|
+
} else if (/\.(ts|tsx|js|jsx|py|go|rs)$/.test(entry.name)) {
|
|
599
|
+
const relPath = path.relative(targetPath, full).replace(/\\/g, "/");
|
|
600
|
+
const content = fs.readFileSync(full, "utf-8");
|
|
601
|
+
const lineCount = content.split("\n").length;
|
|
602
|
+
|
|
603
|
+
// Check pattern violations
|
|
604
|
+
const violations = architectClient.checkFile(relPath, content);
|
|
605
|
+
for (const v of violations) {
|
|
606
|
+
archViolations.push({ file: relPath, message: v.message });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Check file size
|
|
610
|
+
const sizeV = architectClient.checkFileSize(relPath, lineCount);
|
|
611
|
+
if (sizeV) {
|
|
612
|
+
archViolations.push({ file: relPath, message: sizeV.message });
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
archScanDir(targetPath);
|
|
618
|
+
|
|
619
|
+
if (archViolations.length > 0) {
|
|
620
|
+
parts.push(
|
|
621
|
+
`š“ ${archViolations.length} architectural violation(s) ā fix before adding new code`,
|
|
622
|
+
);
|
|
623
|
+
let fullSection = `## Architectural Rules\n\n`;
|
|
624
|
+
fullSection += `**${archViolations.length} violation(s) found**\n\n`;
|
|
625
|
+
for (const v of archViolations) {
|
|
626
|
+
fullSection += `- **${v.file}**: ${v.message}\n`;
|
|
627
|
+
}
|
|
628
|
+
fullSection += "\n";
|
|
629
|
+
fullReport.push(fullSection);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
581
633
|
// Build and save full markdown report
|
|
582
634
|
const fs = require("node:fs");
|
|
583
635
|
fs.mkdirSync(reviewDir, { recursive: true });
|
|
@@ -1190,7 +1242,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1190
1242
|
|
|
1191
1243
|
const skipByFile = scanSkipViolations(astGrepClient, configPath, targetPath, isTsProject, SKIP_RULES, RULE_ACTIONS);
|
|
1192
1244
|
const metricsByFile = scanComplexityMetrics(complexityClient, targetPath, isTsProject);
|
|
1193
|
-
const
|
|
1245
|
+
const architectViolations = architectClient.hasConfig()
|
|
1246
|
+
? scanArchitectViolations(architectClient, targetPath)
|
|
1247
|
+
: new Map<string, string[]>();
|
|
1248
|
+
const scored = scoreFiles(skipByFile, metricsByFile, architectViolations);
|
|
1194
1249
|
|
|
1195
1250
|
if (scored.length === 0) {
|
|
1196
1251
|
ctx.ui.notify("ā
No architectural debt found ā codebase is clean.", "info");
|
|
@@ -1201,6 +1256,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1201
1256
|
const relFile = path.relative(targetPath, worstFile).replace(/\\/g, "/");
|
|
1202
1257
|
const issues = skipByFile.get(worstFile) ?? [];
|
|
1203
1258
|
const metrics = metricsByFile.get(worstFile);
|
|
1259
|
+
const archIssues = architectViolations.get(worstFile) ?? [];
|
|
1204
1260
|
|
|
1205
1261
|
const snippetResult = issues.length > 0 ? extractCodeSnippet(worstFile, issues[0].line) : null;
|
|
1206
1262
|
const snippet = snippetResult?.snippet ?? "";
|
|
@@ -1218,6 +1274,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1218
1274
|
`- \`${r}\` (Ć${n})${RULE_ACTIONS[r] ? ` ā ${RULE_ACTIONS[r].note}` : ""}`,
|
|
1219
1275
|
)
|
|
1220
1276
|
.join("\n");
|
|
1277
|
+
const archSummary = archIssues.length > 0
|
|
1278
|
+
? archIssues.map((m) => `- ${m}`).join("\n")
|
|
1279
|
+
: "None";
|
|
1221
1280
|
const metricsSummary = metrics
|
|
1222
1281
|
? `MI: ${metrics.mi.toFixed(1)}, Cognitive: ${metrics.cognitive}, Nesting: ${metrics.nesting}`
|
|
1223
1282
|
: "";
|
|
@@ -1230,6 +1289,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1230
1289
|
metrics ? `**Complexity**: ${metricsSummary}` : "",
|
|
1231
1290
|
"",
|
|
1232
1291
|
issues.length > 0 ? `**Violations**:\n${issuesSummary}` : "",
|
|
1292
|
+
archIssues.length > 0 ? `**Architectural rules violated**:\n${archSummary}` : "",
|
|
1233
1293
|
"",
|
|
1234
1294
|
`**Code** (\`${relFile}\` lines ${snippetStart}ā${snippetEnd}):`,
|
|
1235
1295
|
"```typescript",
|
|
@@ -1773,8 +1833,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1773
1833
|
dbg(`session_start tools: ${tools.join(", ")}`);
|
|
1774
1834
|
|
|
1775
1835
|
const cwd = ctx.cwd ?? process.cwd();
|
|
1836
|
+
projectRoot = cwd; // Module-level for architect client
|
|
1776
1837
|
dbg(`session_start cwd: ${cwd}`);
|
|
1777
1838
|
|
|
1839
|
+
// Load architect rules if present
|
|
1840
|
+
const hasArchitectRules = architectClient.loadConfig(cwd);
|
|
1841
|
+
if (hasArchitectRules) tools.push("Architect rules");
|
|
1842
|
+
|
|
1778
1843
|
// Log test runner if detected
|
|
1779
1844
|
const detectedRunner = testRunnerClient.detectRunner(cwd);
|
|
1780
1845
|
if (detectedRunner) {
|
|
@@ -1907,6 +1972,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
1907
1972
|
);
|
|
1908
1973
|
}
|
|
1909
1974
|
|
|
1975
|
+
// Architectural rules pre-write hints
|
|
1976
|
+
if (architectClient.hasConfig()) {
|
|
1977
|
+
const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
1978
|
+
const archHints = architectClient.getHints(relPath);
|
|
1979
|
+
if (archHints.length > 0) {
|
|
1980
|
+
hints.push(`š Architectural rules for ${relPath}:`);
|
|
1981
|
+
for (const h of archHints) {
|
|
1982
|
+
hints.push(` ā ${h}`);
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1910
1987
|
dbg(` pre-write hints: ${hints.length} ā ${hints.join(" | ") || "none"}`);
|
|
1911
1988
|
if (hints.length > 0) {
|
|
1912
1989
|
preWriteHints.set(filePath, hints.join("\n"));
|
|
@@ -2052,6 +2129,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
2052
2129
|
}
|
|
2053
2130
|
}
|
|
2054
2131
|
|
|
2132
|
+
// Architectural rule validation (post-write)
|
|
2133
|
+
if (architectClient.hasConfig() && fs.existsSync(filePath)) {
|
|
2134
|
+
const relPath = path.relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
2135
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
2136
|
+
const lineCount = content.split("\n").length;
|
|
2137
|
+
|
|
2138
|
+
// Check for violations
|
|
2139
|
+
const violations = architectClient.checkFile(relPath, content);
|
|
2140
|
+
if (violations.length > 0) {
|
|
2141
|
+
lspOutput += `\n\nš“ Architectural violation(s) in ${relPath}:\n`;
|
|
2142
|
+
for (const v of violations) {
|
|
2143
|
+
lspOutput += ` ā ${v.message}\n`;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// Check file size limit ā hard stop, file is too large to reason about
|
|
2148
|
+
const sizeViolation = architectClient.checkFileSize(relPath, lineCount);
|
|
2149
|
+
if (sizeViolation) {
|
|
2150
|
+
lspOutput += `\n\nš“ STOP ā ${sizeViolation.message}\n`;
|
|
2151
|
+
lspOutput += ` ā Split into smaller, focused modules before adding more code.\n`;
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2055
2155
|
// ast-grep structural analysis ā delta mode (only show new violations)
|
|
2056
2156
|
if (!pi.getFlag("no-ast-grep") && astGrepClient.isAvailable()) {
|
|
2057
2157
|
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.35",
|
|
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",
|