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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.67",
3
+ "version": "0.1.68",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: useMemo and useCallback must specify dependencies (multiline regex)
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);