@geminixiang/pi-simplify 0.0.11 → 0.1.2
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 +29 -0
- package/README.md +49 -1
- package/index.ts +5 -6
- package/package.json +11 -7
- package/src/code-smell/checks.ts +210 -0
- package/src/code-smell/prompt.ts +59 -0
- package/src/code-smell/selector.ts +157 -0
- package/src/code-smell/types.ts +36 -0
- package/src/code-smell/workflow.ts +334 -0
- package/{workflow.ts → src/simplify/workflow.ts} +50 -8
- package/tsconfig.json +2 -1
- package/.github/workflows/publish.yml +0 -56
- package/.pi/skills/release/SKILL.md +0 -146
- /package/{prompt.ts → src/simplify/prompt.ts} +0 -0
- /package/{selector.ts → src/simplify/selector.ts} +0 -0
- /package/{types.ts → src/simplify/types.ts} +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,35 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.2] - 2026-07-26
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Move package development and trusted publishing to the `geminixiang/pi-stuff` monorepo.
|
|
14
|
+
- Declare Pi runtime packages as peer dependencies to follow Pi package conventions.
|
|
15
|
+
|
|
16
|
+
## [0.1.1] - 2026-06-09
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Add `/simplify previous` mode to review only the last commit (`HEAD~1..HEAD`), with aliases for `previous-commit`, `prev`, and `last-commit`.
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-06-03
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Add `/code-smell` command: finds structural problems with programmatic checks (debug remnants, commented-out code, duplication, complex functions) plus agent review across complexity, duplication, coupling, state, errors, performance, and maintainability smell families.
|
|
27
|
+
- Programmatic scanner runs deterministically before agent review; findings are verified by reading files before surfacing them.
|
|
28
|
+
- Interactive findings selector: ranked by severity, auto-selects high-severity / high-confidence items, shows cause-effect chain inline.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Skip redundant `## Scope` section in the agent prompt when scanning the repository root.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Reorganise source into `src/simplify/` and `src/code-smell/` subdirectories.
|
|
37
|
+
|
|
9
38
|
## [0.0.11] - 2026-06-03
|
|
10
39
|
|
|
11
40
|
### Changed
|
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ After implementing a feature, your code often accumulates:
|
|
|
16
16
|
|
|
17
17
|
`/simplify` finds these and removes them.
|
|
18
18
|
|
|
19
|
+
`/code-smell` goes deeper — it runs programmatic checks plus agent review to surface structural problems like coupling, error handling gaps, and performance issues.
|
|
20
|
+
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
21
23
|
```sh
|
|
@@ -24,13 +26,39 @@ pi install npm:@geminixiang/pi-simplify
|
|
|
24
26
|
|
|
25
27
|
## Usage
|
|
26
28
|
|
|
29
|
+
### Code Smell Analysis
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
/code-smell
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Opens a scope selector. Choose to scan the repository or specific paths.
|
|
36
|
+
|
|
37
|
+
Runs programmatic checks (debug remnants, commented-out code, duplication, complex functions) then agent review across seven smell families:
|
|
38
|
+
|
|
39
|
+
- **Complexity** - long functions, deep nesting, mixed responsibilities
|
|
40
|
+
- **Duplication** - copy-paste blocks, repeated condition chains
|
|
41
|
+
- **Coupling** - cross-layer reach-through, boundary violations
|
|
42
|
+
- **State** - redundant derived state, multiple sources of truth
|
|
43
|
+
- **Errors** - swallowed errors, empty catches, inconsistent mapping
|
|
44
|
+
- **Performance** - N+1 loops, sequential independent awaits
|
|
45
|
+
- **Maintainability** - debug remnants, TODO/HACK markers, stringly-typed constants
|
|
46
|
+
|
|
47
|
+
Findings are ranked by severity. Select which to fix and the agent applies small, behavior-preserving cleanups.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
/code-smell src lib
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Scans specific paths directly.
|
|
54
|
+
|
|
27
55
|
### Full Simplify
|
|
28
56
|
|
|
29
57
|
```
|
|
30
58
|
/simplify
|
|
31
59
|
```
|
|
32
60
|
|
|
33
|
-
Opens a preset selector. Choose uncommitted changes or a folder snapshot.
|
|
61
|
+
Opens a preset selector. Choose uncommitted changes, the previous commit, or a folder snapshot.
|
|
34
62
|
|
|
35
63
|
Uncommitted mode analyzes all git changes and presents cleanup candidates:
|
|
36
64
|
|
|
@@ -38,6 +66,16 @@ Uncommitted mode analyzes all git changes and presents cleanup candidates:
|
|
|
38
66
|
- **Confirm** (yellow) - delete after user confirms
|
|
39
67
|
- **Review** (orange) - user should review first
|
|
40
68
|
|
|
69
|
+
### Simplify Previous Commit
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
/simplify previous
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Analyzes only the last commit (`git diff HEAD~1..HEAD`), even if there are uncommitted local changes.
|
|
76
|
+
|
|
77
|
+
Aliases: `/simplify previous-commit`, `/simplify prev`, `/simplify last-commit`.
|
|
78
|
+
|
|
41
79
|
### Simplify Folders
|
|
42
80
|
|
|
43
81
|
```
|
|
@@ -53,6 +91,16 @@ Analyzes the specified folders/files as a snapshot (not a diff), even when there
|
|
|
53
91
|
/simplify focus on removing debug code
|
|
54
92
|
```
|
|
55
93
|
|
|
94
|
+
## Commands
|
|
95
|
+
|
|
96
|
+
| Command | Purpose |
|
|
97
|
+
| -------------------------- | --------------------------------------------------------------------------- |
|
|
98
|
+
| `/simplify` | Delete excess after a feature: dead code, debug remnants, thin wrappers |
|
|
99
|
+
| `/simplify previous` | Same as above but scoped to the previous commit (`HEAD~1..HEAD`) |
|
|
100
|
+
| `/simplify folder <paths>` | Same as above but snapshot mode (no git diff needed) |
|
|
101
|
+
| `/code-smell` | Find structural problems: coupling, complexity, error handling, performance |
|
|
102
|
+
| `/code-smell <paths>` | Scan specific paths for code smells |
|
|
103
|
+
|
|
56
104
|
## Comparison with pi-review
|
|
57
105
|
|
|
58
106
|
| | pi-review | pi-simplify |
|
package/index.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-simplify: Code Review & Cleanup Extension for pi
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* - Quality: dead code, debug remnants, commented-out code, over-engineering, duplicate logic
|
|
7
|
-
* - Efficiency: redundant work, missed concurrency, hot-path bloat, memory leaks
|
|
4
|
+
* /simplify — reviews changed code for reuse, quality, and efficiency
|
|
5
|
+
* /code-smell — finds structural problems with programmatic checks and agent review
|
|
8
6
|
*/
|
|
9
7
|
|
|
10
8
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
|
|
12
|
-
import { registerSimplifyWorkflow } from "./workflow.js";
|
|
9
|
+
import { registerCodeSmellWorkflow } from "./src/code-smell/workflow.js";
|
|
10
|
+
import { registerSimplifyWorkflow } from "./src/simplify/workflow.js";
|
|
13
11
|
|
|
14
12
|
export default function simplifyExtension(pi: ExtensionAPI) {
|
|
15
13
|
registerSimplifyWorkflow(pi);
|
|
14
|
+
registerCodeSmellWorkflow(pi);
|
|
16
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geminixiang/pi-simplify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Simplify: Review changed code for reuse, quality, and efficiency",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
"author": "geminixiang",
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "https://github.com/geminixiang/pi-
|
|
12
|
+
"url": "https://github.com/geminixiang/pi-stuff.git",
|
|
13
|
+
"directory": "packages/pi-simplify"
|
|
13
14
|
},
|
|
14
15
|
"main": "index.ts",
|
|
15
16
|
"scripts": {
|
|
@@ -18,16 +19,19 @@
|
|
|
18
19
|
"fmt": "oxfmt",
|
|
19
20
|
"fmt:check": "oxfmt --check"
|
|
20
21
|
},
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"@earendil-works/pi-coding-agent": ">=0.74.0",
|
|
23
|
-
"@earendil-works/pi-tui": ">=0.74.0",
|
|
24
|
-
"typebox": "^1.1.38"
|
|
25
|
-
},
|
|
26
22
|
"devDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
24
|
+
"@earendil-works/pi-tui": "^0.82.1",
|
|
27
25
|
"oxfmt": "^0.45.0",
|
|
28
26
|
"oxlint": "^1.60.0",
|
|
27
|
+
"typebox": "^1.1.38",
|
|
29
28
|
"typescript": "^6.0.2"
|
|
30
29
|
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
32
|
+
"@earendil-works/pi-tui": "*",
|
|
33
|
+
"typebox": "*"
|
|
34
|
+
},
|
|
31
35
|
"pi": {
|
|
32
36
|
"extensions": [
|
|
33
37
|
"./index.ts"
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ProgrammaticCheck, SmellCategory, SmellSeverity } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_IGNORES = new Set([
|
|
7
|
+
".git",
|
|
8
|
+
"node_modules",
|
|
9
|
+
"dist",
|
|
10
|
+
"build",
|
|
11
|
+
"coverage",
|
|
12
|
+
".next",
|
|
13
|
+
".turbo",
|
|
14
|
+
".cache",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const TEXT_EXTENSIONS = new Set([
|
|
18
|
+
".ts",
|
|
19
|
+
".tsx",
|
|
20
|
+
".js",
|
|
21
|
+
".jsx",
|
|
22
|
+
".mjs",
|
|
23
|
+
".cjs",
|
|
24
|
+
".py",
|
|
25
|
+
".go",
|
|
26
|
+
".rs",
|
|
27
|
+
".java",
|
|
28
|
+
".kt",
|
|
29
|
+
".rb",
|
|
30
|
+
".php",
|
|
31
|
+
".cs",
|
|
32
|
+
".swift",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
type ScanOptions = {
|
|
36
|
+
cwd: string;
|
|
37
|
+
paths: string[];
|
|
38
|
+
maxFiles?: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type SourceFile = {
|
|
42
|
+
absolutePath: string;
|
|
43
|
+
relativePath: string;
|
|
44
|
+
text: string;
|
|
45
|
+
lines: string[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
async function collectFiles(root: string, target: string, out: string[], maxFiles: number) {
|
|
49
|
+
if (out.length >= maxFiles) return;
|
|
50
|
+
const absolute = path.resolve(root, target);
|
|
51
|
+
const info = await stat(absolute).catch(() => null);
|
|
52
|
+
if (!info) return;
|
|
53
|
+
|
|
54
|
+
if (info.isFile()) {
|
|
55
|
+
if (TEXT_EXTENSIONS.has(path.extname(absolute))) out.push(absolute);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!info.isDirectory()) return;
|
|
60
|
+
const entries = await readdir(absolute, { withFileTypes: true }).catch(() => []);
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (out.length >= maxFiles) return;
|
|
63
|
+
if (DEFAULT_IGNORES.has(entry.name)) continue;
|
|
64
|
+
await collectFiles(root, path.join(target, entry.name), out, maxFiles);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lineRange(start: number, end?: number) {
|
|
69
|
+
return end && end !== start ? `${start}-${end}` : String(start);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function push(
|
|
73
|
+
findings: ProgrammaticCheck[],
|
|
74
|
+
args: Omit<ProgrammaticCheck, "severity" | "category"> & {
|
|
75
|
+
category: SmellCategory;
|
|
76
|
+
severity: SmellSeverity;
|
|
77
|
+
},
|
|
78
|
+
) {
|
|
79
|
+
findings.push(args);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scanFile(file: SourceFile): ProgrammaticCheck[] {
|
|
83
|
+
const findings: ProgrammaticCheck[] = [];
|
|
84
|
+
const counts = new Map<string, number[]>();
|
|
85
|
+
let functionStart: number | null = null;
|
|
86
|
+
let functionBraceDepth = 0;
|
|
87
|
+
let currentFunctionComplexity = 0;
|
|
88
|
+
|
|
89
|
+
file.lines.forEach((raw, index) => {
|
|
90
|
+
const lineNo = index + 1;
|
|
91
|
+
const line = raw.trim();
|
|
92
|
+
const normalized = line.replace(/\s+/g, " ");
|
|
93
|
+
|
|
94
|
+
if (normalized.length > 20 && !normalized.startsWith("//") && !normalized.startsWith("*")) {
|
|
95
|
+
const existing = counts.get(normalized) ?? [];
|
|
96
|
+
existing.push(lineNo);
|
|
97
|
+
counts.set(normalized, existing);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (/\b(console\.(log|debug|warn|error)|debugger)\b/.test(line)) {
|
|
101
|
+
push(findings, {
|
|
102
|
+
id: "debug-remnant",
|
|
103
|
+
title: "Debug remnant",
|
|
104
|
+
category: "maintainability",
|
|
105
|
+
severity: line.includes("debugger") ? "high" : "medium",
|
|
106
|
+
file: file.relativePath,
|
|
107
|
+
lines: String(lineNo),
|
|
108
|
+
evidence: raw.trim(),
|
|
109
|
+
recommendation:
|
|
110
|
+
"Remove temporary logging/debug statements or replace with the project's structured logger if this is intentional.",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (/^\/\/\s*(if|for|while|return|const|let|var|function|class|import|export)\b/.test(line)) {
|
|
115
|
+
push(findings, {
|
|
116
|
+
id: "commented-out-code",
|
|
117
|
+
title: "Commented-out code",
|
|
118
|
+
category: "maintainability",
|
|
119
|
+
severity: "low",
|
|
120
|
+
file: file.relativePath,
|
|
121
|
+
lines: String(lineNo),
|
|
122
|
+
evidence: raw.trim(),
|
|
123
|
+
recommendation:
|
|
124
|
+
"Delete stale commented code; git history is the source of old implementations.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (/\b(any|TODO|FIXME|HACK|XXX)\b/.test(line)) {
|
|
129
|
+
push(findings, {
|
|
130
|
+
id: "loose-or-temporary-marker",
|
|
131
|
+
title: "Loose type or temporary marker",
|
|
132
|
+
category: "maintainability",
|
|
133
|
+
severity: line.includes("any") ? "medium" : "low",
|
|
134
|
+
file: file.relativePath,
|
|
135
|
+
lines: String(lineNo),
|
|
136
|
+
evidence: raw.trim(),
|
|
137
|
+
recommendation:
|
|
138
|
+
"Replace temporary markers with a tracked issue or tighten the type/implementation now.",
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (/\b(if|for|while|catch|case|&&|\|\|)\b/.test(line)) currentFunctionComplexity += 1;
|
|
143
|
+
if (/\b(function|async function)\b|=>\s*\{/.test(line) && functionStart === null) {
|
|
144
|
+
functionStart = lineNo;
|
|
145
|
+
currentFunctionComplexity = 1;
|
|
146
|
+
functionBraceDepth = 0;
|
|
147
|
+
}
|
|
148
|
+
if (functionStart !== null) {
|
|
149
|
+
functionBraceDepth += (raw.match(/\{/g) ?? []).length;
|
|
150
|
+
functionBraceDepth -= (raw.match(/\}/g) ?? []).length;
|
|
151
|
+
if (functionBraceDepth <= 0 && lineNo > functionStart) {
|
|
152
|
+
if (lineNo - functionStart > 80 || currentFunctionComplexity > 12) {
|
|
153
|
+
push(findings, {
|
|
154
|
+
id: "large-complex-function",
|
|
155
|
+
title: "Large or complex function",
|
|
156
|
+
category: "complexity",
|
|
157
|
+
severity:
|
|
158
|
+
currentFunctionComplexity > 18 || lineNo - functionStart > 140 ? "high" : "medium",
|
|
159
|
+
file: file.relativePath,
|
|
160
|
+
lines: lineRange(functionStart, lineNo),
|
|
161
|
+
evidence: `${lineNo - functionStart + 1} lines, approximate branch count ${currentFunctionComplexity}`,
|
|
162
|
+
recommendation:
|
|
163
|
+
"Split orchestration from decisions; extract named helpers only around stable concepts, not arbitrary line chunks.",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
functionStart = null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
for (const [line, occurrences] of counts) {
|
|
172
|
+
if (occurrences.length < 3) continue;
|
|
173
|
+
push(findings, {
|
|
174
|
+
id: "repeated-line",
|
|
175
|
+
title: "Repeated implementation line",
|
|
176
|
+
category: "duplication",
|
|
177
|
+
severity: occurrences.length >= 6 ? "medium" : "low",
|
|
178
|
+
file: file.relativePath,
|
|
179
|
+
lines: occurrences.slice(0, 6).join(","),
|
|
180
|
+
evidence: line,
|
|
181
|
+
recommendation:
|
|
182
|
+
"Check whether these repeated lines represent copy-paste logic that should share a helper or table-driven representation.",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return findings;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function runProgrammaticChecks(options: ScanOptions): Promise<ProgrammaticCheck[]> {
|
|
190
|
+
const maxFiles = options.maxFiles ?? 300;
|
|
191
|
+
const targets = options.paths.length > 0 ? options.paths : ["."];
|
|
192
|
+
const files: string[] = [];
|
|
193
|
+
for (const target of targets) await collectFiles(options.cwd, target, files, maxFiles);
|
|
194
|
+
|
|
195
|
+
const findings: ProgrammaticCheck[] = [];
|
|
196
|
+
for (const absolutePath of files) {
|
|
197
|
+
const text = await readFile(absolutePath, "utf8").catch(() => "");
|
|
198
|
+
if (!text) continue;
|
|
199
|
+
findings.push(
|
|
200
|
+
...scanFile({
|
|
201
|
+
absolutePath,
|
|
202
|
+
relativePath: path.relative(options.cwd, absolutePath),
|
|
203
|
+
text,
|
|
204
|
+
lines: text.split(/\r?\n/),
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return findings;
|
|
210
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const CODE_SMELL_PROMPT = `# Code Smell: Find and Clean Structural Problems
|
|
2
|
+
|
|
3
|
+
You are a code smell reviewer. Find code that is likely to become costly to maintain, then propose small, behavior-preserving cleanups.
|
|
4
|
+
|
|
5
|
+
Match the user's language for all candidate fields; if the user's request is in Chinese, write \`smell\`, \`evidence\`, \`impact\`, and \`recommendation\` in Chinese.
|
|
6
|
+
|
|
7
|
+
## Two Analysis Tracks
|
|
8
|
+
|
|
9
|
+
### 1. Programmatic Checks
|
|
10
|
+
|
|
11
|
+
First call \`code_smell_scan\` for the requested paths (or the repository root if no paths are specified). Treat these results as leads, not truth. Verify each important finding by reading the relevant file before returning it.
|
|
12
|
+
|
|
13
|
+
### 2. Agent/Skill Review
|
|
14
|
+
|
|
15
|
+
Then inspect code using these smell families:
|
|
16
|
+
|
|
17
|
+
- **Complexity**: long functions, mixed responsibilities, deep nesting, boolean flag control flow, branch-heavy conditionals
|
|
18
|
+
- **Duplication**: copy-paste blocks, repeated condition chains, parallel representations of the same concept
|
|
19
|
+
- **Coupling**: modules reaching through boundaries, feature code importing internals, circular-feeling dependencies
|
|
20
|
+
- **State**: redundant derived state, mutable globals, caches without invalidation, multiple sources of truth
|
|
21
|
+
- **Errors**: swallowed errors, empty catch blocks, broad catch that hides failures, inconsistent error mapping
|
|
22
|
+
- **Performance**: N+1 loops, repeated I/O, sequential awaits for independent work, hot-path blocking work
|
|
23
|
+
- **Maintainability**: debug remnants, commented-out code, TODO/HACK that hides required design, stringly-typed constants
|
|
24
|
+
|
|
25
|
+
## Return Only Actionable Smells
|
|
26
|
+
|
|
27
|
+
Every returned smell must include a real cause-effect chain:
|
|
28
|
+
|
|
29
|
+
1. \`smell\` — the root structural problem in the current code.
|
|
30
|
+
2. \`evidence\` — the concrete code evidence: symbol, repeated pattern, line, or tool finding.
|
|
31
|
+
3. \`impact\` — what gets worse if left unchanged: divergent behavior, missed errors, repeated edits, N+1 calls, test brittleness.
|
|
32
|
+
4. \`recommendation\` — a small, behavior-preserving next step.
|
|
33
|
+
|
|
34
|
+
Do not flag subjective style issues. Do not recommend large rewrites unless a small first slice is clear.
|
|
35
|
+
|
|
36
|
+
## Severity and Confidence
|
|
37
|
+
|
|
38
|
+
- \`severity\`: \`high\` if likely bug/perf/user impact, \`medium\` if maintenance cost is concrete, \`low\` if cleanup is useful but not urgent.
|
|
39
|
+
- \`confidence\`: \`high\` only after reading enough code to verify the smell; \`medium\` for strong tool-led hints; \`low\` only when the item is worth human inspection.
|
|
40
|
+
|
|
41
|
+
## Output Format (REQUIRED)
|
|
42
|
+
|
|
43
|
+
When analysis is complete, call the \`code_smell_findings\` tool exactly once as your final action. Put ALL findings in that tool call.
|
|
44
|
+
|
|
45
|
+
Fields:
|
|
46
|
+
- \`category\`: one of \`complexity\`, \`duplication\`, \`coupling\`, \`state\`, \`errors\`, \`performance\`, \`maintainability\`
|
|
47
|
+
- \`severity\`: one of \`low\`, \`medium\`, \`high\`
|
|
48
|
+
- \`confidence\`: one of \`low\`, \`medium\`, \`high\`
|
|
49
|
+
- \`file\`: repository-relative path
|
|
50
|
+
- \`lines\`: line number/range or empty string
|
|
51
|
+
- \`smell\`: root problem
|
|
52
|
+
- \`evidence\`: concrete proof
|
|
53
|
+
- \`impact\`: real consequence if unchanged
|
|
54
|
+
- \`recommendation\`: small fix
|
|
55
|
+
- \`action\`: one of \`inspect\`, \`delete\`, \`inline\`, \`extract\`, \`refactor\`, \`guard\`
|
|
56
|
+
|
|
57
|
+
If there are no findings, call \`code_smell_findings\` with an empty \`findings\` array.
|
|
58
|
+
Do NOT write a prose-only final answer; the extension relies on the tool result.
|
|
59
|
+
`;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSelectListTheme,
|
|
3
|
+
keyHint,
|
|
4
|
+
rawKeyHint,
|
|
5
|
+
type ExtensionCommandContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { matchesKey, SelectList, wrapTextWithAnsi, type SelectItem } from "@earendil-works/pi-tui";
|
|
8
|
+
|
|
9
|
+
import type { CodeSmellFinding, SmellAction } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const SEVERITY_CONFIG = {
|
|
12
|
+
high: { label: "High severity" },
|
|
13
|
+
medium: { label: "Medium severity" },
|
|
14
|
+
low: { label: "Low severity" },
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const ACTION_LABEL: Record<SmellAction, string> = {
|
|
18
|
+
inspect: "Inspect manually",
|
|
19
|
+
delete: "Delete code",
|
|
20
|
+
inline: "Inline code",
|
|
21
|
+
extract: "Extract helper",
|
|
22
|
+
refactor: "Refactor",
|
|
23
|
+
guard: "Add guard/check",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function showFindingSelector(
|
|
27
|
+
ctx: ExtensionCommandContext,
|
|
28
|
+
findings: CodeSmellFinding[],
|
|
29
|
+
): Promise<CodeSmellFinding[]> {
|
|
30
|
+
if (findings.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const sorted = [...findings].sort((a, b) => {
|
|
33
|
+
const rank = { high: 0, medium: 1, low: 2 } as const;
|
|
34
|
+
return rank[a.severity] - rank[b.severity];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const listItems: SelectItem[] = sorted.map((finding, index) => ({
|
|
38
|
+
value: String(index),
|
|
39
|
+
label: "",
|
|
40
|
+
description: `${SEVERITY_CONFIG[finding.severity].label} · ${finding.confidence} confidence · ${ACTION_LABEL[finding.action]}`,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const selectedSet = new Set<number>();
|
|
44
|
+
sorted.forEach((finding, index) => {
|
|
45
|
+
if (finding.severity === "high" && finding.confidence !== "low") selectedSet.add(index);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const maxVisible = 8;
|
|
49
|
+
let cursorPos = 0;
|
|
50
|
+
const getStartIndex = (selectedIndex: number) =>
|
|
51
|
+
Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), sorted.length - maxVisible));
|
|
52
|
+
|
|
53
|
+
const result = await ctx.ui.custom<CodeSmellFinding[]>((tui, theme, keybindings, done) => {
|
|
54
|
+
const updateListLabel = (index: number) => {
|
|
55
|
+
const item = listItems[index];
|
|
56
|
+
const finding = sorted[index];
|
|
57
|
+
const checkbox = selectedSet.has(index) ? "[x]" : "[ ]";
|
|
58
|
+
const location = finding.lines ? `${finding.file}:${finding.lines}` : finding.file;
|
|
59
|
+
item.label = `${checkbox} ${location}`;
|
|
60
|
+
};
|
|
61
|
+
const updateAll = () => listItems.forEach((_item, index) => updateListLabel(index));
|
|
62
|
+
updateAll();
|
|
63
|
+
|
|
64
|
+
const selectList = new SelectList(listItems, maxVisible, getSelectListTheme());
|
|
65
|
+
selectList.onSelectionChange = (item) => {
|
|
66
|
+
cursorPos = Number(item.value);
|
|
67
|
+
};
|
|
68
|
+
selectList.onCancel = () => done([]);
|
|
69
|
+
|
|
70
|
+
const toggleCurrent = () => {
|
|
71
|
+
if (selectedSet.has(cursorPos)) selectedSet.delete(cursorPos);
|
|
72
|
+
else selectedSet.add(cursorPos);
|
|
73
|
+
updateListLabel(cursorPos);
|
|
74
|
+
tui.requestRender();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const toggleAll = () => {
|
|
78
|
+
if (selectedSet.size === sorted.length) selectedSet.clear();
|
|
79
|
+
else for (let i = 0; i < sorted.length; i++) selectedSet.add(i);
|
|
80
|
+
updateAll();
|
|
81
|
+
tui.requestRender();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
render(width: number) {
|
|
86
|
+
selectList.setSelectedIndex(cursorPos);
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
lines.push(theme.bold("Code Smell: Select findings to clean"));
|
|
89
|
+
lines.push(theme.fg("muted", `Found ${findings.length} findings`));
|
|
90
|
+
lines.push("");
|
|
91
|
+
|
|
92
|
+
const listLines = selectList.render(width);
|
|
93
|
+
const selectedLineIndex = cursorPos - getStartIndex(cursorPos);
|
|
94
|
+
const current = sorted[cursorPos];
|
|
95
|
+
for (const [lineIndex, line] of listLines.entries()) {
|
|
96
|
+
lines.push(line);
|
|
97
|
+
if (lineIndex !== selectedLineIndex || !current) continue;
|
|
98
|
+
|
|
99
|
+
const detailIndent = " ";
|
|
100
|
+
const detailPrefix = `${detailIndent}│ `;
|
|
101
|
+
const wrapWidth = Math.max(1, width - detailPrefix.length);
|
|
102
|
+
const pushSection = (heading: string, body: string) => {
|
|
103
|
+
if (!body) return;
|
|
104
|
+
lines.push(theme.fg("borderMuted", `${detailIndent}┌─ ${heading}`));
|
|
105
|
+
lines.push(
|
|
106
|
+
...wrapTextWithAnsi(body, wrapWidth).map((wrappedLine) =>
|
|
107
|
+
theme.fg("muted", `${detailPrefix}${wrappedLine}`),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
pushSection("Smell", current.smell);
|
|
113
|
+
pushSection("Evidence", current.evidence);
|
|
114
|
+
pushSection("Impact", current.impact);
|
|
115
|
+
pushSection("Recommendation", current.recommendation);
|
|
116
|
+
lines.push(theme.fg("muted", `${detailPrefix}Category: ${current.category}`));
|
|
117
|
+
lines.push(theme.fg("muted", `${detailPrefix}Action: ${ACTION_LABEL[current.action]}`));
|
|
118
|
+
lines.push(
|
|
119
|
+
theme.fg("borderMuted", `${detailIndent}└─ ${rawKeyHint("space", "selects this fix")}`),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(
|
|
125
|
+
theme.fg(
|
|
126
|
+
"muted",
|
|
127
|
+
[
|
|
128
|
+
`Selected: ${selectedSet.size}`,
|
|
129
|
+
rawKeyHint("↑↓", "navigate"),
|
|
130
|
+
rawKeyHint("space", "toggle"),
|
|
131
|
+
rawKeyHint("a", "select all"),
|
|
132
|
+
keyHint("tui.select.confirm", "confirm"),
|
|
133
|
+
keyHint("tui.select.cancel", "cancel"),
|
|
134
|
+
].join(" • "),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
return lines;
|
|
138
|
+
},
|
|
139
|
+
invalidate() {
|
|
140
|
+
selectList.invalidate();
|
|
141
|
+
},
|
|
142
|
+
handleInput(data: string) {
|
|
143
|
+
if (keybindings.matches(data, "tui.select.confirm")) {
|
|
144
|
+
return done(Array.from(selectedSet).map((i) => sorted[i]));
|
|
145
|
+
}
|
|
146
|
+
if (keybindings.matches(data, "tui.select.cancel")) return done([]);
|
|
147
|
+
if (matchesKey(data, "space")) return toggleCurrent();
|
|
148
|
+
if (matchesKey(data, "a")) return toggleAll();
|
|
149
|
+
const previous = cursorPos;
|
|
150
|
+
selectList.handleInput(data);
|
|
151
|
+
if (cursorPos !== previous) tui.requestRender();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return result ?? [];
|
|
157
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type SmellCategory =
|
|
2
|
+
| "complexity"
|
|
3
|
+
| "duplication"
|
|
4
|
+
| "coupling"
|
|
5
|
+
| "state"
|
|
6
|
+
| "errors"
|
|
7
|
+
| "performance"
|
|
8
|
+
| "maintainability";
|
|
9
|
+
|
|
10
|
+
export type SmellSeverity = "low" | "medium" | "high";
|
|
11
|
+
export type SmellConfidence = "low" | "medium" | "high";
|
|
12
|
+
export type SmellAction = "inspect" | "delete" | "inline" | "extract" | "refactor" | "guard";
|
|
13
|
+
|
|
14
|
+
export type CodeSmellFinding = {
|
|
15
|
+
category: SmellCategory;
|
|
16
|
+
severity: SmellSeverity;
|
|
17
|
+
confidence: SmellConfidence;
|
|
18
|
+
file: string;
|
|
19
|
+
lines: string;
|
|
20
|
+
smell: string;
|
|
21
|
+
evidence: string;
|
|
22
|
+
impact: string;
|
|
23
|
+
recommendation: string;
|
|
24
|
+
action: SmellAction;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ProgrammaticCheck = {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
category: SmellCategory;
|
|
31
|
+
severity: SmellSeverity;
|
|
32
|
+
file: string;
|
|
33
|
+
lines: string;
|
|
34
|
+
evidence: string;
|
|
35
|
+
recommendation: string;
|
|
36
|
+
};
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineTool,
|
|
3
|
+
DynamicBorder,
|
|
4
|
+
getSelectListTheme,
|
|
5
|
+
keyHint,
|
|
6
|
+
type ExtensionAPI,
|
|
7
|
+
type ExtensionCommandContext,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Container, SelectList, Text, type SelectItem } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Type } from "typebox";
|
|
11
|
+
|
|
12
|
+
import { runProgrammaticChecks } from "./checks.js";
|
|
13
|
+
import { CODE_SMELL_PROMPT } from "./prompt.js";
|
|
14
|
+
import { showFindingSelector } from "./selector.js";
|
|
15
|
+
import type { CodeSmellFinding } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const categorySchema = Type.Union([
|
|
18
|
+
Type.Literal("complexity"),
|
|
19
|
+
Type.Literal("duplication"),
|
|
20
|
+
Type.Literal("coupling"),
|
|
21
|
+
Type.Literal("state"),
|
|
22
|
+
Type.Literal("errors"),
|
|
23
|
+
Type.Literal("performance"),
|
|
24
|
+
Type.Literal("maintainability"),
|
|
25
|
+
]);
|
|
26
|
+
const severitySchema = Type.Union([
|
|
27
|
+
Type.Literal("low"),
|
|
28
|
+
Type.Literal("medium"),
|
|
29
|
+
Type.Literal("high"),
|
|
30
|
+
]);
|
|
31
|
+
const confidenceSchema = Type.Union([
|
|
32
|
+
Type.Literal("low"),
|
|
33
|
+
Type.Literal("medium"),
|
|
34
|
+
Type.Literal("high"),
|
|
35
|
+
]);
|
|
36
|
+
const actionSchema = Type.Union([
|
|
37
|
+
Type.Literal("inspect"),
|
|
38
|
+
Type.Literal("delete"),
|
|
39
|
+
Type.Literal("inline"),
|
|
40
|
+
Type.Literal("extract"),
|
|
41
|
+
Type.Literal("refactor"),
|
|
42
|
+
Type.Literal("guard"),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
type Target = { paths: string[] };
|
|
46
|
+
|
|
47
|
+
function parsePaths(value: string): string[] {
|
|
48
|
+
return value
|
|
49
|
+
.split(/\s+/)
|
|
50
|
+
.map((item) => item.trim())
|
|
51
|
+
.filter((item) => item.length > 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function showPathInput(ctx: ExtensionCommandContext): Promise<Target | null> {
|
|
55
|
+
const result = await ctx.ui.editor(
|
|
56
|
+
"Enter folders/files to scan (space-separated or one per line):",
|
|
57
|
+
".",
|
|
58
|
+
);
|
|
59
|
+
const paths = parsePaths(result ?? "");
|
|
60
|
+
return paths.length > 0 ? { paths } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function showTargetSelector(ctx: ExtensionCommandContext): Promise<Target | null> {
|
|
64
|
+
const items: SelectItem[] = [
|
|
65
|
+
{ value: "repo", label: "Scan repository", description: "Programmatic checks + agent review" },
|
|
66
|
+
{ value: "paths", label: "Scan selected paths", description: "Folders/files only" },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const result = await ctx.ui.custom<"repo" | "paths" | null>((tui, theme, _kb, done) => {
|
|
70
|
+
const container = new Container();
|
|
71
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
72
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select code smell scope"))));
|
|
73
|
+
const selectList = new SelectList(items, 6, getSelectListTheme());
|
|
74
|
+
selectList.onSelect = (item) => done(item.value as "repo" | "paths");
|
|
75
|
+
selectList.onCancel = () => done(null);
|
|
76
|
+
container.addChild(selectList);
|
|
77
|
+
container.addChild(
|
|
78
|
+
new Text(
|
|
79
|
+
`${keyHint("tui.select.confirm", "confirm")} ${keyHint("tui.select.cancel", "cancel")}`,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
83
|
+
return {
|
|
84
|
+
render(width: number) {
|
|
85
|
+
return container.render(width);
|
|
86
|
+
},
|
|
87
|
+
invalidate() {
|
|
88
|
+
container.invalidate();
|
|
89
|
+
},
|
|
90
|
+
handleInput(data: string) {
|
|
91
|
+
selectList.handleInput(data);
|
|
92
|
+
tui.requestRender();
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!result) return null;
|
|
98
|
+
if (result === "repo") return { paths: ["."] };
|
|
99
|
+
return showPathInput(ctx);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function registerScanTool(pi: ExtensionAPI) {
|
|
103
|
+
pi.registerTool(
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "code_smell_scan",
|
|
106
|
+
label: "Code Smell Scan",
|
|
107
|
+
description: "Run programmatic code smell checks over repository paths.",
|
|
108
|
+
promptSnippet:
|
|
109
|
+
"Run deterministic checks for debug remnants, commented-out code, duplication, and complex functions",
|
|
110
|
+
promptGuidelines: [
|
|
111
|
+
"Use code_smell_scan before manual code smell review, then verify important findings by reading files.",
|
|
112
|
+
],
|
|
113
|
+
parameters: Type.Object({
|
|
114
|
+
paths: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
115
|
+
maxFiles: Type.Optional(Type.Number({ minimum: 1, maximum: 1000 })),
|
|
116
|
+
}),
|
|
117
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
118
|
+
const findings = await runProgrammaticChecks({
|
|
119
|
+
cwd: ctx.cwd,
|
|
120
|
+
paths: params.paths ?? ["."],
|
|
121
|
+
maxFiles: params.maxFiles,
|
|
122
|
+
});
|
|
123
|
+
const bySeverity = {
|
|
124
|
+
high: findings.filter((f) => f.severity === "high").length,
|
|
125
|
+
medium: findings.filter((f) => f.severity === "medium").length,
|
|
126
|
+
low: findings.filter((f) => f.severity === "low").length,
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: "text" as const,
|
|
132
|
+
text: `Found ${findings.length} programmatic code-smell leads.`,
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
details: { findings, bySeverity },
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function registerFindingsTool(
|
|
143
|
+
pi: ExtensionAPI,
|
|
144
|
+
state: {
|
|
145
|
+
getResolver: () => ((findings: CodeSmellFinding[]) => void) | null;
|
|
146
|
+
clearResolver: () => void;
|
|
147
|
+
setLatestFindings: (findings: CodeSmellFinding[]) => void;
|
|
148
|
+
},
|
|
149
|
+
) {
|
|
150
|
+
pi.registerTool(
|
|
151
|
+
defineTool({
|
|
152
|
+
name: "code_smell_findings",
|
|
153
|
+
label: "Code Smell Findings",
|
|
154
|
+
description:
|
|
155
|
+
"Return the complete finding list for /code-smell. Use as the final action after analysis.",
|
|
156
|
+
promptSnippet:
|
|
157
|
+
"Return structured code smell findings for /code-smell as a terminating result",
|
|
158
|
+
promptGuidelines: [
|
|
159
|
+
"Use code_smell_findings exactly once as the final action when the /code-smell command asks for findings.",
|
|
160
|
+
],
|
|
161
|
+
parameters: Type.Object({
|
|
162
|
+
findings: Type.Array(
|
|
163
|
+
Type.Object({
|
|
164
|
+
category: categorySchema,
|
|
165
|
+
severity: severitySchema,
|
|
166
|
+
confidence: confidenceSchema,
|
|
167
|
+
file: Type.String({ minLength: 1, pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$" }),
|
|
168
|
+
lines: Type.String(),
|
|
169
|
+
smell: Type.String({ minLength: 1 }),
|
|
170
|
+
evidence: Type.String({ minLength: 1 }),
|
|
171
|
+
impact: Type.String({ minLength: 1 }),
|
|
172
|
+
recommendation: Type.String({ minLength: 1 }),
|
|
173
|
+
action: actionSchema,
|
|
174
|
+
}),
|
|
175
|
+
),
|
|
176
|
+
}),
|
|
177
|
+
async execute(_toolCallId, params) {
|
|
178
|
+
const findings: CodeSmellFinding[] = params.findings;
|
|
179
|
+
const resolve = state.getResolver();
|
|
180
|
+
if (!resolve) {
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text" as const,
|
|
185
|
+
text: "Ignored code smell findings because no /code-smell analysis is pending.",
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
details: { ignored: true, findings: [] },
|
|
189
|
+
terminate: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
state.setLatestFindings(findings);
|
|
193
|
+
state.clearResolver();
|
|
194
|
+
resolve(findings);
|
|
195
|
+
return {
|
|
196
|
+
content: [
|
|
197
|
+
{ type: "text" as const, text: `Captured ${findings.length} code smell findings.` },
|
|
198
|
+
],
|
|
199
|
+
details: { ignored: false, findings },
|
|
200
|
+
terminate: true,
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function buildPrompt(target: Target, focus?: string): string {
|
|
208
|
+
let prompt = CODE_SMELL_PROMPT;
|
|
209
|
+
const isRepoRoot = target.paths.length === 1 && target.paths[0] === ".";
|
|
210
|
+
if (!isRepoRoot)
|
|
211
|
+
prompt += `\n\n## Scope\n\nReview only these repository-relative paths: ${target.paths.join(", ")}.`;
|
|
212
|
+
if (focus?.trim()) prompt += `\n\n## Additional Focus\n\n${focus.trim()}`;
|
|
213
|
+
return prompt;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function applyFindings(
|
|
217
|
+
ctx: ExtensionCommandContext,
|
|
218
|
+
selected: CodeSmellFinding[],
|
|
219
|
+
pi: ExtensionAPI,
|
|
220
|
+
) {
|
|
221
|
+
if (selected.length === 0) {
|
|
222
|
+
ctx.ui.notify("No code smell findings selected", "info");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const prompt = `# Clean Selected Code Smells\n\nApply small, behavior-preserving fixes for these selected findings:\n\n${selected
|
|
227
|
+
.map(
|
|
228
|
+
(f) =>
|
|
229
|
+
`- ${f.file} (${f.lines || "?"}) [${f.action}] ${f.category}/${f.severity}\n - Smell: ${f.smell}\n - Evidence: ${f.evidence}\n - Recommendation: ${f.recommendation}`,
|
|
230
|
+
)
|
|
231
|
+
.join(
|
|
232
|
+
"\n",
|
|
233
|
+
)}\n\nRules:\n1. Read each file before editing.\n2. Keep changes minimal and behavior-preserving.\n3. Skip any item whose fix is ambiguous and report why.\n4. Run relevant tests/type-check/lint after changes.\n5. Report changed files and verification output.`;
|
|
234
|
+
|
|
235
|
+
ctx.ui.notify(`Applying ${selected.length} selected code smell fixes`, "info");
|
|
236
|
+
pi.sendUserMessage(prompt);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function registerCodeSmellWorkflow(pi: ExtensionAPI) {
|
|
240
|
+
let resolvePendingTurnStart: (() => void) | null = null;
|
|
241
|
+
let resolvePendingFindingsTool: ((findings: CodeSmellFinding[]) => void) | null = null;
|
|
242
|
+
let latestToolFindings: CodeSmellFinding[] | null = null;
|
|
243
|
+
|
|
244
|
+
pi.on("turn_start", () => {
|
|
245
|
+
if (!resolvePendingTurnStart) return;
|
|
246
|
+
const resolve = resolvePendingTurnStart;
|
|
247
|
+
resolvePendingTurnStart = null;
|
|
248
|
+
resolve();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
registerScanTool(pi);
|
|
252
|
+
registerFindingsTool(pi, {
|
|
253
|
+
getResolver: () => resolvePendingFindingsTool,
|
|
254
|
+
clearResolver: () => {
|
|
255
|
+
resolvePendingFindingsTool = null;
|
|
256
|
+
},
|
|
257
|
+
setLatestFindings: (findings) => {
|
|
258
|
+
latestToolFindings = findings;
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
pi.registerCommand("code-smell", {
|
|
263
|
+
description:
|
|
264
|
+
"Find code smells with programmatic checks and agent review, then optionally clean them",
|
|
265
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
266
|
+
if (!ctx.hasUI) {
|
|
267
|
+
ctx.ui.notify("Code smell workflow requires interactive mode", "error");
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (!ctx.isIdle()) {
|
|
271
|
+
ctx.ui.notify("Cannot run code-smell while agent is busy", "warning");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const paths = parsePaths(args);
|
|
276
|
+
let target: Target | null = paths.length > 0 ? { paths } : null;
|
|
277
|
+
if (!target) target = await showTargetSelector(ctx);
|
|
278
|
+
if (!target) {
|
|
279
|
+
ctx.ui.notify("Code smell scan cancelled", "info");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
latestToolFindings = null;
|
|
284
|
+
const turnStarted = new Promise<void>((r) => {
|
|
285
|
+
resolvePendingTurnStart = r;
|
|
286
|
+
});
|
|
287
|
+
const toolResult = new Promise<CodeSmellFinding[]>((r) => {
|
|
288
|
+
resolvePendingFindingsTool = r;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
ctx.ui.notify(`Analyzing code smells in ${target.paths.join(", ")}`, "info");
|
|
292
|
+
pi.sendUserMessage(buildPrompt(target));
|
|
293
|
+
|
|
294
|
+
const started = await Promise.race<boolean>([
|
|
295
|
+
turnStarted.then(() => true),
|
|
296
|
+
new Promise<boolean>((r) => setTimeout(() => r(false), 10000)),
|
|
297
|
+
]);
|
|
298
|
+
if (!started) {
|
|
299
|
+
resolvePendingTurnStart = null;
|
|
300
|
+
resolvePendingFindingsTool = null;
|
|
301
|
+
ctx.ui.notify("Analysis did not start within 10 seconds", "warning");
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await ctx.waitForIdle();
|
|
306
|
+
const findings =
|
|
307
|
+
latestToolFindings ??
|
|
308
|
+
(await Promise.race<CodeSmellFinding[] | null>([
|
|
309
|
+
toolResult,
|
|
310
|
+
new Promise<null>((r) => setTimeout(() => r(null), 100)),
|
|
311
|
+
]));
|
|
312
|
+
resolvePendingFindingsTool = null;
|
|
313
|
+
|
|
314
|
+
if (findings === null) {
|
|
315
|
+
ctx.ui.notify(
|
|
316
|
+
"Could not read findings — model did not call code_smell_findings",
|
|
317
|
+
"warning",
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (findings.length === 0) {
|
|
322
|
+
ctx.ui.notify("No code smells found", "info");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const selected = await showFindingSelector(ctx, findings);
|
|
327
|
+
if (!selected || selected.length === 0) {
|
|
328
|
+
ctx.ui.notify("Code smell cleanup cancelled", "info");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
await applyFindings(ctx, selected, pi);
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
@@ -29,7 +29,10 @@ const actionSchema = Type.Union([
|
|
|
29
29
|
Type.Literal("parallelize"),
|
|
30
30
|
]);
|
|
31
31
|
|
|
32
|
-
type SimplifyTarget =
|
|
32
|
+
type SimplifyTarget =
|
|
33
|
+
| { type: "uncommitted" }
|
|
34
|
+
| { type: "previous-commit" }
|
|
35
|
+
| { type: "folder"; paths: string[] };
|
|
33
36
|
type ParsedArgs = {
|
|
34
37
|
target: SimplifyTarget | null;
|
|
35
38
|
additionalFocus?: string;
|
|
@@ -38,6 +41,11 @@ type ParsedArgs = {
|
|
|
38
41
|
|
|
39
42
|
const SIMPLIFY_PRESETS = [
|
|
40
43
|
{ value: "uncommitted", label: "Simplify uncommitted changes", description: "" },
|
|
44
|
+
{
|
|
45
|
+
value: "previous-commit",
|
|
46
|
+
label: "Simplify previous commit",
|
|
47
|
+
description: "(diff: HEAD~1..HEAD)",
|
|
48
|
+
},
|
|
41
49
|
{ value: "folder", label: "Simplify a folder (or more)", description: "(snapshot, not diff)" },
|
|
42
50
|
] as const;
|
|
43
51
|
|
|
@@ -55,14 +63,25 @@ function parseArgs(args: string): ParsedArgs {
|
|
|
55
63
|
if (!trimmed) return { target: null };
|
|
56
64
|
|
|
57
65
|
const [subcommand, ...rest] = trimmed.split(/\s+/);
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
const normalizedSubcommand = subcommand?.toLowerCase();
|
|
67
|
+
|
|
68
|
+
if (normalizedSubcommand === "folder") {
|
|
69
|
+
const paths = parseSimplifyPaths(rest.join(" "));
|
|
70
|
+
if (paths.length === 0)
|
|
71
|
+
return { target: null, error: "Usage: /simplify folder <path> [path...]" };
|
|
72
|
+
return { target: { type: "folder", paths } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
normalizedSubcommand === "previous" ||
|
|
77
|
+
normalizedSubcommand === "previous-commit" ||
|
|
78
|
+
normalizedSubcommand === "prev" ||
|
|
79
|
+
normalizedSubcommand === "last-commit"
|
|
80
|
+
) {
|
|
81
|
+
return { target: { type: "previous-commit" }, additionalFocus: rest.join(" ") || undefined };
|
|
60
82
|
}
|
|
61
83
|
|
|
62
|
-
|
|
63
|
-
if (paths.length === 0)
|
|
64
|
-
return { target: null, error: "Usage: /simplify folder <path> [path...]" };
|
|
65
|
-
return { target: { type: "folder", paths } };
|
|
84
|
+
return { target: { type: "uncommitted" }, additionalFocus: trimmed };
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
@@ -70,6 +89,11 @@ async function hasUncommittedChanges(pi: ExtensionAPI): Promise<boolean> {
|
|
|
70
89
|
return code === 0 && stdout.trim().length > 0;
|
|
71
90
|
}
|
|
72
91
|
|
|
92
|
+
async function hasPreviousCommit(pi: ExtensionAPI): Promise<boolean> {
|
|
93
|
+
const { code } = await pi.exec("git", ["rev-parse", "--verify", "HEAD~1"]);
|
|
94
|
+
return code === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
function buildSimplifyPrompt(target: SimplifyTarget, additionalFocus?: string): string {
|
|
74
98
|
let fullPrompt = SIMPLIFY_PROMPT;
|
|
75
99
|
|
|
@@ -77,6 +101,11 @@ function buildSimplifyPrompt(target: SimplifyTarget, additionalFocus?: string):
|
|
|
77
101
|
fullPrompt += `\n\n## Scope Override: Folder Snapshot\n\nReview only the code in these repository-relative paths: ${target.paths.join(", ")}\n\nThis is a snapshot review, not a git diff review. Read files directly under these paths instead of relying on \`git diff\`. Only return candidates whose \`file\` is inside one of these paths.`;
|
|
78
102
|
}
|
|
79
103
|
|
|
104
|
+
if (target.type === "previous-commit") {
|
|
105
|
+
fullPrompt +=
|
|
106
|
+
"\n\n## Scope Override: Previous Commit\n\nReview the previous commit only. Run `git diff HEAD~1..HEAD` to see what changed in the last commit, and treat that diff as the review scope. Do not use uncommitted changes as the analysis target. Only return candidates caused by the previous commit's diff.";
|
|
107
|
+
}
|
|
108
|
+
|
|
80
109
|
if (additionalFocus?.trim()) fullPrompt += `\n\n## Additional Focus\n\n${additionalFocus.trim()}`;
|
|
81
110
|
return fullPrompt;
|
|
82
111
|
}
|
|
@@ -243,6 +272,7 @@ async function showSimplifyTargetSelector(
|
|
|
243
272
|
|
|
244
273
|
if (!result) return null;
|
|
245
274
|
if (result === "uncommitted") return { type: "uncommitted" };
|
|
275
|
+
if (result === "previous-commit") return { type: "previous-commit" };
|
|
246
276
|
|
|
247
277
|
const target = await showFolderInput(ctx);
|
|
248
278
|
if (target) return target;
|
|
@@ -354,8 +384,20 @@ export function registerSimplifyWorkflow(pi: ExtensionAPI) {
|
|
|
354
384
|
return;
|
|
355
385
|
}
|
|
356
386
|
|
|
387
|
+
if (target.type === "previous-commit" && !(await hasPreviousCommit(pi))) {
|
|
388
|
+
ctx.ui.notify(
|
|
389
|
+
"No previous commit found. Need at least two commits to use this mode.",
|
|
390
|
+
"info",
|
|
391
|
+
);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
357
395
|
const targetHint =
|
|
358
|
-
target.type === "folder"
|
|
396
|
+
target.type === "folder"
|
|
397
|
+
? `folders: ${target.paths.join(", ")}`
|
|
398
|
+
: target.type === "previous-commit"
|
|
399
|
+
? "previous commit"
|
|
400
|
+
: "uncommitted changes";
|
|
359
401
|
ctx.ui.notify(`Analyzing ${targetHint} for review findings...`, "info");
|
|
360
402
|
|
|
361
403
|
const fullPrompt = buildSimplifyPrompt(target, parsed.additionalFocus);
|
package/tsconfig.json
CHANGED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
name: CI / Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
push:
|
|
7
|
-
branches: [main]
|
|
8
|
-
workflow_dispatch:
|
|
9
|
-
|
|
10
|
-
permissions:
|
|
11
|
-
id-token: write
|
|
12
|
-
contents: read
|
|
13
|
-
|
|
14
|
-
jobs:
|
|
15
|
-
ci:
|
|
16
|
-
runs-on: ubuntu-latest
|
|
17
|
-
steps:
|
|
18
|
-
- uses: actions/checkout@v4
|
|
19
|
-
|
|
20
|
-
- uses: actions/setup-node@v4
|
|
21
|
-
with:
|
|
22
|
-
node-version: "24"
|
|
23
|
-
registry-url: "https://registry.npmjs.org"
|
|
24
|
-
|
|
25
|
-
- name: Install dependencies
|
|
26
|
-
run: npm ci
|
|
27
|
-
|
|
28
|
-
- name: Type check
|
|
29
|
-
run: npx tsc --noEmit --skipLibCheck || echo "No TypeScript, skipping type check"
|
|
30
|
-
|
|
31
|
-
publish:
|
|
32
|
-
needs: ci
|
|
33
|
-
runs-on: ubuntu-latest
|
|
34
|
-
if: github.event_name == 'release'
|
|
35
|
-
steps:
|
|
36
|
-
- uses: actions/checkout@v4
|
|
37
|
-
|
|
38
|
-
- uses: actions/setup-node@v4
|
|
39
|
-
with:
|
|
40
|
-
node-version: "24"
|
|
41
|
-
registry-url: "https://registry.npmjs.org"
|
|
42
|
-
|
|
43
|
-
- name: Update npm
|
|
44
|
-
run: npm install -g npm@latest
|
|
45
|
-
|
|
46
|
-
- name: Install dependencies
|
|
47
|
-
run: npm ci
|
|
48
|
-
|
|
49
|
-
- name: Publish to npm
|
|
50
|
-
run: |
|
|
51
|
-
VERSION=$(node -p "require('./package.json').version")
|
|
52
|
-
if [[ "$VERSION" == *-* ]]; then
|
|
53
|
-
npm publish --provenance --access public --tag beta
|
|
54
|
-
else
|
|
55
|
-
npm publish --provenance --access public
|
|
56
|
-
fi
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: release
|
|
3
|
-
description: Prepare and publish a new pi-simplify release. Use when bumping the version, committing and pushing it, and creating a GitHub release in the style of earlier pi-simplify releases.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# pi-simplify Release
|
|
7
|
-
|
|
8
|
-
Repo defaults:
|
|
9
|
-
|
|
10
|
-
- branch: `main`
|
|
11
|
-
- remote: `origin`
|
|
12
|
-
- repo: `geminixiang/pi-simplify`
|
|
13
|
-
- npm package: `@geminixiang/pi-simplify`
|
|
14
|
-
- use `package.json` version for npm/package files, and `v<version>` for the git tag and GitHub release title
|
|
15
|
-
- versions with `-alpha.`, `-beta.`, or `-rc.` are prereleases by default
|
|
16
|
-
|
|
17
|
-
## Version rules
|
|
18
|
-
|
|
19
|
-
Examples:
|
|
20
|
-
|
|
21
|
-
- stable package version: `0.2.1`, `0.3.0`, `1.0.0` → release tag/title: `v0.2.1`, `v0.3.0`, `v1.0.0`
|
|
22
|
-
- prerelease package version: `0.2.0-beta.8`, `0.3.0-rc.1`, `1.0.0-alpha.2` → release tag/title: `v0.2.0-beta.8`, `v0.3.0-rc.1`, `v1.0.0-alpha.2`
|
|
23
|
-
|
|
24
|
-
Guidance:
|
|
25
|
-
|
|
26
|
-
- `patch` = bugfix / small maintenance
|
|
27
|
-
- `minor` = backward-compatible features
|
|
28
|
-
- `major` = breaking changes
|
|
29
|
-
- prerelease numbers increase within the same line, e.g. `beta.8 -> beta.9`
|
|
30
|
-
- promote prerelease to stable by dropping the suffix, e.g. `0.2.0-beta.8 -> 0.2.0`
|
|
31
|
-
|
|
32
|
-
## Flow
|
|
33
|
-
|
|
34
|
-
### 1. Check state
|
|
35
|
-
|
|
36
|
-
```bash
|
|
37
|
-
git status --short
|
|
38
|
-
git branch --show-current
|
|
39
|
-
git remote -v
|
|
40
|
-
git tag --list | tail -20
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
Read `package.json` and `package-lock.json`. If unrelated files are modified, ask before committing.
|
|
44
|
-
|
|
45
|
-
### 2. Sync version files
|
|
46
|
-
|
|
47
|
-
Preferred:
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
npm version <version> --no-git-tag-version
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
Use this to sync `package.json` and `package-lock.json` without creating an automatic commit or tag. If the user already edited `package.json`, just verify `package-lock.json` matches.
|
|
54
|
-
|
|
55
|
-
### 3. Update CHANGELOG
|
|
56
|
-
|
|
57
|
-
`CHANGELOG.md` follows Keep a Changelog with `### Added / Changed / Fixed / Removed / Security / Performance / Tests` subsections. The newest release sits at the top under an `## [Unreleased]` placeholder.
|
|
58
|
-
|
|
59
|
-
Gather user-visible changes since the previous tag:
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
git log --pretty=format:'%h %s' <previous-tag>..HEAD | grep -v "^[a-f0-9]* chore: bump version"
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Then in `CHANGELOG.md`:
|
|
66
|
-
|
|
67
|
-
- Keep `## [Unreleased]` at the top as an empty placeholder.
|
|
68
|
-
- Insert a new section `## [<version>] - <YYYY-MM-DD>` (use today's date for stable, omit the date for prereleases to match the existing style).
|
|
69
|
-
- Group entries by subsection; one bullet per user-visible change, imperative voice, no commit hashes.
|
|
70
|
-
- Skip pure internal refactors that don't change behavior unless they affect contributors (then list under `### Changed`).
|
|
71
|
-
|
|
72
|
-
If you generated draft release notes in step 5 first, copy the same wording into CHANGELOG — they should match.
|
|
73
|
-
|
|
74
|
-
### 4. Commit and push
|
|
75
|
-
|
|
76
|
-
Stage version files and CHANGELOG together — the bump and the changelog entry belong in the same commit.
|
|
77
|
-
|
|
78
|
-
```bash
|
|
79
|
-
git add package.json package-lock.json CHANGELOG.md
|
|
80
|
-
git commit -m "chore: bump version to <version>"
|
|
81
|
-
git push origin main
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
### 5. Draft release notes
|
|
85
|
-
|
|
86
|
-
Use the previous GitHub release as the style reference.
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
gh release list --repo geminixiang/pi-simplify --limit 10
|
|
90
|
-
gh release view <previous-tag> --repo geminixiang/pi-simplify --json tagName,name,body,url,publishedAt
|
|
91
|
-
git log --pretty=format:'%h %s' <previous-tag>..HEAD
|
|
92
|
-
git diff --stat <previous-tag>..HEAD
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
Write concise notes focused on user-visible changes, usually with:
|
|
96
|
-
|
|
97
|
-
- `## What's changed`
|
|
98
|
-
- `### Highlights`
|
|
99
|
-
- `### Notable changes`
|
|
100
|
-
- `### Docs and maintenance`
|
|
101
|
-
- `### Verification`
|
|
102
|
-
|
|
103
|
-
Write notes to `/tmp/pi-simplify-release-<version>.md`. Keep them consistent with the CHANGELOG entry from step 3. Use `v<previous-version>...v<version>` in compare links.
|
|
104
|
-
|
|
105
|
-
### 6. Create or update release
|
|
106
|
-
|
|
107
|
-
Prerelease:
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
gh release create v<version> \
|
|
111
|
-
--repo geminixiang/pi-simplify \
|
|
112
|
-
--target main \
|
|
113
|
-
--title v<version> \
|
|
114
|
-
--notes-file /tmp/pi-simplify-release-<version>.md \
|
|
115
|
-
--prerelease
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
Stable:
|
|
119
|
-
|
|
120
|
-
```bash
|
|
121
|
-
gh release create v<version> \
|
|
122
|
-
--repo geminixiang/pi-simplify \
|
|
123
|
-
--target main \
|
|
124
|
-
--title v<version> \
|
|
125
|
-
--notes-file /tmp/pi-simplify-release-<version>.md
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
If it already exists, use `gh release edit v<version> ...` and keep prerelease/stable intent consistent.
|
|
129
|
-
|
|
130
|
-
## Report back
|
|
131
|
-
|
|
132
|
-
Return:
|
|
133
|
-
|
|
134
|
-
- released version
|
|
135
|
-
- stable or prerelease
|
|
136
|
-
- version-bump commit hash
|
|
137
|
-
- push status
|
|
138
|
-
- release URL
|
|
139
|
-
|
|
140
|
-
## Guardrails
|
|
141
|
-
|
|
142
|
-
- Always use `geminixiang/pi-simplify`.
|
|
143
|
-
- Infer stable vs prerelease from the version, or ask.
|
|
144
|
-
- Do not include raw commit hashes in release notes unless requested.
|
|
145
|
-
- If hooks fail during commit, fix or report before retrying.
|
|
146
|
-
- Never publish a release without a corresponding CHANGELOG entry — the version bump commit must include the new CHANGELOG section.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|