codeep 2.5.2 → 2.7.0

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/README.md CHANGED
@@ -217,6 +217,77 @@ AI-powered review of your git diff with `/review`:
217
217
 
218
218
  If there are no git changes, falls back to static analysis automatically.
219
219
 
220
+ #### Custom rules (`.codeep/review.yml` or `.codeep/review.json`)
221
+
222
+ The static reviewer (`codeep review` / `/review --static`) ships a set of
223
+ built-in rules, but a project can tailor them — check a `.codeep/review.yml`
224
+ (or `.codeep/review.json`) into the repo and the CLI **and** the
225
+ [Codeep GitHub Action](https://github.com/VladoIvankovic/codeep-action) both
226
+ pick it up automatically (zero LLM cost). YAML is preferred when present and is
227
+ nicer for regexes — single-quoted YAML keeps backslashes literal
228
+ (`pattern: '\bfoo\('`) so you avoid JSON's double-escaping:
229
+
230
+ ```yaml
231
+ # .codeep/review.yml
232
+ rules:
233
+ - id: no-internal-import
234
+ pattern: "from ['\"]@acme/internal"
235
+ category: best-practice
236
+ severity: error
237
+ message: Don't import from @acme/internal outside the platform team
238
+ suggestion: Use the public @acme/sdk package
239
+ extensions: [.ts, .tsx]
240
+ disable: [todo-comment, anonymous-function]
241
+ include: ['src/**']
242
+ exclude: ['**/*.test.ts', 'vendor/**']
243
+ ```
244
+
245
+ The same config as JSON:
246
+
247
+ ```json
248
+ {
249
+ "rules": [
250
+ {
251
+ "id": "no-internal-import",
252
+ "pattern": "from ['\"]@acme/internal",
253
+ "category": "best-practice",
254
+ "severity": "error",
255
+ "message": "Don't import from @acme/internal outside the platform team",
256
+ "suggestion": "Use the public @acme/sdk package",
257
+ "extensions": [".ts", ".tsx"]
258
+ }
259
+ ],
260
+ "disable": ["todo-comment", "anonymous-function"],
261
+ "include": ["src/**"],
262
+ "exclude": ["**/*.test.ts", "vendor/**"]
263
+ }
264
+ ```
265
+
266
+ - **`rules`** — your own checks. `id`, `pattern` (a regex string), and `message`
267
+ are required; `flags` (default `g`), `category`, `severity`
268
+ (`error|warning|info|suggestion`), `suggestion`, and `extensions` are optional.
269
+ - **`disable`** — turn off built-in rules by id. Run `codeep review --rules` for
270
+ the live list; the built-in ids are: `eval-usage`, `inner-html`,
271
+ `dangerously-set-inner-html`, `hardcoded-password`, `hardcoded-api-key`,
272
+ `foreach-await`, `await-in-loop`, `select-star`, `loose-null-check`,
273
+ `empty-catch`, `console-statement`, `todo-comment`, `any-type`, `ts-ignore`,
274
+ `as-any`, `var-usage`, `anonymous-function`, `missing-jsdoc`, `long-file`,
275
+ `long-function`.
276
+ - **`include` / `exclude`** — glob scoping (`**`, `*`, `?`); `include` empty = all files.
277
+
278
+ A missing, malformed, or partially-invalid config never breaks a review — bad
279
+ entries are skipped and the run proceeds with whatever is valid.
280
+
281
+ **More review commands:**
282
+ - `codeep review --rules` — list the built-in rule ids (above) and exit.
283
+ - `codeep review --ai` — after the offline pass, get an advisory AI second
284
+ opinion on the working-tree diff from your configured provider (needs an API
285
+ key; never changes the exit code, so CI stays deterministic).
286
+ - `codeep hook install` — install a git pre-commit hook that reviews the
287
+ working-tree content of your staged files and blocks the commit on failures
288
+ (`--pre-push` for pre-push, `--fail-on <level>` to set the threshold,
289
+ `codeep hook uninstall` to remove). Stage changes fully before committing.
290
+
220
291
  ### Interactive Mode
221
292
  Agent asks clarifying questions when tasks are ambiguous:
222
293
  ```
@@ -973,11 +973,10 @@ export function startAcpServer() {
973
973
  });
974
974
  };
975
975
  resetTokenTracking();
976
- // In manual mode, confirm write/edit operations
977
- const prevConfirmWrite = config.get('agentConfirmWriteFile');
978
- if (session.currentModeId === 'manual') {
979
- config.set('agentConfirmWriteFile', true);
980
- }
976
+ // Manual mode gates write/edit for THIS run via a per-call option passed to
977
+ // runAgentSession (extraDangerousTools, below) — NOT by mutating the global
978
+ // `agentConfirmWriteFile` config, which leaked the session's mode into the
979
+ // TUI/other processes and raced on a non-atomic restore.
981
980
  const agentResponseChunks = [];
982
981
  const sendChunk = (text) => {
983
982
  agentResponseChunks.push(text);
@@ -1083,6 +1082,9 @@ export function startAcpServer() {
1083
1082
  }
1084
1083
  }
1085
1084
  },
1085
+ // Manual mode gates write_file/edit_file for this run only (per-call,
1086
+ // no global config mutation).
1087
+ extraDangerousTools: session.currentModeId === 'manual' ? ['write_file', 'edit_file'] : undefined,
1086
1088
  // Only request permission in Manual mode
1087
1089
  onRequestPermission: session.currentModeId === 'manual'
1088
1090
  ? async (toolCall) => {
@@ -1183,7 +1185,6 @@ export function startAcpServer() {
1183
1185
  if (agentResponse) {
1184
1186
  session.history.push({ role: 'assistant', content: agentResponse });
1185
1187
  }
1186
- config.set('agentConfirmWriteFile', prevConfirmWrite);
1187
1188
  autoSaveSession(session.history, session.workspaceRoot);
1188
1189
  // Report token usage to dashboard
1189
1190
  const projectCtx = getProjectContext(session.workspaceRoot);
@@ -1247,7 +1248,6 @@ export function startAcpServer() {
1247
1248
  transport.error(msg.id, -32000, err.message);
1248
1249
  }
1249
1250
  }).finally(() => {
1250
- config.set('agentConfirmWriteFile', prevConfirmWrite);
1251
1251
  if (session)
1252
1252
  session.abortController = null;
1253
1253
  planEntries.clear();
@@ -11,6 +11,8 @@ export interface AgentSessionOptions {
11
11
  onThought?: (text: string) => void;
12
12
  onToolCall?: (toolCallId: string, toolName: string, kind: string, title: string, status: 'pending' | 'running' | 'finished' | 'error', locations?: string[], rawOutput?: string) => void;
13
13
  onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
14
+ /** Tools to force into the per-run dangerous set (ACP manual mode). */
15
+ extraDangerousTools?: string[];
14
16
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
15
17
  stdout: string;
16
18
  stderr: string;
@@ -145,6 +145,7 @@ export async function runAgentSession(opts) {
145
145
  }
146
146
  },
147
147
  onRequestPermission: opts.onRequestPermission,
148
+ extraDangerousTools: opts.extraDangerousTools,
148
149
  onExecuteCommand: opts.onExecuteCommand,
149
150
  fs: opts.fs,
150
151
  // Route MCP-prefixed tool calls through the per-session registry.
@@ -96,6 +96,10 @@ interface ConfigSchema {
96
96
  /** True once legacy plaintext keys (providerApiKeys / apiKey) have been
97
97
  * migrated into secure storage and wiped from the config file. */
98
98
  keysSecured: boolean;
99
+ /** Plaintext fallback key map used by utils/keychain.ts ONLY when the OS
100
+ * keychain is unavailable. Swept into the keychain once it becomes available
101
+ * (see sweepFallbackKeysToKeychain). Empty {} on keychain-capable systems. */
102
+ apiKeys?: Record<string, string>;
99
103
  /** Master switch for automatic cloud uploads (usage stats, session
100
104
  * transcripts, progress, memory notes). Default true; set false to opt out.
101
105
  * The CODEEP_NO_TELEMETRY / DO_NOT_TRACK env vars also force it off. */
@@ -186,6 +186,7 @@ function createConfig() {
186
186
  providerApiKeys: [],
187
187
  configuredProviderIds: [],
188
188
  keysSecured: false,
189
+ apiKeys: {},
189
190
  telemetry: true,
190
191
  githubId: '',
191
192
  githubUsername: '',
@@ -359,6 +360,44 @@ async function migrateKeysToSecureStorage() {
359
360
  _migrationPromise = null;
360
361
  }
361
362
  }
363
+ let _sweepPromise = null;
364
+ /**
365
+ * If the OS keychain is now available but API keys are still sitting in the
366
+ * plaintext `apiKeys` fallback map (written by utils/keychain.ts while the
367
+ * keychain was unavailable on a prior run), move each into the keychain. The
368
+ * keychain write also deletes the entry from the plaintext fallback map, so a
369
+ * successful sweep leaves no plaintext behind. Cheap no-op when the map is
370
+ * empty or the keychain is unavailable; deduped for concurrent callers. Runs
371
+ * independently of `keysSecured` so a key written during a keychain outage is
372
+ * still swept up later.
373
+ */
374
+ async function sweepFallbackKeysToKeychain() {
375
+ if (!_sweepPromise) {
376
+ _sweepPromise = (async () => {
377
+ const store = secureKeyStore();
378
+ // Only sweep when the keychain is actually usable; otherwise leave the
379
+ // plaintext fallback in place (there's nowhere safer to move it).
380
+ if (!store.isKeychainAvailable || !(await store.isKeychainAvailable()))
381
+ return;
382
+ const plaintext = config.get('apiKeys') || {};
383
+ for (const [providerId, apiKey] of Object.entries(plaintext)) {
384
+ if (typeof apiKey !== 'string' || !apiKey)
385
+ continue;
386
+ try {
387
+ await store.setApiKey(providerId, apiKey); // writes keychain + deletes from fallback map
388
+ addConfiguredProviderId(providerId);
389
+ }
390
+ catch { /* keep the plaintext entry on failure — never lose a key */ }
391
+ }
392
+ })();
393
+ }
394
+ try {
395
+ await _sweepPromise;
396
+ }
397
+ finally {
398
+ _sweepPromise = null;
399
+ }
400
+ }
362
401
  export const LANGUAGES = {
363
402
  'auto': 'Auto-detect',
364
403
  'en': 'English',
@@ -417,9 +456,11 @@ export async function loadApiKey(providerId) {
417
456
  * Should be called at app startup
418
457
  */
419
458
  export async function loadAllApiKeys() {
420
- // Migrate any legacy plaintext keys, then load all from secure storage using
421
- // the non-secret configuredProviderIds index (no need to probe every provider).
459
+ // Migrate any legacy plaintext keys, then sweep any keychain-fallback plaintext
460
+ // up into the keychain (no-op when none / keychain unavailable), then load all
461
+ // from secure storage using the non-secret configuredProviderIds index.
422
462
  await migrateKeysToSecureStorage();
463
+ await sweepFallbackKeysToKeychain();
423
464
  const store = secureKeyStore();
424
465
  for (const providerId of (config.get('configuredProviderIds') || [])) {
425
466
  const key = await store.getApiKey(providerId);
@@ -387,7 +387,11 @@ async function main() {
387
387
  // the review usage rather than the top-level help.
388
388
  if (args[0] === 'review') {
389
389
  const { runHeadlessReview } = await import('../utils/headlessReview.js');
390
- process.exit(runHeadlessReview(args.slice(1)));
390
+ process.exit(await runHeadlessReview(args.slice(1)));
391
+ }
392
+ if (args[0] === 'hook') {
393
+ const { runHookCommand } = await import('../utils/gitHookInstaller.js');
394
+ process.exit(runHookCommand(args.slice(1)));
391
395
  }
392
396
  if (args.includes('--version') || args.includes('-v')) {
393
397
  console.log(`Codeep v${getCurrentVersion()}`);
@@ -403,7 +407,9 @@ Usage:
403
407
  codeep account sync Pull keys + personalities + commands + profile from codeep.dev
404
408
  codeep account push Push local keys + personalities + commands + profile to codeep.dev
405
409
  codeep acp Start ACP server (for Zed editor integration)
406
- codeep review Offline code review for CI (--json, --fail-on <level>)
410
+ codeep review Offline code review for CI (--json, --fail-on, --rules, --ai)
411
+ codeep hook install Install a git pre-commit hook running \`codeep review\`
412
+ codeep hook uninstall Remove the Codeep git hook
407
413
  codeep --version Show version
408
414
  codeep --help Show this help
409
415
 
@@ -22,6 +22,14 @@ export type PermissionDecision = 'allow-once' | 'allow-always' | 'deny-once' | '
22
22
  * invariant is unit-tested independently of the agent loop.
23
23
  */
24
24
  export declare function classifyPermissionOutcome(outcome: string | undefined | null): PermissionDecision;
25
+ /**
26
+ * Build the set of tools that require a permission prompt this run. Derived from
27
+ * the global agentConfirm* settings, plus any `extra` tools forced in for this
28
+ * run only (ACP manual mode passes ['write_file','edit_file'] this way instead
29
+ * of mutating the global `agentConfirmWriteFile` config — which would leak the
30
+ * session's mode into the TUI and race on restore). Exported for unit testing.
31
+ */
32
+ export declare function buildDangerousTools(extra?: string[]): Set<string>;
25
33
  export interface AgentOptions {
26
34
  maxIterations: number;
27
35
  maxDuration: number;
@@ -34,6 +42,10 @@ export interface AgentOptions {
34
42
  onTaskPlan?: (plan: TaskPlan) => void;
35
43
  onTaskUpdate?: (task: SubTask) => void;
36
44
  onRequestPermission?: (toolCall: ToolCall) => Promise<PermissionOutcome>;
45
+ /** Tool names to force into the per-run dangerous set, on top of the global
46
+ * agentConfirm* settings. ACP manual mode passes ['write_file','edit_file']
47
+ * here to gate them for THIS run only, instead of mutating global config. */
48
+ extraDangerousTools?: string[];
37
49
  onExecuteCommand?: (command: string, args: string[], cwd: string) => Promise<{
38
50
  stdout: string;
39
51
  stderr: string;
@@ -119,6 +119,23 @@ export function classifyPermissionOutcome(outcome) {
119
119
  return 'deny-always';
120
120
  return 'deny-once'; // 'reject_once' OR anything unexpected → fail closed
121
121
  }
122
+ /**
123
+ * Build the set of tools that require a permission prompt this run. Derived from
124
+ * the global agentConfirm* settings, plus any `extra` tools forced in for this
125
+ * run only (ACP manual mode passes ['write_file','edit_file'] this way instead
126
+ * of mutating the global `agentConfirmWriteFile` config — which would leak the
127
+ * session's mode into the TUI and race on restore). Exported for unit testing.
128
+ */
129
+ export function buildDangerousTools(extra = []) {
130
+ const tools = new Set([
131
+ ...(config.get('agentConfirmDeleteFile') !== false ? ['delete_file'] : []),
132
+ ...(config.get('agentConfirmExecuteCommand') !== false ? ['execute_command'] : []),
133
+ ...(config.get('agentConfirmWriteFile') === true ? ['write_file', 'edit_file'] : []),
134
+ ]);
135
+ for (const t of extra)
136
+ tools.add(t);
137
+ return tools;
138
+ }
122
139
  /**
123
140
  * Build the result for a run that paused at a safety limit. Pausing is a normal,
124
141
  * resumable state — not an error — so the summary tells the user how to resume.
@@ -384,11 +401,7 @@ export async function runAgent(prompt, projectContext, options = {}) {
384
401
  // Track tools permanently rejected this session via reject_always
385
402
  const alwaysRejectedTools = new Set();
386
403
  // Tools that require permission when onRequestPermission is set (configurable)
387
- const dangerousTools = new Set([
388
- ...(config.get('agentConfirmDeleteFile') !== false ? ['delete_file'] : []),
389
- ...(config.get('agentConfirmExecuteCommand') !== false ? ['execute_command'] : []),
390
- ...(config.get('agentConfirmWriteFile') === true ? ['write_file', 'edit_file'] : []),
391
- ]);
404
+ const dangerousTools = buildDangerousTools(opts.extraDangerousTools);
392
405
  // Delegation handler: run a named (or generic) sub-agent in its own fresh
393
406
  // context and return its summary as the tool result. Reachable only when the
394
407
  // `delegate` tool was advertised (depth 0). The sub-agent runs nested (no own
@@ -28,6 +28,20 @@ export interface ReviewSummary {
28
28
  byCategory: Record<ReviewCategory, number>;
29
29
  bySeverity: Record<string, number>;
30
30
  }
31
+ /**
32
+ * A single deterministic review rule. Built-in rules and user rules from
33
+ * `.codeep/review.json` share this shape. `id` is stable so a project can
34
+ * disable a built-in rule by id (see utils/reviewConfig.ts).
35
+ */
36
+ export interface RuleDef {
37
+ id: string;
38
+ pattern: RegExp;
39
+ category: ReviewCategory;
40
+ severity: ReviewIssue['severity'];
41
+ message: string;
42
+ suggestion?: string;
43
+ extensions?: string[];
44
+ }
31
45
  /**
32
46
  * Perform code review
33
47
  */
@@ -40,3 +54,24 @@ export declare function formatReviewResult(result: ReviewResult): string;
40
54
  * Get review prompt for AI-enhanced review
41
55
  */
42
56
  export declare function getReviewSystemPrompt(result: ReviewResult): string;
57
+ export interface BuiltinRuleInfo {
58
+ id: string;
59
+ category: ReviewCategory;
60
+ severity: ReviewIssue['severity'];
61
+ description: string;
62
+ }
63
+ /**
64
+ * Built-in rule metadata (id + what each flags) — the ids that
65
+ * `.codeep/review.{json,yml}` "disable" accepts. Includes the two line-count
66
+ * heuristics (long-file / long-function) that live outside CODE_PATTERNS.
67
+ */
68
+ export declare function listBuiltinRules(): BuiltinRuleInfo[];
69
+ /**
70
+ * Append an advisory "AI second opinion" section to a markdown review report.
71
+ * Advisory only — it never changes the score or the exit code; the deterministic
72
+ * review remains authoritative.
73
+ */
74
+ export declare function appendAiSection(markdown: string, aiText: string | null, meta?: {
75
+ provider?: string;
76
+ model?: string;
77
+ }): string;
@@ -4,10 +4,13 @@
4
4
  import { existsSync, readFileSync, readdirSync } from 'fs';
5
5
  import { join, extname, relative } from 'path';
6
6
  import { getChangedFiles } from './git.js';
7
- // Common code patterns that indicate issues
7
+ import { loadReviewConfig, globToRegExp } from './reviewConfig.js';
8
+ // Built-in code patterns that indicate issues. Each has a stable `id` so it can
9
+ // be turned off per-project via `.codeep/review.json` { "disable": ["..."] }.
8
10
  const CODE_PATTERNS = [
9
11
  // Security issues
10
12
  {
13
+ id: 'eval-usage',
11
14
  pattern: /eval\s*\(/g,
12
15
  category: 'security',
13
16
  severity: 'error',
@@ -16,6 +19,7 @@ const CODE_PATTERNS = [
16
19
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
17
20
  },
18
21
  {
22
+ id: 'inner-html',
19
23
  pattern: /innerHTML\s*=/g,
20
24
  category: 'security',
21
25
  severity: 'warning',
@@ -24,6 +28,7 @@ const CODE_PATTERNS = [
24
28
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
25
29
  },
26
30
  {
31
+ id: 'dangerously-set-inner-html',
27
32
  pattern: /dangerouslySetInnerHTML/g,
28
33
  category: 'security',
29
34
  severity: 'warning',
@@ -32,6 +37,7 @@ const CODE_PATTERNS = [
32
37
  extensions: ['.jsx', '.tsx'],
33
38
  },
34
39
  {
40
+ id: 'hardcoded-password',
35
41
  pattern: /password\s*=\s*['"][^'"]+['"]/gi,
36
42
  category: 'security',
37
43
  severity: 'error',
@@ -39,6 +45,7 @@ const CODE_PATTERNS = [
39
45
  suggestion: 'Use environment variables for sensitive data',
40
46
  },
41
47
  {
48
+ id: 'hardcoded-api-key',
42
49
  pattern: /api[_-]?key\s*=\s*['"][^'"]+['"]/gi,
43
50
  category: 'security',
44
51
  severity: 'error',
@@ -47,6 +54,7 @@ const CODE_PATTERNS = [
47
54
  },
48
55
  // Performance issues
49
56
  {
57
+ id: 'foreach-await',
50
58
  pattern: /\.forEach\s*\([^)]*\)\s*{\s*await/g,
51
59
  category: 'performance',
52
60
  severity: 'warning',
@@ -55,6 +63,7 @@ const CODE_PATTERNS = [
55
63
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
56
64
  },
57
65
  {
66
+ id: 'await-in-loop',
58
67
  pattern: /for\s*\([^)]+\)\s*{\s*await/g,
59
68
  category: 'performance',
60
69
  severity: 'info',
@@ -63,6 +72,7 @@ const CODE_PATTERNS = [
63
72
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
64
73
  },
65
74
  {
75
+ id: 'select-star',
66
76
  pattern: /SELECT\s+\*/gi,
67
77
  category: 'performance',
68
78
  severity: 'warning',
@@ -71,6 +81,7 @@ const CODE_PATTERNS = [
71
81
  },
72
82
  // Bug-prone patterns
73
83
  {
84
+ id: 'loose-null-check',
74
85
  pattern: /==\s*null|null\s*==/g,
75
86
  category: 'bug',
76
87
  severity: 'info',
@@ -79,6 +90,7 @@ const CODE_PATTERNS = [
79
90
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
80
91
  },
81
92
  {
93
+ id: 'empty-catch',
82
94
  pattern: /catch\s*\(\s*\w*\s*\)\s*{\s*}/g,
83
95
  category: 'bug',
84
96
  severity: 'warning',
@@ -86,6 +98,7 @@ const CODE_PATTERNS = [
86
98
  suggestion: 'Log the error or handle it appropriately',
87
99
  },
88
100
  {
101
+ id: 'console-statement',
89
102
  pattern: /console\.(log|debug|info|warn|error)\s*\(/g,
90
103
  category: 'maintainability',
91
104
  severity: 'info',
@@ -94,6 +107,7 @@ const CODE_PATTERNS = [
94
107
  extensions: ['.js', '.ts', '.jsx', '.tsx'],
95
108
  },
96
109
  {
110
+ id: 'todo-comment',
97
111
  pattern: /TODO|FIXME|HACK|XXX/g,
98
112
  category: 'maintainability',
99
113
  severity: 'info',
@@ -102,6 +116,7 @@ const CODE_PATTERNS = [
102
116
  },
103
117
  // Type safety
104
118
  {
119
+ id: 'any-type',
105
120
  pattern: /:\s*any\b/g,
106
121
  category: 'types',
107
122
  severity: 'warning',
@@ -110,6 +125,7 @@ const CODE_PATTERNS = [
110
125
  extensions: ['.ts', '.tsx'],
111
126
  },
112
127
  {
128
+ id: 'ts-ignore',
113
129
  pattern: /@ts-ignore/g,
114
130
  category: 'types',
115
131
  severity: 'warning',
@@ -118,6 +134,7 @@ const CODE_PATTERNS = [
118
134
  extensions: ['.ts', '.tsx'],
119
135
  },
120
136
  {
137
+ id: 'as-any',
121
138
  pattern: /as\s+any\b/g,
122
139
  category: 'types',
123
140
  severity: 'warning',
@@ -127,6 +144,7 @@ const CODE_PATTERNS = [
127
144
  },
128
145
  // Best practices
129
146
  {
147
+ id: 'var-usage',
130
148
  pattern: /var\s+\w+/g,
131
149
  category: 'best-practice',
132
150
  severity: 'info',
@@ -135,6 +153,7 @@ const CODE_PATTERNS = [
135
153
  extensions: ['.js', '.jsx'],
136
154
  },
137
155
  {
156
+ id: 'anonymous-function',
138
157
  pattern: /function\s*\(/g,
139
158
  category: 'style',
140
159
  severity: 'info',
@@ -144,6 +163,7 @@ const CODE_PATTERNS = [
144
163
  },
145
164
  // Documentation
146
165
  {
166
+ id: 'missing-jsdoc',
147
167
  pattern: /export\s+(default\s+)?(?:function|class|const)\s+\w+/g,
148
168
  category: 'documentation',
149
169
  severity: 'suggestion',
@@ -155,22 +175,31 @@ const CODE_PATTERNS = [
155
175
  /**
156
176
  * Analyze a single file for issues
157
177
  */
158
- function analyzeFile(filePath, content, projectRoot) {
178
+ function analyzeFile(filePath, content, projectRoot, rules, disabled) {
159
179
  const issues = [];
160
180
  const ext = extname(filePath);
161
181
  const relativePath = relative(projectRoot, filePath);
162
182
  const lines = content.split('\n');
163
- for (const pattern of CODE_PATTERNS) {
183
+ // Skip the regex pass on very large files (the cheap line-count heuristics
184
+ // below still run) so an oversized file can't stall the reviewer. NOTE: this
185
+ // bounds input SIZE only, not regex run-time — catastrophic backtracking is a
186
+ // function of pattern shape. Untrusted custom rules from .codeep/review.json
187
+ // are additionally screened at load (utils/reviewConfig.ts) and the GitHub
188
+ // Action caps wall-clock, but a zero-width match is guarded right here.
189
+ const scannable = content.length <= 2_000_000 ? content : '';
190
+ const MAX_MATCHES_PER_RULE = 1000;
191
+ for (const pattern of rules) {
164
192
  // Skip if pattern doesn't apply to this file type
165
193
  if (pattern.extensions && !pattern.extensions.includes(ext)) {
166
194
  continue;
167
195
  }
168
196
  // Find all matches
169
197
  let match;
198
+ let count = 0;
170
199
  const regex = new RegExp(pattern.pattern.source, pattern.pattern.flags);
171
- while ((match = regex.exec(content)) !== null) {
200
+ while ((match = regex.exec(scannable)) !== null) {
172
201
  // Find line number
173
- const beforeMatch = content.slice(0, match.index);
202
+ const beforeMatch = scannable.slice(0, match.index);
174
203
  const lineNumber = beforeMatch.split('\n').length;
175
204
  issues.push({
176
205
  file: relativePath,
@@ -180,10 +209,17 @@ function analyzeFile(filePath, content, projectRoot) {
180
209
  message: pattern.message,
181
210
  suggestion: pattern.suggestion,
182
211
  });
212
+ // A zero-width match (e.g. a custom rule like `a?` or `(?:)`) leaves
213
+ // lastIndex unchanged, so exec() would return it forever — advance past it.
214
+ if (match.index === regex.lastIndex)
215
+ regex.lastIndex++;
216
+ // Bound pathological match floods (also caps the per-match work above).
217
+ if (++count >= MAX_MATCHES_PER_RULE)
218
+ break;
183
219
  }
184
220
  }
185
221
  // Check for long files
186
- if (lines.length > 500) {
222
+ if (!disabled.has('long-file') && lines.length > 500) {
187
223
  issues.push({
188
224
  file: relativePath,
189
225
  severity: 'info',
@@ -194,7 +230,8 @@ function analyzeFile(filePath, content, projectRoot) {
194
230
  // Check for long functions (basic heuristic)
195
231
  let braceDepth = 0;
196
232
  let functionStart = -1;
197
- for (let i = 0; i < lines.length; i++) {
233
+ const checkLongFunctions = !disabled.has('long-function');
234
+ for (let i = 0; checkLongFunctions && i < lines.length; i++) {
198
235
  const line = lines[i];
199
236
  if (/function\s+\w+|=>\s*{|\)\s*{/.test(line)) {
200
237
  if (braceDepth === 0) {
@@ -278,7 +315,28 @@ function getAllSourceFiles(dir, maxFiles = 50) {
278
315
  */
279
316
  export function performCodeReview(projectContext, specificFiles) {
280
317
  const projectRoot = projectContext.root || process.cwd();
281
- const filesToReview = getFilesToReview(projectRoot, specificFiles);
318
+ // Project-level config (.codeep/review.json): custom rules, disabled built-in
319
+ // ids, and include/exclude globs. Absent/invalid → defaults (built-ins only).
320
+ const config = loadReviewConfig(projectRoot);
321
+ const disabled = config?.disabled ?? new Set();
322
+ const effectiveRules = [
323
+ ...CODE_PATTERNS.filter((p) => !disabled.has(p.id)),
324
+ ...(config?.rules ?? []),
325
+ ];
326
+ let filesToReview = getFilesToReview(projectRoot, specificFiles);
327
+ // Apply include/exclude globs (posix-relative paths). Empty include = all.
328
+ if (config && (config.include.length > 0 || config.exclude.length > 0)) {
329
+ const inc = config.include.map(globToRegExp);
330
+ const exc = config.exclude.map(globToRegExp);
331
+ filesToReview = filesToReview.filter((f) => {
332
+ const rel = relative(projectRoot, f).split('\\').join('/');
333
+ if (inc.length > 0 && !inc.some((re) => re.test(rel)))
334
+ return false;
335
+ if (exc.some((re) => re.test(rel)))
336
+ return false;
337
+ return true;
338
+ });
339
+ }
282
340
  const allIssues = [];
283
341
  // Determine scope — mirrors the branching in getFilesToReview so the user
284
342
  // sees exactly which branch ran.
@@ -295,7 +353,7 @@ export function performCodeReview(projectContext, specificFiles) {
295
353
  for (const filePath of filesToReview) {
296
354
  try {
297
355
  const content = readFileSync(filePath, 'utf-8');
298
- const issues = analyzeFile(filePath, content, projectRoot);
356
+ const issues = analyzeFile(filePath, content, projectRoot, effectiveRules, disabled);
299
357
  allIssues.push(...issues);
300
358
  }
301
359
  catch { }
@@ -404,3 +462,34 @@ ${formatReviewResult(result)}
404
462
 
405
463
  Be concise and practical. Focus on issues that matter most for code quality and maintainability.`;
406
464
  }
465
+ /**
466
+ * Built-in rule metadata (id + what each flags) — the ids that
467
+ * `.codeep/review.{json,yml}` "disable" accepts. Includes the two line-count
468
+ * heuristics (long-file / long-function) that live outside CODE_PATTERNS.
469
+ */
470
+ export function listBuiltinRules() {
471
+ return [
472
+ ...CODE_PATTERNS.map((p) => ({
473
+ id: p.id,
474
+ category: p.category,
475
+ severity: p.severity,
476
+ description: p.message,
477
+ })),
478
+ { id: 'long-file', category: 'maintainability', severity: 'info', description: 'File exceeds 500 lines — consider splitting into smaller modules' },
479
+ { id: 'long-function', category: 'maintainability', severity: 'info', description: 'Function exceeds 50 lines — consider breaking it down' },
480
+ ];
481
+ }
482
+ /**
483
+ * Append an advisory "AI second opinion" section to a markdown review report.
484
+ * Advisory only — it never changes the score or the exit code; the deterministic
485
+ * review remains authoritative.
486
+ */
487
+ export function appendAiSection(markdown, aiText, meta = {}) {
488
+ const label = meta.model
489
+ ? `${meta.model}${meta.provider ? ` via ${meta.provider}` : ''}`
490
+ : (meta.provider || 'your provider');
491
+ if (!aiText || !aiText.trim()) {
492
+ return `${markdown}\n\n## AI Second Opinion\n_Skipped — no response from ${label}._`;
493
+ }
494
+ return `${markdown}\n\n## AI Second Opinion (${label})\n_Advisory — does not affect the score or exit code; the deterministic review above is authoritative._\n\n${aiText.trim()}`;
495
+ }
@@ -0,0 +1,26 @@
1
+ import type { FailOn } from './headlessReview.js';
2
+ export type HookType = 'pre-commit' | 'pre-push';
3
+ export type HookAction = 'install' | 'uninstall' | 'help';
4
+ export interface HookArgs {
5
+ action: HookAction;
6
+ hookType: HookType;
7
+ failOn: FailOn;
8
+ help: boolean;
9
+ }
10
+ export declare const HOOK_HELP = "Usage: codeep hook <install|uninstall> [options]\n\nInstall a git hook that runs `codeep review` on your changes, blocking the\ncommit/push when issues at/above the threshold are found. Honors a project's\n.codeep/review.yml (or .json).\n\nActions:\n install Install the hook (pre-commit by default)\n uninstall Remove the Codeep-managed hook\n\nOptions:\n --pre-push Manage the pre-push hook instead of pre-commit\n --fail-on <level> Severity that blocks: error | warning | info | none (default: error)\n -h, --help Show this help\n\nThe pre-commit hook reviews the working-tree content of staged files, so stage\nyour changes fully before committing for the most accurate result.\nCodeep never overwrites a pre-existing hook it didn't create.";
11
+ /** Parse argv after `hook`. Pure. */
12
+ export declare function parseHookArgs(argv: string[]): HookArgs;
13
+ /** The hook script body. Pure. pre-commit scopes to staged files; pre-push reviews changes. */
14
+ export declare function buildHookScript(hookType: HookType, failOn: FailOn): string;
15
+ /** True when a hook file was created by Codeep (safe to overwrite/remove). */
16
+ export declare function isCodeepHook(content: string): boolean;
17
+ /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
18
+ export declare function resolveHooksDir(cwd: string): string | null;
19
+ export interface HookDeps {
20
+ resolveHooksDir: (cwd: string) => string | null;
21
+ readHook: (path: string) => string | null;
22
+ writeHook: (path: string, content: string) => void;
23
+ removeHook: (path: string) => void;
24
+ write: (text: string) => void;
25
+ }
26
+ export declare function runHookCommand(argv: string[], deps?: HookDeps): number;
@@ -0,0 +1,168 @@
1
+ // `codeep hook install|uninstall` — installs a GIT hook (pre-commit / pre-push)
2
+ // that runs the offline reviewer on your changes and blocks the commit/push when
3
+ // issues at/above the threshold are found. Honors .codeep/review.{yml,json}.
4
+ //
5
+ // This is a GIT-hook installer — distinct from the lifecycle SHELL hooks in
6
+ // utils/hooks.ts (.codeep/hooks/<event>.sh) and the React hook in src/hooks/.
7
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, rmSync } from 'fs';
8
+ import { join, dirname, isAbsolute } from 'path';
9
+ import { execSync } from 'child_process';
10
+ const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
11
+ const MARKER_START = '# >>> codeep hook >>>';
12
+ const MARKER_END = '# <<< codeep hook <<<';
13
+ export const HOOK_HELP = `Usage: codeep hook <install|uninstall> [options]
14
+
15
+ Install a git hook that runs \`codeep review\` on your changes, blocking the
16
+ commit/push when issues at/above the threshold are found. Honors a project's
17
+ .codeep/review.yml (or .json).
18
+
19
+ Actions:
20
+ install Install the hook (pre-commit by default)
21
+ uninstall Remove the Codeep-managed hook
22
+
23
+ Options:
24
+ --pre-push Manage the pre-push hook instead of pre-commit
25
+ --fail-on <level> Severity that blocks: error | warning | info | none (default: error)
26
+ -h, --help Show this help
27
+
28
+ The pre-commit hook reviews the working-tree content of staged files, so stage
29
+ your changes fully before committing for the most accurate result.
30
+ Codeep never overwrites a pre-existing hook it didn't create.`;
31
+ /** Parse argv after `hook`. Pure. */
32
+ export function parseHookArgs(argv) {
33
+ const out = { action: 'help', hookType: 'pre-commit', failOn: 'error', help: false };
34
+ let i = 0;
35
+ if (argv[0] === 'install' || argv[0] === 'uninstall') {
36
+ out.action = argv[0];
37
+ i = 1;
38
+ }
39
+ for (; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--pre-push')
42
+ out.hookType = 'pre-push';
43
+ else if (a === '--pre-commit')
44
+ out.hookType = 'pre-commit';
45
+ else if (a === '-h' || a === '--help')
46
+ out.help = true;
47
+ else if (a === '--fail-on') {
48
+ const v = argv[++i];
49
+ if (FAIL_ON_VALUES.includes(v))
50
+ out.failOn = v;
51
+ }
52
+ else if (a.startsWith('--fail-on=')) {
53
+ const v = a.slice('--fail-on='.length);
54
+ if (FAIL_ON_VALUES.includes(v))
55
+ out.failOn = v;
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+ /** The hook script body. Pure. pre-commit scopes to staged files; pre-push reviews changes. */
61
+ export function buildHookScript(hookType, failOn) {
62
+ const lines = [
63
+ '#!/bin/sh',
64
+ MARKER_START,
65
+ '# Managed by Codeep: `codeep hook install` to update, `codeep hook uninstall` to remove.',
66
+ 'command -v codeep >/dev/null 2>&1 || exit 0', // no codeep on PATH → skip, don't block
67
+ ];
68
+ if (hookType === 'pre-commit') {
69
+ // NUL-delimited + xargs -0 so staged paths with spaces/metachars survive as
70
+ // single arguments (an unquoted $files would word-split and silently skip
71
+ // them — a fail-open gate). Temp file keeps it portable: BSD/macOS xargs has
72
+ // no `-r`, so we guard emptiness with `[ -s ]` instead.
73
+ lines.push('tmp=$(mktemp)');
74
+ lines.push('git diff --cached --name-only -z --diff-filter=ACMR > "$tmp"');
75
+ lines.push('if [ -s "$tmp" ]; then');
76
+ lines.push(` xargs -0 codeep review --fail-on ${failOn} < "$tmp"`);
77
+ lines.push(' status=$?');
78
+ lines.push('else');
79
+ lines.push(' status=0');
80
+ lines.push('fi');
81
+ lines.push('rm -f "$tmp"');
82
+ lines.push('exit $status');
83
+ }
84
+ else {
85
+ lines.push(`codeep review --fail-on ${failOn}`);
86
+ }
87
+ lines.push(MARKER_END, '');
88
+ return lines.join('\n');
89
+ }
90
+ /** True when a hook file was created by Codeep (safe to overwrite/remove). */
91
+ export function isCodeepHook(content) {
92
+ return content.includes(MARKER_START);
93
+ }
94
+ /** Resolve the git hooks directory (honors worktrees + core.hooksPath). Null if not a repo. */
95
+ export function resolveHooksDir(cwd) {
96
+ try {
97
+ execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' });
98
+ const hooks = execSync('git rev-parse --git-path hooks', { cwd, encoding: 'utf8' }).trim();
99
+ return isAbsolute(hooks) ? hooks : join(cwd, hooks);
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ export function runHookCommand(argv, deps = defaultHookDeps()) {
106
+ const args = parseHookArgs(argv);
107
+ if (args.help || args.action === 'help') {
108
+ deps.write(HOOK_HELP);
109
+ return 0;
110
+ }
111
+ const hooksDir = deps.resolveHooksDir(process.cwd());
112
+ if (!hooksDir) {
113
+ deps.write('Not a git repository — run `codeep hook` inside a repo.');
114
+ return 1;
115
+ }
116
+ const target = join(hooksDir, args.hookType);
117
+ const existing = deps.readHook(target);
118
+ if (args.action === 'uninstall') {
119
+ if (existing === null) {
120
+ deps.write(`No ${args.hookType} hook to remove.`);
121
+ return 0;
122
+ }
123
+ if (!isCodeepHook(existing)) {
124
+ deps.write(`Refusing to remove ${args.hookType}: it was not created by Codeep.`);
125
+ return 1;
126
+ }
127
+ deps.removeHook(target);
128
+ deps.write(`Removed the Codeep ${args.hookType} hook.`);
129
+ return 0;
130
+ }
131
+ // install
132
+ if (existing !== null && !isCodeepHook(existing)) {
133
+ deps.write(`A ${args.hookType} hook already exists and was not created by Codeep — refusing to overwrite it. Remove it first, or add a \`codeep review\` call manually.`);
134
+ return 1;
135
+ }
136
+ deps.writeHook(target, buildHookScript(args.hookType, args.failOn));
137
+ const when = args.hookType === 'pre-commit' ? 'commit' : 'push';
138
+ deps.write(`Installed the Codeep ${args.hookType} hook → runs \`codeep review --fail-on ${args.failOn}\` on each ${when}.`);
139
+ return 0;
140
+ }
141
+ function defaultHookDeps() {
142
+ return {
143
+ resolveHooksDir,
144
+ readHook: (p) => {
145
+ try {
146
+ return readFileSync(p, 'utf8');
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ },
152
+ writeHook: (p, content) => {
153
+ mkdirSync(dirname(p), { recursive: true });
154
+ writeFileSync(p, content, { mode: 0o755 });
155
+ try {
156
+ chmodSync(p, 0o755);
157
+ }
158
+ catch { /* best effort (e.g. Windows) */ }
159
+ },
160
+ removeHook: (p) => {
161
+ try {
162
+ rmSync(p);
163
+ }
164
+ catch { /* ignore */ }
165
+ },
166
+ write: (t) => process.stdout.write(t + '\n'),
167
+ };
168
+ }
@@ -4,21 +4,35 @@ export interface ReviewArgs {
4
4
  files: string[];
5
5
  json: boolean;
6
6
  failOn: FailOn;
7
+ rules: boolean;
8
+ ai: boolean;
7
9
  help: boolean;
8
10
  }
9
- export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
11
+ export declare const REVIEW_HELP = "Usage: codeep review [options] [files...]\n\nRun a deterministic, offline code review (no API key required). With no files,\nreviews your unstaged git changes, falling back to a src/ scan when the tree is\nclean. Pass files (or let your CI pass the PR's changed files) to scope it.\n\nCustom/disabled rules come from .codeep/review.yml (or .json) in the repo.\n\nOptions:\n --json Print the result as JSON instead of the markdown report\n --fail-on <level> Exit non-zero when an issue at or above <level> is found:\n error | warning | info | none (default: error)\n --rules List the built-in rule ids (for \"disable\" in .codeep/review.*) and exit\n --ai After the offline pass, ask your configured provider for a\n contextual second opinion on the working-tree diff\n (advisory; needs an API key; never affects the exit code)\n -h, --help Show this help\n\nExit code: 0 when nothing at/above --fail-on is found, 1 otherwise.";
10
12
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
11
13
  export declare function parseReviewArgs(argv: string[]): ReviewArgs;
12
14
  /** Exit code for a result under a fail-on threshold. Pure. */
13
15
  export declare function exitCodeForResult(result: ReviewResult, failOn: FailOn): number;
16
+ /** Render the built-in rule ids for `--rules`. Pure. */
17
+ export declare function formatBuiltinRules(): string;
14
18
  export interface ReviewDeps {
15
19
  /** Run the review over optional specific files. */
16
20
  review: (files?: string[]) => ReviewResult;
17
21
  /** Sink for the report (one call). */
18
22
  write: (text: string) => void;
23
+ /** List of built-in rule ids (for --rules). */
24
+ listRules: () => string;
25
+ /** Optional AI second opinion; returns null when unavailable (no key / error). */
26
+ aiReview: (result: ReviewResult) => Promise<string | null>;
27
+ /** Provider/model label for the AI section header. */
28
+ aiMeta: () => {
29
+ provider?: string;
30
+ model?: string;
31
+ };
19
32
  }
20
33
  /**
21
34
  * Orchestrate a headless review and return the process exit code. Side effects
22
- * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
35
+ * (filesystem, stdout, provider call) live behind `deps` so the flow is
36
+ * unit-testable. The exit code is ALWAYS deterministic — `--ai` is advisory.
23
37
  */
24
- export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): number;
38
+ export declare function runHeadlessReview(argv: string[], deps?: ReviewDeps): Promise<number>;
@@ -2,7 +2,12 @@
2
2
  // deterministic reviewer in codeReview.ts. No API key, no TUI: it scans, prints
3
3
  // a report (markdown or JSON), and exits non-zero when issues at/above a chosen
4
4
  // severity are found, so it drops cleanly into CI (e.g. a GitHub Action).
5
- import { performCodeReview, formatReviewResult } from './codeReview.js';
5
+ //
6
+ // `--ai` is the one opt-in online mode: after the offline pass it asks the
7
+ // configured provider for a contextual second opinion (advisory only — it never
8
+ // affects the exit code), and degrades to deterministic-only when no key is set.
9
+ import { performCodeReview, formatReviewResult, getReviewSystemPrompt, listBuiltinRules, appendAiSection, } from './codeReview.js';
10
+ import { config, getCurrentProvider } from '../config/index.js';
6
11
  const FAIL_ON_VALUES = ['error', 'warning', 'info', 'none'];
7
12
  // Higher = more severe. `suggestion` sits below `info` so `--fail-on info`
8
13
  // never trips on a mere suggestion.
@@ -13,21 +18,33 @@ Run a deterministic, offline code review (no API key required). With no files,
13
18
  reviews your unstaged git changes, falling back to a src/ scan when the tree is
14
19
  clean. Pass files (or let your CI pass the PR's changed files) to scope it.
15
20
 
21
+ Custom/disabled rules come from .codeep/review.yml (or .json) in the repo.
22
+
16
23
  Options:
17
24
  --json Print the result as JSON instead of the markdown report
18
25
  --fail-on <level> Exit non-zero when an issue at or above <level> is found:
19
26
  error | warning | info | none (default: error)
27
+ --rules List the built-in rule ids (for "disable" in .codeep/review.*) and exit
28
+ --ai After the offline pass, ask your configured provider for a
29
+ contextual second opinion on the working-tree diff
30
+ (advisory; needs an API key; never affects the exit code)
20
31
  -h, --help Show this help
21
32
 
22
33
  Exit code: 0 when nothing at/above --fail-on is found, 1 otherwise.`;
23
34
  /** Parse `codeep review` argv (everything after the subcommand). Pure. */
24
35
  export function parseReviewArgs(argv) {
25
- const out = { files: [], json: false, failOn: 'error', help: false };
36
+ const out = { files: [], json: false, failOn: 'error', rules: false, ai: false, help: false };
26
37
  for (let i = 0; i < argv.length; i++) {
27
38
  const arg = argv[i];
28
39
  if (arg === '--json') {
29
40
  out.json = true;
30
41
  }
42
+ else if (arg === '--rules') {
43
+ out.rules = true;
44
+ }
45
+ else if (arg === '--ai') {
46
+ out.ai = true;
47
+ }
31
48
  else if (arg === '-h' || arg === '--help') {
32
49
  out.help = true;
33
50
  }
@@ -56,18 +73,44 @@ export function exitCodeForResult(result, failOn) {
56
73
  const tripped = result.issues.some((i) => (SEVERITY_RANK[i.severity] ?? 0) >= threshold);
57
74
  return tripped ? 1 : 0;
58
75
  }
76
+ /** Render the built-in rule ids for `--rules`. Pure. */
77
+ export function formatBuiltinRules() {
78
+ const rules = listBuiltinRules();
79
+ const idW = Math.max(...rules.map((r) => r.id.length));
80
+ const sevW = Math.max(...rules.map((r) => r.severity.length));
81
+ const out = [
82
+ 'Built-in review rules — put any id in "disable" in .codeep/review.yml|json:',
83
+ '',
84
+ ];
85
+ for (const r of rules) {
86
+ out.push(` ${r.id.padEnd(idW)} ${r.severity.padEnd(sevW)} ${r.description}`);
87
+ }
88
+ return out.join('\n');
89
+ }
59
90
  /**
60
91
  * Orchestrate a headless review and return the process exit code. Side effects
61
- * (filesystem, stdout) live behind `deps` so the flow is unit-testable.
92
+ * (filesystem, stdout, provider call) live behind `deps` so the flow is
93
+ * unit-testable. The exit code is ALWAYS deterministic — `--ai` is advisory.
62
94
  */
63
- export function runHeadlessReview(argv, deps = defaultDeps()) {
95
+ export async function runHeadlessReview(argv, deps = defaultDeps()) {
64
96
  const args = parseReviewArgs(argv);
65
97
  if (args.help) {
66
98
  deps.write(REVIEW_HELP);
67
99
  return 0;
68
100
  }
101
+ if (args.rules) {
102
+ deps.write(deps.listRules());
103
+ return 0;
104
+ }
69
105
  const result = deps.review(args.files.length ? args.files : undefined);
70
- deps.write(args.json ? JSON.stringify(result, null, 2) : formatReviewResult(result));
106
+ const aiText = args.ai ? await deps.aiReview(result) : null;
107
+ if (args.json) {
108
+ deps.write(JSON.stringify(args.ai ? { ...result, aiReview: aiText } : result, null, 2));
109
+ }
110
+ else {
111
+ const md = formatReviewResult(result);
112
+ deps.write(args.ai ? appendAiSection(md, aiText, deps.aiMeta()) : md);
113
+ }
71
114
  return exitCodeForResult(result, args.failOn);
72
115
  }
73
116
  // Only the reviewer's `.root` is read, so a minimal context rooted at cwd is
@@ -84,8 +127,41 @@ function minimalContext(root) {
84
127
  };
85
128
  }
86
129
  function defaultDeps() {
130
+ const cwd = process.cwd();
87
131
  return {
88
- review: (files) => performCodeReview(minimalContext(process.cwd()), files),
132
+ review: (files) => performCodeReview(minimalContext(cwd), files),
89
133
  write: (text) => process.stdout.write(text + '\n'),
134
+ listRules: () => formatBuiltinRules(),
135
+ aiMeta: () => {
136
+ try {
137
+ return { provider: getCurrentProvider().name, model: String(config.get('model') || '') };
138
+ }
139
+ catch {
140
+ return {};
141
+ }
142
+ },
143
+ aiReview: async (result) => {
144
+ try {
145
+ const { loadAllApiKeys, isConfigured } = await import('../config/index.js');
146
+ await loadAllApiKeys();
147
+ if (!isConfigured()) {
148
+ process.stderr.write('codeep review --ai: no API key configured for the current provider; skipping the AI pass (deterministic results below).\n');
149
+ return null;
150
+ }
151
+ const { getGitDiff } = await import('./git.js');
152
+ const { chat } = await import('../api/index.js');
153
+ const d = getGitDiff(false, cwd);
154
+ const diff = d.success && d.diff ? d.diff.slice(0, 50000) : '(no textual diff available — reviewing the deterministic findings only)';
155
+ // Fold the prompt + findings into the user message (not a system-role
156
+ // history entry): chat() does not hoist a history `system` entry into
157
+ // the Anthropic top-level `system` field, so it would be dropped on
158
+ // Anthropic-protocol providers. As the user message it reaches every provider.
159
+ const message = `${getReviewSystemPrompt(result)}\n\n## Diff under review\n\`\`\`diff\n${diff}\n\`\`\``;
160
+ return await chat(message, []);
161
+ }
162
+ catch {
163
+ return null; // never hard-fail the review on an AI error
164
+ }
165
+ },
90
166
  };
91
167
  }
@@ -9,6 +9,9 @@ export interface SecureStorage {
9
9
  setApiKey(providerId: string, apiKey: string): Promise<void>;
10
10
  deleteApiKey(providerId: string): Promise<void>;
11
11
  hasApiKey(providerId: string): Promise<boolean>;
12
+ /** Whether the OS keychain is usable (vs the plaintext-config fallback).
13
+ * Optional — only SmartStorage (what createSecureStorage returns) implements it. */
14
+ isKeychainAvailable?(): Promise<boolean>;
12
15
  }
13
16
  /**
14
17
  * Migrate existing plain-text API keys to keychain
@@ -124,6 +124,10 @@ class SmartStorage {
124
124
  }
125
125
  this.keychainTested = true;
126
126
  }
127
+ async isKeychainAvailable() {
128
+ await this.ensureKeychainTested();
129
+ return this.useKeychain;
130
+ }
127
131
  async getApiKey(providerId) {
128
132
  await this.ensureKeychainTested();
129
133
  if (this.useKeychain) {
@@ -0,0 +1,10 @@
1
+ import type { RuleDef } from './codeReview';
2
+ export interface ReviewConfig {
3
+ rules: RuleDef[];
4
+ disabled: Set<string>;
5
+ include: string[];
6
+ exclude: string[];
7
+ }
8
+ /** Convert a simple glob (`**`, `*`, `?`) into an anchored RegExp over posix paths. */
9
+ export declare function globToRegExp(glob: string): RegExp;
10
+ export declare function loadReviewConfig(projectRoot: string): ReviewConfig | null;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Project-level review configuration: `.codeep/review.json`.
3
+ *
4
+ * Lets a repo extend the deterministic reviewer with its own rules, disable
5
+ * built-in rules by id, and scope which files are reviewed — all checked into
6
+ * the repo so the CLI (`codeep review`) and the GitHub Action enforce the same
7
+ * conventions with zero LLM cost. Loading is fully defensive: a missing,
8
+ * malformed, or partially-invalid config never throws — bad entries are skipped
9
+ * with a warning and the review proceeds with whatever is valid.
10
+ *
11
+ * Shape:
12
+ * {
13
+ * "rules": [
14
+ * { "id": "no-foo", "pattern": "\\bfoo\\(", "flags": "gi",
15
+ * "category": "bug", "severity": "warning",
16
+ * "message": "Avoid foo()", "suggestion": "Use bar()",
17
+ * "extensions": [".ts", ".js"] }
18
+ * ],
19
+ * "disable": ["eval-usage", "todo-comment"],
20
+ * "include": ["src/**"],
21
+ * "exclude": ["vendor/**", "dist/**"]
22
+ * }
23
+ */
24
+ import { existsSync, readFileSync } from 'fs';
25
+ import { join } from 'path';
26
+ import yaml from 'js-yaml';
27
+ // Candidate config files, in precedence order: YAML preferred (nicer for a
28
+ // human-authored rules file — comments, single-quoted regex), JSON as fallback.
29
+ const CONFIG_PATHS = ['.codeep/review.yml', '.codeep/review.yaml', '.codeep/review.json'];
30
+ const MAX_RULES = 200;
31
+ const VALID_CATEGORIES = [
32
+ 'security', 'performance', 'maintainability', 'bug', 'style', 'types', 'best-practice', 'documentation',
33
+ ];
34
+ const VALID_SEVERITIES = ['error', 'warning', 'info', 'suggestion'];
35
+ /** Convert a simple glob (`**`, `*`, `?`) into an anchored RegExp over posix paths. */
36
+ export function globToRegExp(glob) {
37
+ // Escape regex metacharacters but keep the glob wildcards * ? for translation.
38
+ // Function replacer (not `$&`) so a literal `$` in a path can't be mangled.
39
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, (m) => '\\' + m);
40
+ // Plain ASCII sentinels that won't appear in a real glob; split/join avoids
41
+ // any `$`-replacement pitfalls when substituting the regex fragments.
42
+ const DSTAR_SLASH = '__CODEEP_DSTAR_SLASH__';
43
+ const DSTAR = '__CODEEP_DSTAR__';
44
+ const body = escaped
45
+ .replace(/\*\*\//g, DSTAR_SLASH) // **/ → zero or more directory segments
46
+ .replace(/\*\*/g, DSTAR) // ** → anything, including slashes
47
+ .replace(/\*/g, '[^/]*') // * → within a single path segment
48
+ .replace(/\?/g, '[^/]') // ? → a single non-slash char
49
+ .split(DSTAR_SLASH).join('(?:.*/)?')
50
+ .split(DSTAR).join('.*');
51
+ return new RegExp('^' + body + '$');
52
+ }
53
+ function asStringArray(v) {
54
+ return Array.isArray(v)
55
+ ? v.filter((x) => typeof x === 'string' && x.length > 0)
56
+ : [];
57
+ }
58
+ export function loadReviewConfig(projectRoot) {
59
+ // First existing candidate wins (.yml > .yaml > .json).
60
+ let chosen = null;
61
+ let text = '';
62
+ for (const candidate of CONFIG_PATHS) {
63
+ const fp = join(projectRoot, candidate);
64
+ if (existsSync(fp)) {
65
+ chosen = candidate;
66
+ try {
67
+ text = readFileSync(fp, 'utf-8');
68
+ }
69
+ catch {
70
+ return null;
71
+ }
72
+ break;
73
+ }
74
+ }
75
+ if (!chosen)
76
+ return null;
77
+ const isJson = chosen.endsWith('.json');
78
+ let data;
79
+ try {
80
+ data = isJson ? JSON.parse(text) : yaml.load(text);
81
+ }
82
+ catch {
83
+ console.warn(`[codeep] Ignoring ${chosen}: not valid ${isJson ? 'JSON' : 'YAML'}.`);
84
+ return null;
85
+ }
86
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
87
+ console.warn(`[codeep] Ignoring ${chosen}: expected a top-level object.`);
88
+ return null;
89
+ }
90
+ const cfg = data;
91
+ const disabled = new Set(asStringArray(cfg.disable));
92
+ const include = asStringArray(cfg.include);
93
+ const exclude = asStringArray(cfg.exclude);
94
+ const rules = [];
95
+ const rawRules = Array.isArray(cfg.rules) ? cfg.rules.slice(0, MAX_RULES) : [];
96
+ for (const raw of rawRules) {
97
+ if (!raw || typeof raw !== 'object')
98
+ continue;
99
+ const r = raw;
100
+ const id = typeof r.id === 'string' && r.id.trim() ? r.id.trim() : null;
101
+ const message = typeof r.message === 'string' && r.message.trim() ? r.message.trim() : null;
102
+ const patternSrc = typeof r.pattern === 'string' && r.pattern ? r.pattern : null;
103
+ if (!id || !message || !patternSrc) {
104
+ console.warn(`[codeep] Skipping a rule in ${chosen}: each rule needs id, pattern and message.`);
105
+ continue;
106
+ }
107
+ // Reject oversized patterns outright — keeps regex compilation/run bounded.
108
+ if (patternSrc.length > 1000) {
109
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: pattern is too long (>1000 chars).`);
110
+ continue;
111
+ }
112
+ // Conservative ReDoS screen: reject the classic catastrophic shape — a group
113
+ // ending in an unbounded quantifier that is itself quantified, e.g. (a+)+,
114
+ // (\d*)*, (.*)+, (x+){2,}. Not exhaustive (the GitHub Action also bounds
115
+ // wall-clock), but it blocks the common foot-guns in an untrusted review.json.
116
+ if (/\([^)]*[+*]\)\s*[+*{]/.test(patternSrc)) {
117
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: nested quantifiers risk catastrophic backtracking (ReDoS).`);
118
+ continue;
119
+ }
120
+ // Always include the global flag so every match in a file is found.
121
+ let flags = typeof r.flags === 'string' && /^[gimsuy]*$/.test(r.flags) ? r.flags : '';
122
+ if (!flags.includes('g'))
123
+ flags += 'g';
124
+ let pattern;
125
+ try {
126
+ pattern = new RegExp(patternSrc, flags);
127
+ }
128
+ catch {
129
+ console.warn(`[codeep] Skipping rule "${id}" in ${chosen}: invalid regex.`);
130
+ continue;
131
+ }
132
+ const category = VALID_CATEGORIES.includes(r.category)
133
+ ? r.category : 'best-practice';
134
+ const severity = VALID_SEVERITIES.includes(r.severity)
135
+ ? r.severity : 'warning';
136
+ const extensions = asStringArray(r.extensions);
137
+ rules.push({
138
+ id,
139
+ pattern,
140
+ category,
141
+ severity,
142
+ message,
143
+ suggestion: typeof r.suggestion === 'string' ? r.suggestion : undefined,
144
+ extensions: extensions.length ? extensions : undefined,
145
+ });
146
+ }
147
+ return { rules, disabled, include, exclude };
148
+ }
@@ -5,7 +5,9 @@ export interface VersionInfo {
5
5
  error?: string;
6
6
  }
7
7
  /**
8
- * Get current version from package.json
8
+ * Current Codeep version. Uses the build-time-baked VERSION constant (works in
9
+ * the bun-compiled binary, which has no package.json on disk), falling back to
10
+ * reading package.json for an unbuilt dev tree.
9
11
  */
10
12
  export declare function getCurrentVersion(): string;
11
13
  /**
@@ -1,14 +1,18 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { fileURLToPath } from 'url';
4
+ import { VERSION } from '../version.js';
4
5
  const __filename = fileURLToPath(import.meta.url);
5
6
  const __dirname = dirname(__filename);
6
7
  /**
7
- * Get current version from package.json
8
+ * Current Codeep version. Uses the build-time-baked VERSION constant (works in
9
+ * the bun-compiled binary, which has no package.json on disk), falling back to
10
+ * reading package.json for an unbuilt dev tree.
8
11
  */
9
12
  export function getCurrentVersion() {
13
+ if (VERSION)
14
+ return VERSION;
10
15
  try {
11
- // In built version, package.json is in parent directory
12
16
  const packagePath = join(__dirname, '../../package.json');
13
17
  const packageJson = JSON.parse(readFileSync(packagePath, 'utf-8'));
14
18
  return packageJson.version;
@@ -0,0 +1 @@
1
+ export declare const VERSION = "2.7.0";
@@ -0,0 +1,4 @@
1
+ // AUTO-GENERATED by scripts/gen-version.js — do not edit by hand.
2
+ // Baked from package.json at build time so the bun-compiled binary reports
3
+ // the right version (it has no package.json on disk to read at runtime).
4
+ export const VERSION = '2.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.5.2",
3
+ "version": "2.7.0",
4
4
  "description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,9 +8,9 @@
8
8
  "codeep": "bin/codeep.js"
9
9
  },
10
10
  "scripts": {
11
- "dev": "node --import tsx src/renderer/main.ts",
12
- "prepack": "tsc; node scripts/fix-imports.js",
13
- "build": "tsc && node scripts/fix-imports.js",
11
+ "dev": "node scripts/gen-version.js && node --import tsx src/renderer/main.ts",
12
+ "prepack": "node scripts/gen-version.js && tsc; node scripts/fix-imports.js",
13
+ "build": "node scripts/gen-version.js && tsc && node scripts/fix-imports.js",
14
14
  "start": "node dist/renderer/main.js",
15
15
  "demo:renderer": "node --import tsx src/renderer/demo.ts",
16
16
  "demo:app": "node --import tsx src/renderer/demo-app.ts",
@@ -42,10 +42,12 @@
42
42
  "dependencies": {
43
43
  "clipboardy": "^4.0.0",
44
44
  "conf": "^12.0.0",
45
+ "js-yaml": "^4.1.0",
45
46
  "keytar": "^7.9.0",
46
47
  "open": "^10.0.0"
47
48
  },
48
49
  "devDependencies": {
50
+ "@types/js-yaml": "^4.0.9",
49
51
  "@types/node": "^20.10.0",
50
52
  "@vitest/coverage-v8": "^4.0.18",
51
53
  "pkg": "^5.8.1",