pi-lens 2.0.37 → 2.0.39
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 +16 -0
- package/README.md +1 -1
- package/clients/architect-client.js +50 -20
- package/clients/architect-client.ts +61 -22
- package/clients/ast-grep-client.js +32 -127
- package/clients/ast-grep-client.test.js +2 -14
- package/clients/ast-grep-client.test.ts +2 -16
- package/clients/ast-grep-client.ts +34 -150
- package/clients/auto-loop.js +117 -0
- package/clients/auto-loop.ts +171 -0
- package/clients/biome-client.js +25 -22
- package/clients/biome-client.ts +28 -25
- package/clients/complexity-client.js +111 -74
- package/clients/complexity-client.ts +149 -105
- package/clients/dependency-checker.js +16 -30
- package/clients/dependency-checker.ts +15 -35
- package/clients/fix-scanners.js +195 -0
- package/clients/fix-scanners.ts +297 -0
- package/clients/interviewer-templates.js +75 -0
- package/clients/interviewer-templates.ts +90 -0
- package/clients/interviewer.js +73 -101
- package/clients/interviewer.ts +195 -140
- package/clients/knip-client.js +12 -4
- package/clients/knip-client.ts +21 -16
- package/clients/metrics-history.js +215 -0
- package/clients/metrics-history.ts +300 -0
- package/clients/scan-architectural-debt.js +62 -72
- package/clients/scan-architectural-debt.ts +79 -64
- package/clients/scan-utils.js +98 -0
- package/clients/scan-utils.ts +112 -0
- package/clients/sg-runner.js +138 -0
- package/clients/sg-runner.ts +168 -0
- package/clients/subprocess-client.js +0 -37
- package/clients/subprocess-client.ts +0 -60
- package/clients/ts-service.ts +1 -7
- package/clients/type-safety-client.js +3 -8
- package/clients/type-safety-client.ts +10 -14
- package/clients/typescript-client.js +55 -56
- package/clients/typescript-client.ts +94 -52
- package/default-architect.yaml +87 -0
- package/index.ts +143 -1165
- package/package.json +2 -1
- package/rules/ast-grep-rules/rules/large-class.yml +6 -2
- package/rules/ast-grep-rules/rules/long-method.yml +1 -1
- package/rules/ast-grep-rules/rules/no-single-char-var.yml +2 -2
- package/rules/ast-grep-rules/rules/switch-without-default.yml +0 -12
|
@@ -8,11 +8,19 @@
|
|
|
8
8
|
* Rules: ./rules/ directory
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { AstGrepParser } from "./ast-grep-parser.js";
|
|
15
15
|
import { AstGrepRuleManager } from "./ast-grep-rule-manager.js";
|
|
16
|
+
import { SgRunner, type SgMatch } from "./sg-runner.js";
|
|
17
|
+
|
|
18
|
+
const getExtensionDir = () => {
|
|
19
|
+
if (typeof __dirname !== "undefined") {
|
|
20
|
+
return __dirname;
|
|
21
|
+
}
|
|
22
|
+
return ".";
|
|
23
|
+
};
|
|
16
24
|
|
|
17
25
|
// --- Types ---
|
|
18
26
|
|
|
@@ -54,19 +62,15 @@ export class AstGrepClient {
|
|
|
54
62
|
private ruleDir: string;
|
|
55
63
|
private log: (msg: string) => void;
|
|
56
64
|
private ruleManager: AstGrepRuleManager;
|
|
65
|
+
private runner: SgRunner;
|
|
57
66
|
|
|
58
67
|
constructor(ruleDir?: string, verbose = false) {
|
|
59
|
-
this.ruleDir =
|
|
60
|
-
ruleDir ||
|
|
61
|
-
path.join(
|
|
62
|
-
typeof __dirname !== "undefined" ? __dirname : ".",
|
|
63
|
-
"..",
|
|
64
|
-
"rules",
|
|
65
|
-
);
|
|
68
|
+
this.ruleDir = ruleDir || path.join(process.cwd(), "rules");
|
|
66
69
|
this.log = verbose
|
|
67
70
|
? (msg: string) => console.error(`[ast-grep] ${msg}`)
|
|
68
71
|
: () => {};
|
|
69
72
|
this.ruleManager = new AstGrepRuleManager(this.ruleDir, this.log);
|
|
73
|
+
this.runner = new SgRunner(verbose);
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -74,18 +78,10 @@ export class AstGrepClient {
|
|
|
74
78
|
*/
|
|
75
79
|
isAvailable(): boolean {
|
|
76
80
|
if (this.available !== null) return this.available;
|
|
77
|
-
|
|
78
|
-
const result = spawnSync("npx", ["sg", "--version"], {
|
|
79
|
-
encoding: "utf-8",
|
|
80
|
-
timeout: 10000,
|
|
81
|
-
shell: true,
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
this.available = !result.error && result.status === 0;
|
|
81
|
+
this.available = this.runner.isAvailable();
|
|
85
82
|
if (this.available) {
|
|
86
83
|
this.log("ast-grep available");
|
|
87
84
|
}
|
|
88
|
-
|
|
89
85
|
return this.available;
|
|
90
86
|
}
|
|
91
87
|
|
|
@@ -97,7 +93,7 @@ export class AstGrepClient {
|
|
|
97
93
|
lang: string,
|
|
98
94
|
paths: string[],
|
|
99
95
|
): Promise<{ matches: AstGrepMatch[]; error?: string }> {
|
|
100
|
-
return this.
|
|
96
|
+
return this.runner.exec([
|
|
101
97
|
"run",
|
|
102
98
|
"-p",
|
|
103
99
|
pattern,
|
|
@@ -131,7 +127,7 @@ export class AstGrepClient {
|
|
|
131
127
|
if (apply) args.push("--update-all");
|
|
132
128
|
args.push(...paths);
|
|
133
129
|
|
|
134
|
-
const result = await this.
|
|
130
|
+
const result = await this.runner.exec(args);
|
|
135
131
|
return { matches: result.matches, applied: apply, error: result.error };
|
|
136
132
|
}
|
|
137
133
|
|
|
@@ -145,44 +141,7 @@ export class AstGrepClient {
|
|
|
145
141
|
timeout = 30000,
|
|
146
142
|
): any[] {
|
|
147
143
|
if (!this.isAvailable()) return [];
|
|
148
|
-
|
|
149
|
-
const tmpDir = require("node:os").tmpdir();
|
|
150
|
-
const ts = Date.now();
|
|
151
|
-
const sessionDir = path.join(tmpDir, `pi-lens-temp-${ruleId}-${ts}`);
|
|
152
|
-
const rulesSubdir = path.join(sessionDir, "rules");
|
|
153
|
-
const ruleFile = path.join(rulesSubdir, `${ruleId}.yml`);
|
|
154
|
-
const configFile = path.join(sessionDir, ".sgconfig.yml");
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
fs.mkdirSync(rulesSubdir, { recursive: true });
|
|
158
|
-
fs.writeFileSync(configFile, `ruleDirs:\n - ./rules\n`);
|
|
159
|
-
fs.writeFileSync(ruleFile, ruleYaml);
|
|
160
|
-
|
|
161
|
-
const result = spawnSync(
|
|
162
|
-
"npx",
|
|
163
|
-
["sg", "scan", "--config", configFile, "--json", dir],
|
|
164
|
-
{
|
|
165
|
-
encoding: "utf-8",
|
|
166
|
-
timeout,
|
|
167
|
-
shell: true,
|
|
168
|
-
},
|
|
169
|
-
);
|
|
170
|
-
|
|
171
|
-
const output = result.stdout || result.stderr || "";
|
|
172
|
-
if (!output.trim()) return [];
|
|
173
|
-
|
|
174
|
-
const items = JSON.parse(output);
|
|
175
|
-
return Array.isArray(items) ? items : [items];
|
|
176
|
-
} catch (err) {
|
|
177
|
-
void err;
|
|
178
|
-
return [];
|
|
179
|
-
} finally {
|
|
180
|
-
try {
|
|
181
|
-
fs.rmSync(sessionDir, { recursive: true, force: true });
|
|
182
|
-
} catch (err) {
|
|
183
|
-
void err;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
144
|
+
return this.runner.tempScan(dir, ruleId, ruleYaml, timeout);
|
|
186
145
|
}
|
|
187
146
|
|
|
188
147
|
/**
|
|
@@ -215,42 +174,30 @@ message: found
|
|
|
215
174
|
pattern: string;
|
|
216
175
|
functions: Array<{ name: string; file: string; line: number }>;
|
|
217
176
|
}> {
|
|
218
|
-
const
|
|
219
|
-
string,
|
|
220
|
-
Array<{ name: string; file: string; line: number }>
|
|
221
|
-
>();
|
|
177
|
+
const grouped = new Map<string, Array<{ name: string; file: string; line: number }>>();
|
|
222
178
|
|
|
223
179
|
for (const item of matches) {
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
if (!nameMatch?.[1]) continue;
|
|
180
|
+
const name = this.extractFunctionName(item.text);
|
|
181
|
+
if (!name) continue;
|
|
227
182
|
|
|
228
|
-
const signature = this.normalizeFunction(text);
|
|
183
|
+
const signature = this.normalizeFunction(item.text);
|
|
184
|
+
const line = (item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0) + 1;
|
|
229
185
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const line =
|
|
235
|
-
item.range?.start?.line || item.labels?.[0]?.range?.start?.line || 0;
|
|
236
|
-
normalized.get(signature)?.push({
|
|
237
|
-
name: nameMatch[1],
|
|
238
|
-
file: item.file,
|
|
239
|
-
line: line + 1,
|
|
240
|
-
});
|
|
186
|
+
const group = grouped.get(signature) ?? [];
|
|
187
|
+
group.push({ name, file: item.file, line });
|
|
188
|
+
grouped.set(signature, group);
|
|
241
189
|
}
|
|
242
190
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
functions
|
|
246
|
-
|
|
247
|
-
for (const [pattern, functions] of normalized) {
|
|
248
|
-
if (functions.length > 1) {
|
|
249
|
-
result_groups.push({ pattern, functions });
|
|
250
|
-
}
|
|
251
|
-
}
|
|
191
|
+
return Array.from(grouped.entries())
|
|
192
|
+
.filter(([_, functions]) => functions.length > 1)
|
|
193
|
+
.map(([pattern, functions]) => ({ pattern, functions }));
|
|
194
|
+
}
|
|
252
195
|
|
|
253
|
-
|
|
196
|
+
/**
|
|
197
|
+
* Extract function name from match text
|
|
198
|
+
*/
|
|
199
|
+
private extractFunctionName(text: string): string | null {
|
|
200
|
+
return text.match(/function\s+(\w+)/)?.[1] ?? null;
|
|
254
201
|
}
|
|
255
202
|
|
|
256
203
|
private normalizeFunction(text: string): string {
|
|
@@ -305,71 +252,8 @@ message: found
|
|
|
305
252
|
return exports;
|
|
306
253
|
}
|
|
307
254
|
|
|
308
|
-
private runSg(
|
|
309
|
-
args: string[],
|
|
310
|
-
): Promise<{ matches: AstGrepMatch[]; error?: string }> {
|
|
311
|
-
return new Promise((resolve) => {
|
|
312
|
-
const proc = spawn("npx", ["sg", ...args], {
|
|
313
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
314
|
-
shell: true,
|
|
315
|
-
});
|
|
316
|
-
let stdout = "";
|
|
317
|
-
let stderr = "";
|
|
318
|
-
|
|
319
|
-
proc.stdout.on("data", (data: Buffer) => (stdout += data.toString()));
|
|
320
|
-
proc.stderr.on("data", (data: Buffer) => (stderr += data.toString()));
|
|
321
|
-
|
|
322
|
-
proc.on("error", (err: Error) => {
|
|
323
|
-
if (err.message.includes("ENOENT")) {
|
|
324
|
-
resolve({
|
|
325
|
-
matches: [],
|
|
326
|
-
error: "ast-grep CLI not found. Install: npm i -D @ast-grep/cli",
|
|
327
|
-
});
|
|
328
|
-
} else {
|
|
329
|
-
resolve({ matches: [], error: err.message });
|
|
330
|
-
}
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
proc.on("close", (code: number | null) => {
|
|
334
|
-
if (code !== 0 && !stdout.trim()) {
|
|
335
|
-
resolve({
|
|
336
|
-
matches: [],
|
|
337
|
-
error: stderr.includes("No files found")
|
|
338
|
-
? undefined
|
|
339
|
-
: stderr.trim() || `Exit code ${code}`,
|
|
340
|
-
});
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (!stdout.trim()) {
|
|
344
|
-
resolve({ matches: [] });
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
try {
|
|
348
|
-
const parsed = JSON.parse(stdout);
|
|
349
|
-
const matches = Array.isArray(parsed) ? parsed : [parsed];
|
|
350
|
-
resolve({ matches });
|
|
351
|
-
} catch (err) {
|
|
352
|
-
void err;
|
|
353
|
-
resolve({ matches: [], error: "Failed to parse output" });
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
|
|
359
255
|
formatMatches(matches: AstGrepMatch[], isDryRun = false): string {
|
|
360
|
-
|
|
361
|
-
const MAX = 50;
|
|
362
|
-
const shown = matches.slice(0, MAX);
|
|
363
|
-
const lines = shown.map((m) => {
|
|
364
|
-
const loc = `${m.file}:${m.range.start.line + 1}:${m.range.start.column + 1}`;
|
|
365
|
-
const text = m.text.length > 100 ? `${m.text.slice(0, 100)}...` : m.text;
|
|
366
|
-
return isDryRun && m.replacement
|
|
367
|
-
? `${loc}\n - ${text}\n + ${m.replacement}`
|
|
368
|
-
: `${loc}: ${text}`;
|
|
369
|
-
});
|
|
370
|
-
if (matches.length > MAX)
|
|
371
|
-
lines.unshift(`Found ${matches.length} matches (showing first ${MAX}):`);
|
|
372
|
-
return lines.join("\n");
|
|
256
|
+
return this.runner.formatMatches(matches as SgMatch[], isDryRun);
|
|
373
257
|
}
|
|
374
258
|
|
|
375
259
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-loop engine for pi-lens fix and refactor commands.
|
|
3
|
+
*
|
|
4
|
+
* Provides automatic iteration without requiring the user to manually
|
|
5
|
+
* re-run the command each time. Uses pi's event system (agent_end)
|
|
6
|
+
* to trigger the next iteration automatically.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: Must be initialized at extension load time (in index.ts),
|
|
9
|
+
* not lazily when the command is called. Event handlers need to be
|
|
10
|
+
* registered early to catch agent_end events.
|
|
11
|
+
*/
|
|
12
|
+
export function createAutoLoop(pi, config) {
|
|
13
|
+
let state = {
|
|
14
|
+
active: false,
|
|
15
|
+
iteration: 0,
|
|
16
|
+
maxIterations: config.maxIterations,
|
|
17
|
+
};
|
|
18
|
+
const updateStatus = (ctx) => {
|
|
19
|
+
if (state.active) {
|
|
20
|
+
ctx.ui.setStatus(`loop-${config.name}`, `${config.name} (${state.iteration + 1}/${state.maxIterations})`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
ctx.ui.setStatus(`loop-${config.name}`, undefined);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const stop = (ctx, reason) => {
|
|
27
|
+
const wasActive = state.active;
|
|
28
|
+
state = { active: false, iteration: 0, maxIterations: config.maxIterations };
|
|
29
|
+
updateStatus(ctx);
|
|
30
|
+
if (wasActive) {
|
|
31
|
+
ctx.ui.notify(`✅ ${config.name} loop ${reason}`, "info");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const complete = (ctx, reason) => {
|
|
35
|
+
stop(ctx, reason);
|
|
36
|
+
};
|
|
37
|
+
const start = (ctx) => {
|
|
38
|
+
if (state.active) {
|
|
39
|
+
ctx.ui.notify(`${config.name} loop is already running`, "warning");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
state = {
|
|
43
|
+
active: true,
|
|
44
|
+
iteration: 0,
|
|
45
|
+
maxIterations: config.maxIterations,
|
|
46
|
+
};
|
|
47
|
+
updateStatus(ctx);
|
|
48
|
+
ctx.ui.notify(`🔄 Starting ${config.name} auto-loop (max ${state.maxIterations} iterations)...`, "info");
|
|
49
|
+
};
|
|
50
|
+
const getState = () => ({ ...state });
|
|
51
|
+
// --- Event Handlers (registered at module load time) ---
|
|
52
|
+
// Handle user interruption (any manual input stops the loop)
|
|
53
|
+
pi.on("input", async (event, ctx) => {
|
|
54
|
+
if (!ctx.hasUI)
|
|
55
|
+
return { action: "continue" };
|
|
56
|
+
if (!state.active)
|
|
57
|
+
return { action: "continue" };
|
|
58
|
+
// User typed something manually → stop the auto-loop
|
|
59
|
+
if (event.source === "interactive") {
|
|
60
|
+
stop(ctx, "stopped (user interrupted)");
|
|
61
|
+
}
|
|
62
|
+
return { action: "continue" };
|
|
63
|
+
});
|
|
64
|
+
// Handle end of agent turn → check if we should continue
|
|
65
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
66
|
+
if (!ctx.hasUI)
|
|
67
|
+
return;
|
|
68
|
+
if (!state.active)
|
|
69
|
+
return;
|
|
70
|
+
const assistantMessages = event.messages.filter((m) => m.role === "assistant");
|
|
71
|
+
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
|
|
72
|
+
if (!lastAssistantMessage) {
|
|
73
|
+
stop(ctx, "stopped (no response)");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const textContent = lastAssistantMessage.content
|
|
77
|
+
.filter((c) => c.type === "text")
|
|
78
|
+
.map((c) => c.text)
|
|
79
|
+
.join("\n");
|
|
80
|
+
if (!textContent.trim()) {
|
|
81
|
+
stop(ctx, "stopped (empty response)");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Check for completion patterns (explicit success)
|
|
85
|
+
if (config.completionPatterns) {
|
|
86
|
+
const hasCompletion = config.completionPatterns.some((p) => p.test(textContent));
|
|
87
|
+
if (hasCompletion) {
|
|
88
|
+
complete(ctx, "completed successfully");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Check for exit patterns (could be success or stopped)
|
|
93
|
+
const hasExit = config.exitPatterns.some((p) => p.test(textContent));
|
|
94
|
+
if (hasExit) {
|
|
95
|
+
complete(ctx, "completed - no more work");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Check max iterations
|
|
99
|
+
state.iteration++;
|
|
100
|
+
if (state.iteration >= state.maxIterations) {
|
|
101
|
+
stop(ctx, `stopped (max iterations ${state.maxIterations} reached)`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
// Continue to next iteration - send command as follow-up
|
|
105
|
+
updateStatus(ctx);
|
|
106
|
+
const continueMsg = config.continuePrompt || `Run ${config.command} to continue.`;
|
|
107
|
+
pi.sendUserMessage(`🔄 Auto-loop (${state.iteration + 1}/${state.maxIterations}): ${continueMsg}`, { deliverAs: "followUp" });
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
start,
|
|
111
|
+
stop,
|
|
112
|
+
getState,
|
|
113
|
+
setMaxIterations: (n) => {
|
|
114
|
+
state.maxIterations = n;
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-loop engine for pi-lens fix and refactor commands.
|
|
3
|
+
*
|
|
4
|
+
* Provides automatic iteration without requiring the user to manually
|
|
5
|
+
* re-run the command each time. Uses pi's event system (agent_end)
|
|
6
|
+
* to trigger the next iteration automatically.
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT: Must be initialized at extension load time (in index.ts),
|
|
9
|
+
* not lazily when the command is called. Event handlers need to be
|
|
10
|
+
* registered early to catch agent_end events.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
export interface LoopConfig {
|
|
16
|
+
/** Unique identifier for this loop instance (e.g., "fix", "refactor") */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Maximum iterations before stopping */
|
|
19
|
+
maxIterations: number;
|
|
20
|
+
/** Command to run for each iteration (e.g., "/lens-booboo-fix --loop") */
|
|
21
|
+
command: string;
|
|
22
|
+
/** Patterns that indicate the loop should exit (e.g., "no more fixable issues") */
|
|
23
|
+
exitPatterns: RegExp[];
|
|
24
|
+
/** Patterns that indicate completion with success */
|
|
25
|
+
completionPatterns?: RegExp[];
|
|
26
|
+
/** Additional text to include when prompting for next iteration */
|
|
27
|
+
continuePrompt?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoopState {
|
|
31
|
+
active: boolean;
|
|
32
|
+
iteration: number;
|
|
33
|
+
maxIterations: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createAutoLoop(pi: ExtensionAPI, config: LoopConfig): {
|
|
37
|
+
start: (ctx: ExtensionContext) => void;
|
|
38
|
+
stop: (ctx: ExtensionContext, reason: string) => void;
|
|
39
|
+
getState: () => LoopState;
|
|
40
|
+
setMaxIterations: (n: number) => void;
|
|
41
|
+
} {
|
|
42
|
+
let state: LoopState = {
|
|
43
|
+
active: false,
|
|
44
|
+
iteration: 0,
|
|
45
|
+
maxIterations: config.maxIterations,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const updateStatus = (ctx: ExtensionContext) => {
|
|
49
|
+
if (state.active) {
|
|
50
|
+
ctx.ui.setStatus(
|
|
51
|
+
`loop-${config.name}`,
|
|
52
|
+
`${config.name} (${state.iteration + 1}/${state.maxIterations})`,
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
ctx.ui.setStatus(`loop-${config.name}`, undefined);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const stop = (ctx: ExtensionContext, reason: string) => {
|
|
60
|
+
const wasActive = state.active;
|
|
61
|
+
state = { active: false, iteration: 0, maxIterations: config.maxIterations };
|
|
62
|
+
updateStatus(ctx);
|
|
63
|
+
if (wasActive) {
|
|
64
|
+
ctx.ui.notify(`✅ ${config.name} loop ${reason}`, "info");
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const complete = (ctx: ExtensionContext, reason: string) => {
|
|
69
|
+
stop(ctx, reason);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const start = (ctx: ExtensionContext) => {
|
|
73
|
+
if (state.active) {
|
|
74
|
+
ctx.ui.notify(`${config.name} loop is already running`, "warning");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
state = {
|
|
79
|
+
active: true,
|
|
80
|
+
iteration: 0,
|
|
81
|
+
maxIterations: config.maxIterations,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
updateStatus(ctx);
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
`🔄 Starting ${config.name} auto-loop (max ${state.maxIterations} iterations)...`,
|
|
87
|
+
"info",
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const getState = (): LoopState => ({ ...state });
|
|
92
|
+
|
|
93
|
+
// --- Event Handlers (registered at module load time) ---
|
|
94
|
+
|
|
95
|
+
// Handle user interruption (any manual input stops the loop)
|
|
96
|
+
pi.on("input", async (event, ctx) => {
|
|
97
|
+
if (!ctx.hasUI) return { action: "continue" as const };
|
|
98
|
+
if (!state.active) return { action: "continue" as const };
|
|
99
|
+
|
|
100
|
+
// User typed something manually → stop the auto-loop
|
|
101
|
+
if (event.source === "interactive") {
|
|
102
|
+
stop(ctx, "stopped (user interrupted)");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { action: "continue" as const };
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Handle end of agent turn → check if we should continue
|
|
109
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
110
|
+
if (!ctx.hasUI) return;
|
|
111
|
+
if (!state.active) return;
|
|
112
|
+
|
|
113
|
+
const assistantMessages = event.messages.filter((m) => m.role === "assistant");
|
|
114
|
+
const lastAssistantMessage = assistantMessages[assistantMessages.length - 1];
|
|
115
|
+
|
|
116
|
+
if (!lastAssistantMessage) {
|
|
117
|
+
stop(ctx, "stopped (no response)");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const textContent = lastAssistantMessage.content
|
|
122
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
123
|
+
.map((c) => c.text)
|
|
124
|
+
.join("\n");
|
|
125
|
+
|
|
126
|
+
if (!textContent.trim()) {
|
|
127
|
+
stop(ctx, "stopped (empty response)");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for completion patterns (explicit success)
|
|
132
|
+
if (config.completionPatterns) {
|
|
133
|
+
const hasCompletion = config.completionPatterns.some((p) => p.test(textContent));
|
|
134
|
+
if (hasCompletion) {
|
|
135
|
+
complete(ctx, "completed successfully");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Check for exit patterns (could be success or stopped)
|
|
141
|
+
const hasExit = config.exitPatterns.some((p) => p.test(textContent));
|
|
142
|
+
if (hasExit) {
|
|
143
|
+
complete(ctx, "completed - no more work");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check max iterations
|
|
148
|
+
state.iteration++;
|
|
149
|
+
if (state.iteration >= state.maxIterations) {
|
|
150
|
+
stop(ctx, `stopped (max iterations ${state.maxIterations} reached)`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Continue to next iteration - send command as follow-up
|
|
155
|
+
updateStatus(ctx);
|
|
156
|
+
const continueMsg = config.continuePrompt || `Run ${config.command} to continue.`;
|
|
157
|
+
pi.sendUserMessage(
|
|
158
|
+
`🔄 Auto-loop (${state.iteration + 1}/${state.maxIterations}): ${continueMsg}`,
|
|
159
|
+
{ deliverAs: "followUp" },
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
start,
|
|
165
|
+
stop,
|
|
166
|
+
getState,
|
|
167
|
+
setMaxIterations: (n: number) => {
|
|
168
|
+
state.maxIterations = n;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
package/clients/biome-client.js
CHANGED
|
@@ -56,14 +56,24 @@ export class BiomeClient {
|
|
|
56
56
|
".cjs",
|
|
57
57
|
].includes(ext);
|
|
58
58
|
}
|
|
59
|
+
// --- Internal helpers ---
|
|
59
60
|
/**
|
|
60
|
-
*
|
|
61
|
+
* Validate path and availability — returns path or null on failure
|
|
61
62
|
*/
|
|
62
|
-
|
|
63
|
+
withValidatedPath(filePath) {
|
|
63
64
|
if (!this.isAvailable())
|
|
64
|
-
return
|
|
65
|
+
return null;
|
|
65
66
|
const absolutePath = path.resolve(filePath);
|
|
66
67
|
if (!fs.existsSync(absolutePath))
|
|
68
|
+
return null;
|
|
69
|
+
return absolutePath;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Run biome check (format + lint) without fixing — returns diagnostics
|
|
73
|
+
*/
|
|
74
|
+
checkFile(filePath) {
|
|
75
|
+
const absolutePath = this.withValidatedPath(filePath);
|
|
76
|
+
if (!absolutePath)
|
|
67
77
|
return [];
|
|
68
78
|
try {
|
|
69
79
|
const result = spawnSync("npx", [
|
|
@@ -92,11 +102,13 @@ export class BiomeClient {
|
|
|
92
102
|
* Format a file (writes to disk)
|
|
93
103
|
*/
|
|
94
104
|
formatFile(filePath) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
const absolutePath = this.withValidatedPath(filePath);
|
|
106
|
+
if (!absolutePath)
|
|
107
|
+
return {
|
|
108
|
+
success: false,
|
|
109
|
+
changed: false,
|
|
110
|
+
error: this.isAvailable() ? "File not found" : "Biome not available",
|
|
111
|
+
};
|
|
100
112
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
101
113
|
try {
|
|
102
114
|
const result = spawnSync("npx", ["@biomejs/biome", "format", "--write", absolutePath], {
|
|
@@ -123,20 +135,13 @@ export class BiomeClient {
|
|
|
123
135
|
* Fix both formatting and linting issues (writes to disk)
|
|
124
136
|
*/
|
|
125
137
|
fixFile(filePath) {
|
|
126
|
-
|
|
138
|
+
const absolutePath = this.withValidatedPath(filePath);
|
|
139
|
+
if (!absolutePath)
|
|
127
140
|
return {
|
|
128
141
|
success: false,
|
|
129
142
|
changed: false,
|
|
130
143
|
fixed: 0,
|
|
131
|
-
error: "Biome not available",
|
|
132
|
-
};
|
|
133
|
-
const absolutePath = path.resolve(filePath);
|
|
134
|
-
if (!fs.existsSync(absolutePath))
|
|
135
|
-
return {
|
|
136
|
-
success: false,
|
|
137
|
-
changed: false,
|
|
138
|
-
fixed: 0,
|
|
139
|
-
error: "File not found",
|
|
144
|
+
error: this.isAvailable() ? "File not found" : "Biome not available",
|
|
140
145
|
};
|
|
141
146
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
142
147
|
try {
|
|
@@ -211,10 +216,8 @@ export class BiomeClient {
|
|
|
211
216
|
* Generate a diff-like summary of formatting changes
|
|
212
217
|
*/
|
|
213
218
|
getFormatDiff(filePath) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const absolutePath = path.resolve(filePath);
|
|
217
|
-
if (!fs.existsSync(absolutePath))
|
|
219
|
+
const absolutePath = this.withValidatedPath(filePath);
|
|
220
|
+
if (!absolutePath)
|
|
218
221
|
return "";
|
|
219
222
|
const content = fs.readFileSync(absolutePath, "utf-8");
|
|
220
223
|
try {
|