Skip to content

Commit 8878ec7

Browse files
authored
feat: add /enhance:plugin - plugin structure and tool use analyzer (#130)
* feat: add /enhance:plugin - plugin structure and tool use analyzer Implements #119 - creates a new enhance plugin that analyzes: - Plugin.json structure and required fields - MCP tool definitions against best practices - Security patterns in agent/command files Features: - HIGH/MEDIUM/LOW certainty levels following slop-patterns model - Auto-fix capability for HIGH certainty issues - Markdown report generation - Comprehensive test suite (21 tests) New files: - plugins/enhance/ - plugin command and agent - lib/enhance/ - pattern matching, reporter, fixer modules - tests/enhance/ - unit tests * docs: add /enhance:plugin to CHANGELOG * fix: convert enhance tests from node:test to Jest format The test file was using Node.js native test runner (node:test) but Jest picks up .test.js files. Convert to Jest assertions so the tests run properly in CI. * fix: address PR review comments - Use array index instead of command name in schemaPath for auto-fix to work - Add try-catch for JSON.parse to handle malformed plugin.json gracefully - Check if path is a file before unlinking in cleanupBackups - Use fake test secret to avoid GitGuardian false positive * fix: address additional PR review comments - Fix documentation: HIGH certainty section title (not all are auto-fixable) - Fix example: use fixed.applied.length instead of fixed.length - Remove misleading inline autoFixFn from missing_required_fields pattern (plugin-analyzer provides the proper fixer.fixRequiredFields)
1 parent 2b49b6c commit 8878ec7

13 files changed

Lines changed: 2638 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
- **Plugin Structure Analyzer** - New `/enhance:plugin` command (#119)
12+
- Analyzes plugin.json structure and required fields
13+
- Validates MCP tool definitions against best practices
14+
- Detects security patterns in agent/command files
15+
- HIGH/MEDIUM/LOW certainty levels following slop-patterns model
16+
- Auto-fix capability for HIGH certainty issues
17+
- New lib/enhance/ module with pattern matching, reporter, fixer
18+
- Comprehensive test suite (21 tests)
19+
820
## [2.7.1] - 2026-01-22
921

1022
### Security

lib/enhance/fixer.js

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
/**
2+
* Plugin Analysis Fixer
3+
* Applies auto-fixes for HIGH certainty issues
4+
*
5+
* @author Avi Fenesh
6+
* @license MIT
7+
*/
8+
9+
const fs = require('fs');
10+
const path = require('path');
11+
12+
/**
13+
* Apply fixes for issues that have autoFixFn
14+
* @param {Array} issues - Array of issues with potential fixes
15+
* @param {Object} options - Fix options
16+
* @param {boolean} options.dryRun - Show changes without applying
17+
* @param {boolean} options.backup - Create backup files
18+
* @returns {Object} Fix results
19+
*/
20+
function applyFixes(issues, options = {}) {
21+
const { dryRun = false, backup = true } = options;
22+
23+
const results = {
24+
applied: [],
25+
skipped: [],
26+
errors: []
27+
};
28+
29+
// Filter to only HIGH certainty issues with autoFixFn
30+
const fixableIssues = issues.filter(i =>
31+
i.certainty === 'HIGH' && i.autoFixFn && i.filePath
32+
);
33+
34+
// Group by file to minimize reads/writes
35+
const byFile = new Map();
36+
for (const issue of fixableIssues) {
37+
if (!byFile.has(issue.filePath)) {
38+
byFile.set(issue.filePath, []);
39+
}
40+
byFile.get(issue.filePath).push(issue);
41+
}
42+
43+
// Process each file
44+
for (const [filePath, fileIssues] of byFile) {
45+
try {
46+
// Read current content
47+
if (!fs.existsSync(filePath)) {
48+
results.errors.push({ filePath, error: 'File not found' });
49+
continue;
50+
}
51+
52+
const content = fs.readFileSync(filePath, 'utf8');
53+
let data;
54+
55+
// Parse based on file type
56+
if (filePath.endsWith('.json')) {
57+
data = JSON.parse(content);
58+
} else {
59+
// For non-JSON files, skip auto-fix
60+
results.skipped.push(...fileIssues.map(i => ({
61+
...i,
62+
reason: 'Non-JSON file - manual fix required'
63+
})));
64+
continue;
65+
}
66+
67+
// Apply each fix
68+
let modified = data;
69+
const appliedToFile = [];
70+
71+
for (const issue of fileIssues) {
72+
try {
73+
// Determine what part of data to fix
74+
if (issue.schemaPath) {
75+
// Fix at specific path in the data
76+
modified = applyAtPath(modified, issue.schemaPath, issue.autoFixFn);
77+
} else {
78+
// Apply to root
79+
modified = issue.autoFixFn(modified);
80+
}
81+
82+
appliedToFile.push({
83+
issue: issue.issue,
84+
fix: issue.fix,
85+
filePath
86+
});
87+
} catch (err) {
88+
results.errors.push({
89+
issue: issue.issue,
90+
filePath,
91+
error: err.message
92+
});
93+
}
94+
}
95+
96+
// Write changes
97+
if (!dryRun && appliedToFile.length > 0) {
98+
// Create backup
99+
if (backup) {
100+
const backupPath = `${filePath}.backup`;
101+
fs.writeFileSync(backupPath, content, 'utf8');
102+
}
103+
104+
// Write modified content
105+
const newContent = JSON.stringify(modified, null, 2);
106+
fs.writeFileSync(filePath, newContent, 'utf8');
107+
}
108+
109+
results.applied.push(...appliedToFile);
110+
111+
} catch (err) {
112+
results.errors.push({
113+
filePath,
114+
error: err.message
115+
});
116+
}
117+
}
118+
119+
// Add non-fixable issues to skipped
120+
const nonFixable = issues.filter(i =>
121+
i.certainty !== 'HIGH' || !i.autoFixFn
122+
);
123+
results.skipped.push(...nonFixable.map(i => ({
124+
...i,
125+
reason: i.certainty !== 'HIGH' ? 'Not HIGH certainty' : 'No auto-fix available'
126+
})));
127+
128+
return results;
129+
}
130+
131+
/**
132+
* Apply a fix function at a specific path in an object
133+
* @private
134+
*/
135+
function applyAtPath(obj, pathStr, fixFn) {
136+
const parts = pathStr.split('.');
137+
const result = JSON.parse(JSON.stringify(obj)); // Deep clone
138+
139+
let current = result;
140+
for (let i = 0; i < parts.length - 1; i++) {
141+
const part = parts[i];
142+
if (part.includes('[')) {
143+
// Array access
144+
const match = part.match(/(\w+)\[(\d+)\]/);
145+
if (match) {
146+
current = current[match[1]][parseInt(match[2])];
147+
}
148+
} else {
149+
current = current[part];
150+
}
151+
}
152+
153+
const lastPart = parts[parts.length - 1];
154+
if (lastPart.includes('[')) {
155+
const match = lastPart.match(/(\w+)\[(\d+)\]/);
156+
if (match) {
157+
current[match[1]][parseInt(match[2])] = fixFn(current[match[1]][parseInt(match[2])]);
158+
}
159+
} else {
160+
current[lastPart] = fixFn(current[lastPart]);
161+
}
162+
163+
return result;
164+
}
165+
166+
/**
167+
* Fix missing additionalProperties in a schema
168+
* @param {Object} schema - JSON Schema object
169+
* @returns {Object} Fixed schema
170+
*/
171+
function fixAdditionalProperties(schema) {
172+
if (!schema || typeof schema !== 'object') return schema;
173+
174+
const fixed = { ...schema };
175+
176+
if (fixed.type === 'object' && fixed.properties) {
177+
fixed.additionalProperties = false;
178+
}
179+
180+
// Recursively fix nested schemas
181+
if (fixed.properties) {
182+
fixed.properties = {};
183+
for (const [key, value] of Object.entries(schema.properties)) {
184+
fixed.properties[key] = fixAdditionalProperties(value);
185+
}
186+
}
187+
188+
return fixed;
189+
}
190+
191+
/**
192+
* Fix missing required array
193+
* @param {Object} schema - JSON Schema object
194+
* @returns {Object} Fixed schema
195+
*/
196+
function fixRequiredFields(schema) {
197+
if (!schema || typeof schema !== 'object') return schema;
198+
199+
const fixed = { ...schema };
200+
201+
if (fixed.type === 'object' && fixed.properties && !fixed.required) {
202+
// Add all non-optional fields to required
203+
fixed.required = Object.entries(fixed.properties)
204+
.filter(([_, prop]) => {
205+
// Skip if has default or marked optional in description
206+
if (prop.default !== undefined) return false;
207+
if (prop.description && /optional/i.test(prop.description)) return false;
208+
return true;
209+
})
210+
.map(([key]) => key);
211+
}
212+
213+
return fixed;
214+
}
215+
216+
/**
217+
* Fix version mismatch by syncing to package.json version
218+
* @param {Object} pluginJson - Plugin JSON object
219+
* @param {string} targetVersion - Version to sync to
220+
* @returns {Object} Fixed plugin JSON
221+
*/
222+
function fixVersionMismatch(pluginJson, targetVersion) {
223+
return {
224+
...pluginJson,
225+
version: targetVersion
226+
};
227+
}
228+
229+
/**
230+
* Generate a fix preview without applying
231+
* @param {Array} issues - Issues to preview
232+
* @returns {Array} Preview of changes
233+
*/
234+
function previewFixes(issues) {
235+
const previews = [];
236+
237+
for (const issue of issues) {
238+
if (issue.certainty === 'HIGH' && issue.autoFixFn) {
239+
previews.push({
240+
filePath: issue.filePath,
241+
issue: issue.issue,
242+
fix: issue.fix,
243+
willApply: true
244+
});
245+
} else {
246+
previews.push({
247+
filePath: issue.filePath,
248+
issue: issue.issue,
249+
fix: issue.fix || 'No auto-fix available',
250+
willApply: false,
251+
reason: issue.certainty !== 'HIGH' ? 'Not HIGH certainty' : 'No auto-fix function'
252+
});
253+
}
254+
}
255+
256+
return previews;
257+
}
258+
259+
/**
260+
* Restore from backup
261+
* @param {string} filePath - Path to file to restore
262+
* @returns {boolean} True if restored successfully
263+
*/
264+
function restoreFromBackup(filePath) {
265+
const backupPath = `${filePath}.backup`;
266+
267+
if (!fs.existsSync(backupPath)) {
268+
return false;
269+
}
270+
271+
const backupContent = fs.readFileSync(backupPath, 'utf8');
272+
fs.writeFileSync(filePath, backupContent, 'utf8');
273+
fs.unlinkSync(backupPath);
274+
275+
return true;
276+
}
277+
278+
/**
279+
* Clean up backup files
280+
* @param {string} directory - Directory to clean
281+
* @returns {number} Number of backups removed
282+
*/
283+
function cleanupBackups(directory) {
284+
let count = 0;
285+
286+
const files = fs.readdirSync(directory, { recursive: true });
287+
for (const file of files) {
288+
if (file.endsWith('.backup')) {
289+
const fullPath = path.join(directory, file);
290+
try {
291+
const stat = fs.statSync(fullPath);
292+
if (stat.isFile()) {
293+
fs.unlinkSync(fullPath);
294+
count++;
295+
}
296+
} catch (err) {
297+
// File may have been removed already
298+
}
299+
}
300+
}
301+
302+
return count;
303+
}
304+
305+
module.exports = {
306+
applyFixes,
307+
fixAdditionalProperties,
308+
fixRequiredFields,
309+
fixVersionMismatch,
310+
previewFixes,
311+
restoreFromBackup,
312+
cleanupBackups
313+
};

lib/enhance/index.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Enhance Library
3+
* Plugin structure and tool use analyzer
4+
*
5+
* @author Avi Fenesh
6+
* @license MIT
7+
*/
8+
9+
const pluginAnalyzer = require('./plugin-analyzer');
10+
const pluginPatterns = require('./plugin-patterns');
11+
const toolPatterns = require('./tool-patterns');
12+
const securityPatterns = require('./security-patterns');
13+
const reporter = require('./reporter');
14+
const fixer = require('./fixer');
15+
16+
module.exports = {
17+
// Main analyzer
18+
pluginAnalyzer,
19+
20+
// Pattern modules
21+
pluginPatterns,
22+
toolPatterns,
23+
securityPatterns,
24+
25+
// Output modules
26+
reporter,
27+
fixer,
28+
29+
// Convenience exports
30+
analyze: pluginAnalyzer.analyze,
31+
analyzePlugin: pluginAnalyzer.analyzePlugin,
32+
analyzeAllPlugins: pluginAnalyzer.analyzeAllPlugins,
33+
applyFixes: pluginAnalyzer.applyFixes,
34+
generateReport: pluginAnalyzer.generateReport
35+
};

0 commit comments

Comments
 (0)