buddy-workbench 0.1.67 → 0.1.68
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/package.json +1 -1
- package/server/repositories/settings.js +45 -0
- package/server/routes/pr-review.js +53 -12
- package/ui/dist/assets/{index--sEEIpXL.js → index-BPwgVyU2.js} +124 -124
- package/ui/dist/assets/index-D5QYI1v4.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/ui/dist/assets/index-BzRDhqvZ.css +0 -1
package/package.json
CHANGED
|
@@ -95,6 +95,51 @@ export function saveClipboardDeduplicateMinutes(minutes) {
|
|
|
95
95
|
return settingsStatus();
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
export function readPrReviewRules(defaultRules) {
|
|
99
|
+
const settings = readSettings();
|
|
100
|
+
const savedRules = settings.prReviewRules && typeof settings.prReviewRules === 'object' ? settings.prReviewRules : {};
|
|
101
|
+
return defaultRules.map((rule) => ({
|
|
102
|
+
...rule,
|
|
103
|
+
enabled: savedRules[rule.id]?.enabled !== false,
|
|
104
|
+
severity: ['critical', 'warning', 'info'].includes(savedRules[rule.id]?.severity)
|
|
105
|
+
? savedRules[rule.id].severity
|
|
106
|
+
: rule.severity
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readPrReviewCustomRules() {
|
|
111
|
+
const settings = readSettings();
|
|
112
|
+
return Array.isArray(settings.prReviewCustomRules) ? settings.prReviewCustomRules : [];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function savePrReviewRules(rules, customRules, defaultRules) {
|
|
116
|
+
const settings = readSettings();
|
|
117
|
+
const allowed = new Map(defaultRules.map((rule) => [rule.id, rule]));
|
|
118
|
+
settings.prReviewRules = {};
|
|
119
|
+
for (const rule of Array.isArray(rules) ? rules : []) {
|
|
120
|
+
const defaultRule = allowed.get(rule?.id);
|
|
121
|
+
if (!defaultRule) continue;
|
|
122
|
+
settings.prReviewRules[defaultRule.id] = {
|
|
123
|
+
enabled: rule.enabled !== false,
|
|
124
|
+
severity: ['critical', 'warning', 'info'].includes(rule.severity) ? rule.severity : defaultRule.severity
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
settings.prReviewCustomRules = (Array.isArray(customRules) ? customRules : [])
|
|
128
|
+
.filter((rule) => rule && typeof rule.id === 'string' && typeof rule.name === 'string' && typeof rule.pattern === 'string' && rule.name.trim() && rule.pattern.trim())
|
|
129
|
+
.map((rule) => ({
|
|
130
|
+
id: rule.id.slice(0, 80),
|
|
131
|
+
name: rule.name.trim().slice(0, 120),
|
|
132
|
+
pattern: rule.pattern.slice(0, 500),
|
|
133
|
+
flags: typeof rule.flags === 'string' ? rule.flags.replace(/[^dgimsuvy]/g, '').slice(0, 8) : 'g',
|
|
134
|
+
message: typeof rule.message === 'string' && rule.message.trim() ? rule.message.trim().slice(0, 300) : 'Custom pattern matched.',
|
|
135
|
+
enabled: rule.enabled !== false,
|
|
136
|
+
severity: ['critical', 'warning', 'info'].includes(rule.severity) ? rule.severity : 'warning'
|
|
137
|
+
}));
|
|
138
|
+
mkdirSync(dirname(paths.settings), { recursive: true });
|
|
139
|
+
writeFileSync(paths.settings, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
|
140
|
+
return { rules: readPrReviewRules(defaultRules), customRules: readPrReviewCustomRules() };
|
|
141
|
+
}
|
|
142
|
+
|
|
98
143
|
const accessTokenFields = {
|
|
99
144
|
bitbucket: 'bitbucketAccessToken',
|
|
100
145
|
jira: 'jiraAccessToken',
|
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
import https from 'node:https';
|
|
2
2
|
import axios from 'axios';
|
|
3
3
|
import { Router } from 'express';
|
|
4
|
-
import { readSettings } from '../repositories/settings.js';
|
|
4
|
+
import { readSettings, readPrReviewCustomRules, readPrReviewRules, savePrReviewRules } from '../repositories/settings.js';
|
|
5
5
|
import { recordApiError } from '../lib/api-errors.js';
|
|
6
6
|
|
|
7
7
|
const router = Router();
|
|
8
8
|
const previewLimit = 2000;
|
|
9
9
|
let lastUsedHost = '';
|
|
10
10
|
|
|
11
|
+
const PR_REVIEW_RULES = [
|
|
12
|
+
{ id: 'lodash-get', name: 'Lodash Get Required', description: 'Prefer lodash/get for nested object property access.', pattern: String.raw`\b([a-zA-Z_$][\w$]*)\s*(\?\.|\.)\s*([a-zA-Z_$][\w$]*)\b(?!\s*\()`, severity: 'warning' },
|
|
13
|
+
{ id: 'no-any', name: 'No Any Type', description: 'Disallow the TypeScript any type.', pattern: String.raw`:\s*any\b|:\s*any\[\]|as\s+any\b|<[^>]*?\bany\b[^>]*?>|\bany\[\]`, severity: 'critical' },
|
|
14
|
+
{ id: 'no-hardcoded-strings', name: 'No Hardcoded Strings', description: 'Move hardcoded text into constants or i18n.', pattern: String.raw`(const|let|var)\s+[a-zA-Z_$][\w$]*\s*=\s*['"][^'"]+['"]`, severity: 'warning' },
|
|
15
|
+
{ id: 'no-inline-styles', name: 'No Inline Styles', description: 'Use CSS classes or styled components instead of inline styles.', pattern: String.raw`\bstyle\s*=\s*\{\s*\{[\s\S]*?\}\s*\}|\bstyle\s*=\s*['"][\s\S]*?['"]`, severity: 'warning' },
|
|
16
|
+
{ id: 'clickable-element-tag', name: 'Clickable Element Tag', description: 'Use button or anchor elements for clickable UI.', pattern: String.raw`<(div|span|p|li|tr|td|img|i|svg|section|article|header|footer|h[1-6]|label)\b[^>]*?\b(onClick|@click)\b[^>]*?>`, severity: 'warning' },
|
|
17
|
+
{ id: 'no-relative-paths', name: 'No Relative Paths', description: 'Use absolute module imports or path aliases.', pattern: String.raw`import\s*(?:\{[^}]*\}|[^{'"\n]+)\s*from\s*['"]\.\.?\/*['"]|require\s*\(\s*['"]\.\.?\/*['"]\)`, severity: 'warning' },
|
|
18
|
+
{ id: 'no-debugger', name: 'No Debugger', description: 'Disallow debugger statements in committed code.', pattern: String.raw`\bdebugger\b`, severity: 'critical' },
|
|
19
|
+
{ id: 'no-console-log', name: 'No Console Log', description: 'Disallow leftover console.log statements.', pattern: String.raw`\bconsole\.log\b`, severity: 'critical' },
|
|
20
|
+
{ id: 'no-array-index-key', name: 'No Array Index Key', description: 'Avoid array indexes as React list keys.', pattern: String.raw`\bkey\s*=\s*\{\s*(index|idx|i)\s*\}`, severity: 'warning' },
|
|
21
|
+
{ id: 'no-nested-components', name: 'No Nested Components', description: 'Keep React component definitions outside component bodies.', pattern: String.raw`^\s*(const|function)\s+[A-Z][a-zA-Z0-9_$]*\s*=\s*\(|\bfunction\s+[A-Z][a-zA-Z0-9_$]*\s*\(`, severity: 'warning' },
|
|
22
|
+
{ id: 'no-async-component', name: 'No Async Component', description: 'Disallow async client components.', pattern: String.raw`\b(const|function)\s+[A-Z][a-zA-Z0-9_$]*\s*=\s*async\b|\basync\s+function\s+[A-Z][a-zA-Z0-9_$]*`, severity: 'critical' },
|
|
23
|
+
{ id: 'no-non-null-assertion', name: 'No Non-null Assertion', description: 'Prefer optional chaining and defensive null checks.', pattern: String.raw`[a-zA-Z0-9_$]!\s*(\.|\[)`, severity: 'warning' },
|
|
24
|
+
{ id: 'no-direct-state-mutation', name: 'No Direct State Mutation', description: 'Use immutable updates for React state.', pattern: String.raw`\b(state|list|items|data)\.(push|pop|shift|unshift|splice|sort|reverse)\s*\(|\bstate\.[a-zA-Z0-9_$]+\s*=\s*`, severity: 'warning' },
|
|
25
|
+
{ id: 'unhandled-async-event', name: 'Unhandled Async Event', description: 'Wrap async event handlers in try/catch.', pattern: String.raw`\bon[A-Z][a-zA-Z0-9_$]*\s*=\s*\{\s*async\b[\s\S]*?\}`, severity: 'warning' },
|
|
26
|
+
{ id: 'no-magic-numbers', name: 'No Magic Numbers', description: 'Define named constants for business-logic numbers.', pattern: String.raw`(===|==|!==|!=)\s*([2-9]|\d{2,})\b|\bsetTimeout\s*\([^,]+,\s*([2-9]|\d{2,})\)`, severity: 'info' }
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
router.get('/rules', (_req, res) => res.json({ rules: readPrReviewRules(PR_REVIEW_RULES), customRules: readPrReviewCustomRules() }));
|
|
30
|
+
router.put('/rules', (req, res) => {
|
|
31
|
+
if (!Array.isArray(req.body?.rules)) return res.status(400).json({ error: 'Rules must be an array.' });
|
|
32
|
+
res.json(savePrReviewRules(req.body.rules, req.body.customRules, PR_REVIEW_RULES));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function getRuleSettings() {
|
|
36
|
+
return Object.fromEntries(readPrReviewRules(PR_REVIEW_RULES).map((rule) => [rule.name, rule]));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getCustomRuleSettings() {
|
|
40
|
+
return readPrReviewCustomRules().filter((rule) => rule.enabled !== false).map((rule) => {
|
|
41
|
+
try {
|
|
42
|
+
const flags = rule.flags || 'g';
|
|
43
|
+
return { ...rule, regex: new RegExp(rule.pattern, flags.includes('g') ? flags : `${flags}g`) };
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}).filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
11
50
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
12
51
|
const httpClient = axios.create({
|
|
13
52
|
httpsAgent,
|
|
@@ -62,6 +101,8 @@ const isFiltered = (filePath) => {
|
|
|
62
101
|
|
|
63
102
|
const analyzeDiff = (filePath, hunks) => {
|
|
64
103
|
const issues = [];
|
|
104
|
+
const ruleSettings = getRuleSettings();
|
|
105
|
+
const customRules = getCustomRuleSettings();
|
|
65
106
|
|
|
66
107
|
for (const hunk of hunks || []) {
|
|
67
108
|
for (const segment of hunk.segments || []) {
|
|
@@ -94,10 +135,12 @@ const analyzeDiff = (filePath, hunks) => {
|
|
|
94
135
|
};
|
|
95
136
|
|
|
96
137
|
const addIssue = (severity, rule, message, charIndex, customCode) => {
|
|
138
|
+
const configuredRule = ruleSettings[rule];
|
|
139
|
+
if (configuredRule?.enabled === false) return;
|
|
97
140
|
const { lineNum, text } = getLineInfo(charIndex);
|
|
98
141
|
if (!issues.some(i => i.rule === rule && i.line === lineNum)) {
|
|
99
142
|
issues.push({
|
|
100
|
-
severity,
|
|
143
|
+
severity: configuredRule?.severity || severity,
|
|
101
144
|
rule,
|
|
102
145
|
message,
|
|
103
146
|
line: lineNum,
|
|
@@ -106,6 +149,13 @@ const analyzeDiff = (filePath, hunks) => {
|
|
|
106
149
|
}
|
|
107
150
|
};
|
|
108
151
|
|
|
152
|
+
for (const customRule of customRules) {
|
|
153
|
+
customRule.regex.lastIndex = 0;
|
|
154
|
+
for (const match of segmentText.matchAll(customRule.regex)) {
|
|
155
|
+
addIssue(customRule.severity, customRule.name, customRule.message, match.index, match[0]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
109
159
|
// Rule 1: Object property extraction requires lodash.get
|
|
110
160
|
const propAccessRegex = /\b([a-zA-Z_$][\w$]*)\s*(\?\.|\.)\s*([a-zA-Z_$][\w$]*)\b(?!\s*\()/g;
|
|
111
161
|
for (const match of segmentText.matchAll(propAccessRegex)) {
|
|
@@ -153,16 +203,7 @@ const analyzeDiff = (filePath, hunks) => {
|
|
|
153
203
|
}
|
|
154
204
|
}
|
|
155
205
|
|
|
156
|
-
// Rule 4:
|
|
157
|
-
const hookRegex = /\buse(Memo|Callback)\s*\(\s*(?:(?!,\s*\[).)*?\)/gs;
|
|
158
|
-
for (const match of segmentText.matchAll(hookRegex)) {
|
|
159
|
-
const matchedSnippet = match[0];
|
|
160
|
-
if (matchedSnippet.includes(', []') || !matchedSnippet.includes(', [')) {
|
|
161
|
-
addIssue('warning', 'Missing Hook Dependency', '`useMemo` and `useCallback` must specify dynamic dependencies (missing array or empty `[]` is prohibited).', match.index, matchedSnippet);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// Rule 5: No inline styles (multiline)
|
|
206
|
+
// Rule 4: No inline styles (multiline)
|
|
166
207
|
const styleRegex = /\bstyle\s*=\s*\{\s*\{[\s\S]*?\}\s*\}|\bstyle\s*=\s*['"][\s\S]*?['"]/g;
|
|
167
208
|
for (const match of segmentText.matchAll(styleRegex)) {
|
|
168
209
|
addIssue('warning', 'No Inline Styles', 'Inline styles are strictly prohibited. Use CSS classes or styled components.', match.index);
|