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