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 +71 -0
- package/dist/acp/server.js +7 -7
- package/dist/acp/session.d.ts +2 -0
- package/dist/acp/session.js +1 -0
- package/dist/config/index.d.ts +4 -0
- package/dist/config/index.js +43 -2
- package/dist/renderer/main.js +8 -2
- package/dist/utils/agent.d.ts +12 -0
- package/dist/utils/agent.js +18 -5
- package/dist/utils/codeReview.d.ts +35 -0
- package/dist/utils/codeReview.js +98 -9
- package/dist/utils/gitHookInstaller.d.ts +26 -0
- package/dist/utils/gitHookInstaller.js +168 -0
- package/dist/utils/headlessReview.d.ts +17 -3
- package/dist/utils/headlessReview.js +82 -6
- package/dist/utils/keychain.d.ts +3 -0
- package/dist/utils/keychain.js +4 -0
- package/dist/utils/reviewConfig.d.ts +10 -0
- package/dist/utils/reviewConfig.js +148 -0
- package/dist/utils/update.d.ts +3 -1
- package/dist/utils/update.js +6 -2
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/package.json +6 -4
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
|
```
|
package/dist/acp/server.js
CHANGED
|
@@ -973,11 +973,10 @@ export function startAcpServer() {
|
|
|
973
973
|
});
|
|
974
974
|
};
|
|
975
975
|
resetTokenTracking();
|
|
976
|
-
//
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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();
|
package/dist/acp/session.d.ts
CHANGED
|
@@ -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;
|
package/dist/acp/session.js
CHANGED
|
@@ -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.
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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. */
|
package/dist/config/index.js
CHANGED
|
@@ -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
|
|
421
|
-
//
|
|
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);
|
package/dist/renderer/main.js
CHANGED
|
@@ -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
|
|
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
|
|
package/dist/utils/agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/agent.js
CHANGED
|
@@ -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 =
|
|
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;
|
package/dist/utils/codeReview.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
200
|
+
while ((match = regex.exec(scannable)) !== null) {
|
|
172
201
|
// Find line number
|
|
173
|
-
const beforeMatch =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
}
|
package/dist/utils/keychain.d.ts
CHANGED
|
@@ -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
|
package/dist/utils/keychain.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/utils/update.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export interface VersionInfo {
|
|
|
5
5
|
error?: string;
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
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
|
/**
|
package/dist/utils/update.js
CHANGED
|
@@ -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
|
-
*
|
|
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";
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "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",
|