pika-review 2.1.0 → 2.2.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 +55 -8
- package/dist/cmd/hook.js +85 -0
- package/dist/cmd/models.js +68 -0
- package/dist/cmd/rules.js +114 -0
- package/dist/cmd/scan.js +60 -14
- package/dist/core/ai.js +19 -4
- package/dist/core/reporter.js +331 -83
- package/dist/core/scanner.js +12 -4
- package/dist/index.js +54 -19
- package/dist/utils/config.js +17 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,12 +11,15 @@ Pika Review is a high-performance CLI tool designed to perform surgical code rev
|
|
|
11
11
|
## 🚀 Key Features
|
|
12
12
|
|
|
13
13
|
- **🧠 Architecture Rules Engine**: Enforce project-specific standards via `.pika-rules.md`.
|
|
14
|
+
- **🦙 Native Local Ollama Support**: Run 100% private, free, offline scans using local LLMs (e.g. Qwen, Llama).
|
|
15
|
+
- **🎛️ Local Model Selector**: Interactively choose and configure local models from the command line.
|
|
16
|
+
- **🛡️ Git Commit Safeguard Hook**: Prevent pushing high-severity compliance debt and vulnerabilities automatically.
|
|
14
17
|
- **📊 Health Dashboard**: Track architectural health trends over time with `pika-review stats`.
|
|
15
18
|
- **🎨 Premium Interactive Reports**: Immersive, dark-mode HTML reports for deep triage.
|
|
16
19
|
- **🔍 Smart Context Scanning**: Uses `-U10` context windows for higher AI reasoning accuracy.
|
|
17
20
|
- **🛡️ "Pika-Ignore" support**: Suppress specific lines using `// pika-ignore` comments.
|
|
18
21
|
- **⚡ Parallel Orchestration**: Scans multiple files concurrently with `p-limit`.
|
|
19
|
-
- **🌍 Provider Agnostic**: Works with OpenAI, Claude, Grok, or any OpenAI-compatible endpoint.
|
|
22
|
+
- **🌍 Provider Agnostic**: Works with OpenAI, Claude, Grok, local Ollama, or any OpenAI-compatible endpoint.
|
|
20
23
|
|
|
21
24
|
---
|
|
22
25
|
|
|
@@ -80,13 +83,57 @@ pika-review stats
|
|
|
80
83
|
|
|
81
84
|
## 🔍 Usage
|
|
82
85
|
|
|
83
|
-
| Command | Description
|
|
84
|
-
| :---------------------- |
|
|
85
|
-
| `pika-review scan` | Scan staged git changes (Default)
|
|
86
|
-
| `pika-review scan -i` | Interactive file selection mode
|
|
87
|
-
| `pika-review view` | Open the latest interactive report
|
|
88
|
-
| `pika-review stats` | View architectural health dashboard
|
|
89
|
-
| `pika-review
|
|
86
|
+
| Command | Description |
|
|
87
|
+
| :---------------------- | :----------------------------------------------------------- |
|
|
88
|
+
| `pika-review scan` | Scan staged git changes (Default) |
|
|
89
|
+
| `pika-review scan -i` | Interactive file selection mode |
|
|
90
|
+
| `pika-review view` | Open the latest interactive report |
|
|
91
|
+
| `pika-review stats` | View architectural health trends and scan dashboard |
|
|
92
|
+
| `pika-review models` | Interactively select and configure local Ollama models |
|
|
93
|
+
| `pika-review hook <act>`| Install (`install`) or uninstall (`uninstall`) Git safeguards |
|
|
94
|
+
| `pika-review rules -g` | Auto-generate architectural `.pika-rules.md` guidelines |
|
|
95
|
+
| `pika-review scan --ci` | Fail pipeline on Critical/High issues |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 🦙 Local Ollama & Offline Setup
|
|
100
|
+
|
|
101
|
+
Pika Review fully supports local, 100% private, offline code reviews via [Ollama](https://ollama.com).
|
|
102
|
+
|
|
103
|
+
### 1. Configure Ollama Provider
|
|
104
|
+
Initialize your configuration:
|
|
105
|
+
```bash
|
|
106
|
+
pika-review init
|
|
107
|
+
```
|
|
108
|
+
Open `~/.pika-review.yaml` and configure the Ollama provider:
|
|
109
|
+
```yaml
|
|
110
|
+
ai:
|
|
111
|
+
provider: "ollama"
|
|
112
|
+
model: "qwen2.5-coder:7b" # Or your pulled model
|
|
113
|
+
baseURL: "http://localhost:11434/v1"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### 2. Interactive Local Setup
|
|
117
|
+
To avoid editing files manually, you can manage everything directly from the command line:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Discover local pulled models and switch active model instantly
|
|
121
|
+
pika-review models
|
|
122
|
+
|
|
123
|
+
# Automatically bootstrap customized architectural rules for your tech stack
|
|
124
|
+
pika-review rules --generate
|
|
125
|
+
|
|
126
|
+
# Register git safeguard hooks to run scans automatically before commits
|
|
127
|
+
pika-review hook install
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## 🤝 Contributing
|
|
133
|
+
|
|
134
|
+
We welcome contributions from the community to help make Pika Review the ultimate architectural code reviewer!
|
|
135
|
+
|
|
136
|
+
Please read our **[Contributing Guide](CONTRIBUTING.md)** for details on how to set up local development, run compiler checks, write custom CLI commands, and submit high-quality Pull Requests.
|
|
90
137
|
|
|
91
138
|
---
|
|
92
139
|
|
package/dist/cmd/hook.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* CLI Command Action: hook [action]
|
|
7
|
+
* Manages the Git pre-commit hook installation/removal.
|
|
8
|
+
*/
|
|
9
|
+
export async function hookAction(action) {
|
|
10
|
+
const gitPath = path.join(process.cwd(), ".git");
|
|
11
|
+
if (!fs.existsSync(gitPath)) {
|
|
12
|
+
logger.error("Not a Git repository.");
|
|
13
|
+
logger.dim("Pika Review Git hooks can only be installed in the root of a Git repository.");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const hooksPath = path.join(gitPath, "hooks");
|
|
17
|
+
const hookFile = path.join(hooksPath, "pre-commit");
|
|
18
|
+
// Ensure hooks directory exists
|
|
19
|
+
if (!fs.existsSync(hooksPath)) {
|
|
20
|
+
fs.mkdirSync(hooksPath, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
const HOOK_SCRIPT = `#!/bin/sh
|
|
23
|
+
# Pika Review - Architectural Sentinel Git Safeguard Hook
|
|
24
|
+
echo "🦊 Running local architectural compliance checks..."
|
|
25
|
+
pika-review scan --ci
|
|
26
|
+
|
|
27
|
+
EXIT_CODE=$?
|
|
28
|
+
if [ $EXIT_CODE -ne 0 ]; then
|
|
29
|
+
echo "❌ [Pika Review] Commit rejected due to high-severity compliance anomalies."
|
|
30
|
+
exit $EXIT_CODE
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
exit 0
|
|
34
|
+
`;
|
|
35
|
+
if (action === "install") {
|
|
36
|
+
let backupCreated = false;
|
|
37
|
+
if (fs.existsSync(hookFile)) {
|
|
38
|
+
const content = fs.readFileSync(hookFile, "utf-8");
|
|
39
|
+
if (content.includes("Pika Review")) {
|
|
40
|
+
logger.info("Pika Review hook is already installed!");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Create backup of existing hook
|
|
44
|
+
const backupPath = `${hookFile}.bak`;
|
|
45
|
+
fs.writeFileSync(backupPath, content);
|
|
46
|
+
backupCreated = true;
|
|
47
|
+
logger.info(`Existing hook backed up to ${chalk.cyan("pre-commit.bak")}`);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(hookFile, HOOK_SCRIPT, { mode: 0o755 });
|
|
51
|
+
logger.success("Git pre-commit hook installed successfully!");
|
|
52
|
+
logger.info(`Path: ${chalk.dim(hookFile)}`);
|
|
53
|
+
logger.dim("Pika Review will now scan your staged files automatically before every commit.");
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
logger.error(`Failed to install Git hook: ${e.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else if (action === "uninstall") {
|
|
60
|
+
if (!fs.existsSync(hookFile)) {
|
|
61
|
+
logger.warn("No pre-commit hook found to uninstall.");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const content = fs.readFileSync(hookFile, "utf-8");
|
|
65
|
+
if (!content.includes("Pika Review")) {
|
|
66
|
+
logger.warn("The existing pre-commit hook was not installed by Pika Review. Leaving it untouched.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
fs.unlinkSync(hookFile);
|
|
71
|
+
logger.success("Pika Review Git pre-commit hook uninstalled successfully.");
|
|
72
|
+
// Restore backup if it exists
|
|
73
|
+
const backupPath = `${hookFile}.bak`;
|
|
74
|
+
if (fs.existsSync(backupPath)) {
|
|
75
|
+
fs.renameSync(backupPath, hookFile);
|
|
76
|
+
// Make sure it remains executable
|
|
77
|
+
fs.chmodSync(hookFile, 0o755);
|
|
78
|
+
logger.success("Restored previous pre-commit hook from backup!");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
logger.error(`Failed to uninstall Git hook: ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import prompts from "prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getConfig, saveConfig } from "../utils/config.js";
|
|
4
|
+
import { logger } from "../utils/logger.js";
|
|
5
|
+
/**
|
|
6
|
+
* CLI Command Action: models
|
|
7
|
+
* Queries local Ollama daemon for available models and updates active configuration.
|
|
8
|
+
*/
|
|
9
|
+
export async function modelsAction() {
|
|
10
|
+
const config = getConfig();
|
|
11
|
+
// Extract base host from configuration baseURL or fallback to localhost
|
|
12
|
+
let host = "http://localhost:11434";
|
|
13
|
+
if (config.ai.baseURL) {
|
|
14
|
+
try {
|
|
15
|
+
const url = new URL(config.ai.baseURL);
|
|
16
|
+
host = `${url.protocol}//${url.host}`;
|
|
17
|
+
}
|
|
18
|
+
catch (e) { }
|
|
19
|
+
}
|
|
20
|
+
logger.info(`Connecting to Ollama daemon at ${chalk.cyan(host)}...`);
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(`${host}/api/tags`);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
throw new Error(`Server returned HTTP ${res.status}`);
|
|
25
|
+
}
|
|
26
|
+
const data = (await res.json());
|
|
27
|
+
if (!data.models || data.models.length === 0) {
|
|
28
|
+
logger.warn(`No models found inside your local Ollama instance.`);
|
|
29
|
+
logger.info(`Try pulling a model first using: ${chalk.yellow("ollama pull llama3")}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const choices = data.models.map((m) => {
|
|
33
|
+
const sizeGB = m.size ? `${(m.size / (1024 * 1024 * 1024)).toFixed(2)} GB` : "Unknown size";
|
|
34
|
+
const paramSize = m.details?.parameter_size ? ` [${m.details.parameter_size}]` : "";
|
|
35
|
+
return {
|
|
36
|
+
title: `${chalk.bold(m.name)} ${chalk.dim(`(${sizeGB}${paramSize})`)}`,
|
|
37
|
+
value: m.name,
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
const response = await prompts({
|
|
41
|
+
type: "select",
|
|
42
|
+
name: "model",
|
|
43
|
+
message: "Select your active local Ollama model:",
|
|
44
|
+
choices,
|
|
45
|
+
initial: 0,
|
|
46
|
+
});
|
|
47
|
+
if (response.model) {
|
|
48
|
+
config.ai.provider = "ollama";
|
|
49
|
+
config.ai.model = response.model;
|
|
50
|
+
// Auto-set standard baseURL if empty or not set to localhost
|
|
51
|
+
if (!config.ai.baseURL || config.ai.baseURL.includes("cloudflare")) {
|
|
52
|
+
config.ai.baseURL = "http://localhost:11434/v1";
|
|
53
|
+
}
|
|
54
|
+
saveConfig(config);
|
|
55
|
+
logger.success(`Configuration updated successfully!`);
|
|
56
|
+
logger.info(`Active Model: ${chalk.green(response.model)}`);
|
|
57
|
+
logger.info(`AI Provider: ${chalk.cyan("ollama")}`);
|
|
58
|
+
logger.info(`API Base URL: ${chalk.cyan(config.ai.baseURL)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
logger.error(`Could not connect to Ollama daemon.`);
|
|
63
|
+
console.log(chalk.dim(`\n💡 To solve this, make sure:`));
|
|
64
|
+
console.log(` 1. Ollama is installed on your machine (https://ollama.com).`);
|
|
65
|
+
console.log(` 2. The daemon is running (${chalk.yellow("ollama serve")} or desktop application).`);
|
|
66
|
+
console.log(` 3. Your config baseURL is pointing to your active daemon (current: ${chalk.red(host)}).\n`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import prompts from "prompts";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import OpenAI from "openai";
|
|
6
|
+
import { getConfig } from "../utils/config.js";
|
|
7
|
+
import { listProjectFiles } from "../core/scanner.js";
|
|
8
|
+
import { logger } from "../utils/logger.js";
|
|
9
|
+
/**
|
|
10
|
+
* CLI Command Action: rules generate
|
|
11
|
+
* Uses the configured AI provider to bootstrap architectural rules tailored to the current codebase.
|
|
12
|
+
*/
|
|
13
|
+
export async function rulesAction() {
|
|
14
|
+
const config = getConfig();
|
|
15
|
+
const isOllama = config.ai.provider === "ollama";
|
|
16
|
+
if (!isOllama && (!config.ai.apiKey || !config.ai.accountId)) {
|
|
17
|
+
logger.error("Missing AI credentials in ~/.pika-review.yaml.");
|
|
18
|
+
logger.info("Run 'pika-review init' to initialize config or set provider to 'ollama' for offline mode.");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const rulesPath = path.join(process.cwd(), ".pika-rules.md");
|
|
22
|
+
if (fs.existsSync(rulesPath)) {
|
|
23
|
+
const overwrite = await prompts({
|
|
24
|
+
type: "confirm",
|
|
25
|
+
name: "confirm",
|
|
26
|
+
message: "An existing .pika-rules.md already exists. Do you want to overwrite it?",
|
|
27
|
+
initial: false,
|
|
28
|
+
});
|
|
29
|
+
if (!overwrite.confirm) {
|
|
30
|
+
logger.info("Operation cancelled.");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const confirm = await prompts({
|
|
36
|
+
type: "confirm",
|
|
37
|
+
name: "confirm",
|
|
38
|
+
message: "Generate a custom, AI-crafted .pika-rules.md architectural guidelines guide for this codebase?",
|
|
39
|
+
initial: true,
|
|
40
|
+
});
|
|
41
|
+
if (!confirm.confirm) {
|
|
42
|
+
logger.info("Operation cancelled.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
logger.info("Analyzing codebase structure and dependencies...");
|
|
47
|
+
// 1. Gather package.json context
|
|
48
|
+
let projectTechStack = "";
|
|
49
|
+
try {
|
|
50
|
+
const packageJsonPath = path.join(process.cwd(), "package.json");
|
|
51
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
52
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
53
|
+
const deps = Object.keys(packageJson.dependencies || {}).join(", ");
|
|
54
|
+
const devDeps = Object.keys(packageJson.devDependencies || {}).join(", ");
|
|
55
|
+
projectTechStack += `Package Name: ${packageJson.name || "unknown"}\nDependencies: ${deps || "none"}\nDevDependencies: ${devDeps || "none"}\n`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch (e) { }
|
|
59
|
+
// 2. Gather file layout context
|
|
60
|
+
const files = listProjectFiles();
|
|
61
|
+
const fileSummary = files.slice(0, 30).map(f => ` - ${f}`).join("\n");
|
|
62
|
+
const projectStructure = `Detected files (subset):\n${fileSummary}\nTotal files discovered: ${files.length}\n`;
|
|
63
|
+
logger.info(`Synthesizing tailored rules using AI model (${chalk.green(config.ai.model)})...`);
|
|
64
|
+
const prompt = `You are an Elite Senior Software Architect and Compliance Officer.
|
|
65
|
+
Your task is to write a highly detailed, professional, and practical architectural rules file called ".pika-rules.md" for a project with the following tech stack and structure:
|
|
66
|
+
|
|
67
|
+
<project_tech_stack>
|
|
68
|
+
${projectTechStack || "Generic web/software codebase"}
|
|
69
|
+
</project_tech_stack>
|
|
70
|
+
|
|
71
|
+
<project_structure>
|
|
72
|
+
${projectStructure}
|
|
73
|
+
</project_structure>
|
|
74
|
+
|
|
75
|
+
Instructions:
|
|
76
|
+
- Write the output in clean, professional Markdown format.
|
|
77
|
+
- Establish 5 to 8 strict, concrete, actionable architectural rules tailored to the libraries, patterns, and folder layout of this specific project (e.g. naming conventions, folder segregation, API patterns, state management, security).
|
|
78
|
+
- Provide a brief justification for each rule.
|
|
79
|
+
- Do NOT output any preamble, greeting, markdown backticks wrapper, or postscript.
|
|
80
|
+
- Start directly with "# Pika Architectural Rules" and start rules lists immediately.`;
|
|
81
|
+
const baseURL = config.ai.baseURL && config.ai.baseURL.trim()
|
|
82
|
+
? config.ai.baseURL
|
|
83
|
+
: (isOllama
|
|
84
|
+
? "http://localhost:11434/v1"
|
|
85
|
+
: `https://api.cloudflare.com/client/v4/accounts/${config.ai.accountId}/ai/v1`);
|
|
86
|
+
const openai = new OpenAI({
|
|
87
|
+
apiKey: config.ai.apiKey || "ollama",
|
|
88
|
+
baseURL,
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
const response = await openai.chat.completions.create({
|
|
92
|
+
model: config.ai.model,
|
|
93
|
+
messages: [{ role: "user", content: prompt }],
|
|
94
|
+
temperature: 0.3,
|
|
95
|
+
max_tokens: 1500,
|
|
96
|
+
});
|
|
97
|
+
let rawMarkdown = response.choices[0]?.message?.content || "";
|
|
98
|
+
// Clean code block ticks if LLM ignored instructions
|
|
99
|
+
rawMarkdown = rawMarkdown
|
|
100
|
+
.replace(/```markdown/g, "")
|
|
101
|
+
.replace(/```/g, "")
|
|
102
|
+
.trim();
|
|
103
|
+
if (!rawMarkdown.startsWith("#")) {
|
|
104
|
+
rawMarkdown = `# Pika Architectural Rules\n\n${rawMarkdown}`;
|
|
105
|
+
}
|
|
106
|
+
fs.writeFileSync(rulesPath, rawMarkdown, "utf-8");
|
|
107
|
+
logger.success(".pika-rules.md generated successfully!");
|
|
108
|
+
logger.info(`Location: ${chalk.dim(rulesPath)}`);
|
|
109
|
+
logger.dim("The local Pika Review engine will now enforce these rules during future scans!");
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
logger.error(`Failed to generate rules: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/cmd/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import prompts from "prompts";
|
|
2
2
|
import chalk from "chalk";
|
|
3
|
+
import { execSync } from "child_process";
|
|
3
4
|
import { runScan, getDiffFiles, listProjectFiles } from "../core/scanner.js";
|
|
4
5
|
import { logger } from "../utils/logger.js";
|
|
5
6
|
/**
|
|
@@ -32,35 +33,80 @@ export async function scanAction(files, options) {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
|
|
36
|
-
if (!
|
|
36
|
+
const { markdownReports, htmlReport, findings } = await runScan(target, isCI, targets);
|
|
37
|
+
if (!markdownReports || markdownReports.length === 0) {
|
|
37
38
|
if (!isCI) {
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
console.log(`\n ${chalk.green("✓")} ${chalk.bold("Scan complete. No architectural anomalies or security risks detected.")}`);
|
|
40
|
+
console.log(` ${chalk.dim("Try scanning with '--unstaged' if you have unstaged changes.")}\n`);
|
|
40
41
|
}
|
|
41
42
|
return;
|
|
42
43
|
}
|
|
43
44
|
if (!isCI) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Count findings by severity
|
|
46
|
+
let criticalCount = 0;
|
|
47
|
+
let highCount = 0;
|
|
48
|
+
let mediumCount = 0;
|
|
49
|
+
let lowCount = 0;
|
|
50
|
+
let totalIssues = 0;
|
|
51
|
+
findings.forEach((f) => {
|
|
52
|
+
totalIssues += f.reviews.length;
|
|
53
|
+
f.reviews.forEach((r) => {
|
|
54
|
+
if (r.severity === "Critical")
|
|
55
|
+
criticalCount++;
|
|
56
|
+
else if (r.severity === "High")
|
|
57
|
+
highCount++;
|
|
58
|
+
else if (r.severity === "Medium")
|
|
59
|
+
mediumCount++;
|
|
60
|
+
else if (r.severity === "Low")
|
|
61
|
+
lowCount++;
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
// Sleek summary box
|
|
65
|
+
console.log(`\n ${chalk.cyan.bold("◆ Scan Completed Successfully")}`);
|
|
66
|
+
console.log(` ${chalk.dim("─".repeat(34))}`);
|
|
67
|
+
console.log(` ${chalk.bold("Files Audited:")} ${findings.length}`);
|
|
68
|
+
console.log(` ${chalk.bold("Total Findings:")} ${totalIssues}`);
|
|
69
|
+
if (totalIssues > 0) {
|
|
70
|
+
console.log(`\n ${chalk.bold("Severity Breakdown:")}`);
|
|
71
|
+
if (criticalCount > 0)
|
|
72
|
+
console.log(` 🚨 ${chalk.red.bold(criticalCount)} Critical`);
|
|
73
|
+
if (highCount > 0)
|
|
74
|
+
console.log(` 🔥 ${chalk.yellow.bold(highCount)} High`);
|
|
75
|
+
if (mediumCount > 0)
|
|
76
|
+
console.log(` ⚠️ ${chalk.blue.bold(mediumCount)} Medium`);
|
|
77
|
+
if (lowCount > 0)
|
|
78
|
+
console.log(` 📝 ${chalk.gray.bold(lowCount)} Low`);
|
|
79
|
+
}
|
|
80
|
+
console.log(`\n ${chalk.bold("Obsidian Report:")} ${chalk.cyan.underline(`file://${htmlReport}`)}\n`);
|
|
47
81
|
const response = await prompts({
|
|
48
82
|
type: "select",
|
|
49
83
|
name: "action",
|
|
50
|
-
message: "
|
|
84
|
+
message: "Choose post-scan action:",
|
|
51
85
|
choices: [
|
|
52
|
-
{ title: "
|
|
86
|
+
{ title: "✨ Open Interactive HTML Dashboard (Recommended)", value: "html" },
|
|
87
|
+
{ title: "📂 List generated Markdown file paths", value: "markdown" },
|
|
53
88
|
{ title: "🛑 Exit and start refactoring", value: "exit" },
|
|
54
89
|
],
|
|
55
90
|
initial: 0,
|
|
56
91
|
});
|
|
57
|
-
if (response.action === "
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
if (response.action === "html") {
|
|
93
|
+
console.log(`\n ${chalk.cyan("→")} Opening interactive report in browser...`);
|
|
94
|
+
try {
|
|
95
|
+
const command = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
96
|
+
execSync(`${command} "${htmlReport}"`);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
console.log(` ${chalk.red("✖")} Failed to open browser automatically.`);
|
|
100
|
+
console.log(` ${chalk.dim(`Manually open: file://${htmlReport}`)}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (response.action === "markdown") {
|
|
104
|
+
console.log(`\n ${chalk.bold("Generated Markdown Artifacts:")}`);
|
|
105
|
+
markdownReports.forEach((r) => {
|
|
60
106
|
const fileName = r.split("/").pop();
|
|
61
|
-
console.log(`
|
|
107
|
+
console.log(` ${chalk.cyan("•")} ${chalk.bold(fileName)} ${chalk.dim(`(file://${r})`)}`);
|
|
62
108
|
});
|
|
63
|
-
console.log(chalk.dim("
|
|
109
|
+
console.log(`\n ${chalk.dim("Tip: Click any file:// link above to open directly in your editor.")}\n`);
|
|
64
110
|
}
|
|
65
111
|
}
|
|
66
112
|
}
|
package/dist/core/ai.js
CHANGED
|
@@ -18,6 +18,8 @@ export function extractJSON(raw) {
|
|
|
18
18
|
// Keep only safe whitespace: \n, \r, \t
|
|
19
19
|
return ["\n", "\r", "\t"].includes(match) ? match : "";
|
|
20
20
|
});
|
|
21
|
+
// Remove single-line comments that are not part of URLs
|
|
22
|
+
cleaned = cleaned.replace(/(?<!http:|https:)\/\/.*$/gm, "");
|
|
21
23
|
// Find the outermost JSON structure
|
|
22
24
|
const firstBrace = cleaned.indexOf("{");
|
|
23
25
|
const firstBracket = cleaned.indexOf("[");
|
|
@@ -36,6 +38,8 @@ export function extractJSON(raw) {
|
|
|
36
38
|
let snippet = cleaned.substring(start, end + 1);
|
|
37
39
|
// Heuristic: MoE models sometimes miss commas between objects in an array
|
|
38
40
|
snippet = snippet.replace(/\}\s*\{/g, "},{");
|
|
41
|
+
// Clean trailing commas before closing braces/brackets (which break JSON.parse)
|
|
42
|
+
snippet = snippet.replace(/,\s*(\}|\])/g, "$1");
|
|
39
43
|
// Heuristic Structural Recovery for Truncated Responses
|
|
40
44
|
const openBraces = (snippet.match(/\{/g) || []).length;
|
|
41
45
|
const closeBraces = (snippet.match(/\}/g) || []).length;
|
|
@@ -56,8 +60,15 @@ export function extractJSON(raw) {
|
|
|
56
60
|
*/
|
|
57
61
|
export async function analyzeDiff(diff, fileName) {
|
|
58
62
|
const config = getConfig();
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
const isOllama = config.ai.provider === "ollama";
|
|
64
|
+
const isCloudflare = config.ai.provider === "cloudflare";
|
|
65
|
+
// Relax credentials check: Only enforce accountId/apiKey for Cloudflare Workers AI
|
|
66
|
+
if (isCloudflare && (!config.ai.apiKey || !config.ai.accountId)) {
|
|
67
|
+
throw new Error("Missing Cloudflare provider credentials (apiKey, accountId) in ~/.pika-review.yaml");
|
|
68
|
+
}
|
|
69
|
+
// For standard API providers (OpenAI, DeepSeek, etc.), we only need the apiKey
|
|
70
|
+
if (!isOllama && !isCloudflare && !config.ai.apiKey) {
|
|
71
|
+
throw new Error("Missing API Key (apiKey) in ~/.pika-review.yaml");
|
|
61
72
|
}
|
|
62
73
|
// Rate Limit Safety: Truncate input to protect the daily free limit.
|
|
63
74
|
// 30,000 chars is roughly 7,500 tokens, leaving room for a detailed response.
|
|
@@ -68,9 +79,13 @@ export async function analyzeDiff(diff, fileName) {
|
|
|
68
79
|
: diff;
|
|
69
80
|
const baseURL = config.ai.baseURL && config.ai.baseURL.trim()
|
|
70
81
|
? config.ai.baseURL
|
|
71
|
-
:
|
|
82
|
+
: (isOllama
|
|
83
|
+
? "http://localhost:11434/v1"
|
|
84
|
+
: (isCloudflare
|
|
85
|
+
? `https://api.cloudflare.com/client/v4/accounts/${config.ai.accountId}/ai/v1`
|
|
86
|
+
: "https://api.openai.com/v1"));
|
|
72
87
|
const openai = new OpenAI({
|
|
73
|
-
apiKey: config.ai.apiKey,
|
|
88
|
+
apiKey: config.ai.apiKey || "ollama",
|
|
74
89
|
baseURL,
|
|
75
90
|
});
|
|
76
91
|
// Load custom architecture rules if they exist
|
package/dist/core/reporter.js
CHANGED
|
@@ -86,140 +86,266 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
86
86
|
<head>
|
|
87
87
|
<meta charset="UTF-8">
|
|
88
88
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
89
|
-
<title>Pika
|
|
89
|
+
<title>Pika Sentinel Report | ${projectName}</title>
|
|
90
90
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
91
91
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
92
|
-
<link href="https://fonts.googleapis.com/css2?family=
|
|
92
|
+
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
|
93
93
|
<style>
|
|
94
94
|
:root {
|
|
95
|
-
--bg: #
|
|
96
|
-
--sidebar-bg: #
|
|
97
|
-
--card-bg:
|
|
98
|
-
--
|
|
99
|
-
--text
|
|
100
|
-
--
|
|
101
|
-
--
|
|
102
|
-
--
|
|
103
|
-
--
|
|
104
|
-
--
|
|
105
|
-
--
|
|
95
|
+
--bg: #09090b;
|
|
96
|
+
--sidebar-bg: #0c0c0e;
|
|
97
|
+
--card-bg: rgba(255, 255, 255, 0.02);
|
|
98
|
+
--card-hover: rgba(255, 255, 255, 0.04);
|
|
99
|
+
--text: #f3f4f6;
|
|
100
|
+
--text-dim: #a1a1aa;
|
|
101
|
+
--accent: #00F0FF;
|
|
102
|
+
--accent-glow: rgba(0, 240, 255, 0.15);
|
|
103
|
+
--critical: #ef4444;
|
|
104
|
+
--high: #f97316;
|
|
105
|
+
--medium: #eab308;
|
|
106
|
+
--low: #10b981;
|
|
107
|
+
--border: rgba(255, 255, 255, 0.08);
|
|
108
|
+
--font-sans: 'Outfit', sans-serif;
|
|
109
|
+
--font-mono: 'Fira Code', monospace;
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
109
113
|
body {
|
|
110
|
-
font-family:
|
|
114
|
+
font-family: var(--font-sans);
|
|
111
115
|
background: var(--bg);
|
|
112
116
|
color: var(--text);
|
|
113
117
|
display: flex;
|
|
114
118
|
height: 100vh;
|
|
115
119
|
overflow: hidden;
|
|
120
|
+
-webkit-font-smoothing: antialiased;
|
|
116
121
|
}
|
|
117
122
|
|
|
118
|
-
/*
|
|
123
|
+
/* Ambient glows and high-tech grid overlays */
|
|
124
|
+
.bg-grid {
|
|
125
|
+
position: fixed;
|
|
126
|
+
top: 0; left: 0; width: 100%; height: 100%;
|
|
127
|
+
background-image:
|
|
128
|
+
linear-gradient(to right, rgba(255,255,255,0.01) 1px, transparent 1px),
|
|
129
|
+
linear-gradient(to bottom, rgba(255,255,255,0.01) 1px, transparent 1px);
|
|
130
|
+
background-size: 30px 30px;
|
|
131
|
+
pointer-events: none;
|
|
132
|
+
z-index: 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.bg-radial {
|
|
136
|
+
position: fixed;
|
|
137
|
+
top: -10%; right: -10%; width: 60vw; height: 60vw;
|
|
138
|
+
background: radial-gradient(circle, rgba(0, 240, 255, 0.03) 0%, transparent 70%);
|
|
139
|
+
pointer-events: none;
|
|
140
|
+
z-index: 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Sidebar container */
|
|
119
144
|
aside {
|
|
120
|
-
width:
|
|
145
|
+
width: 360px;
|
|
121
146
|
background: var(--sidebar-bg);
|
|
122
147
|
border-right: 1px solid var(--border);
|
|
123
148
|
display: flex;
|
|
124
149
|
flex-direction: column;
|
|
125
|
-
box-shadow: 10px 0
|
|
150
|
+
box-shadow: 10px 0 40px rgba(0,0,0,0.5);
|
|
126
151
|
z-index: 10;
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
.brand {
|
|
130
|
-
padding:
|
|
155
|
+
padding: 30px 24px;
|
|
131
156
|
border-bottom: 1px solid var(--border);
|
|
132
|
-
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 16px;
|
|
160
|
+
background: linear-gradient(180deg, rgba(0, 240, 255, 0.02), transparent);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.brand-logo {
|
|
164
|
+
width: 38px;
|
|
165
|
+
height: 38px;
|
|
166
|
+
filter: drop-shadow(0 0 6px rgba(0, 240, 255, 0.5));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.brand-text h1 {
|
|
170
|
+
font-size: 1.25rem;
|
|
171
|
+
font-weight: 800;
|
|
172
|
+
letter-spacing: -0.5px;
|
|
173
|
+
color: var(--text);
|
|
174
|
+
display: flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.brand-text h1 span {
|
|
180
|
+
color: var(--accent);
|
|
181
|
+
text-shadow: 0 0 10px rgba(0, 240, 255, 0.3);
|
|
133
182
|
}
|
|
134
183
|
|
|
135
|
-
.brand
|
|
136
|
-
|
|
184
|
+
.brand-text p {
|
|
185
|
+
font-size: 0.75rem;
|
|
186
|
+
color: var(--text-dim);
|
|
187
|
+
margin-top: 4px;
|
|
188
|
+
font-weight: 500;
|
|
189
|
+
}
|
|
137
190
|
|
|
138
191
|
.sidebar-stats {
|
|
139
192
|
padding: 20px 24px;
|
|
140
193
|
display: grid;
|
|
141
|
-
grid-template-columns:
|
|
142
|
-
gap:
|
|
194
|
+
grid-template-columns: repeat(3, 1fr);
|
|
195
|
+
gap: 10px;
|
|
143
196
|
border-bottom: 1px solid var(--border);
|
|
144
197
|
}
|
|
145
198
|
|
|
146
199
|
.stat-box {
|
|
147
|
-
background:
|
|
148
|
-
padding: 12px;
|
|
149
|
-
border-radius: 10px;
|
|
150
|
-
text-align: center;
|
|
200
|
+
background: var(--card-bg);
|
|
151
201
|
border: 1px solid var(--border);
|
|
202
|
+
padding: 12px 6px;
|
|
203
|
+
border-radius: 8px;
|
|
204
|
+
text-align: center;
|
|
205
|
+
transition: all 0.2s ease;
|
|
152
206
|
}
|
|
153
207
|
|
|
154
|
-
.stat-
|
|
155
|
-
|
|
208
|
+
.stat-box:hover {
|
|
209
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.stat-val {
|
|
213
|
+
display: block;
|
|
214
|
+
font-size: 1.15rem;
|
|
215
|
+
font-weight: 800;
|
|
216
|
+
color: var(--accent);
|
|
217
|
+
text-shadow: 0 0 8px rgba(0, 240, 255, 0.2);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.stat-box.crit-stat .stat-val {
|
|
221
|
+
color: var(--critical);
|
|
222
|
+
text-shadow: 0 0 8px rgba(239, 68, 68, 0.2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.stat-lab {
|
|
226
|
+
display: block;
|
|
227
|
+
font-size: 0.6rem;
|
|
228
|
+
font-weight: 700;
|
|
229
|
+
text-transform: uppercase;
|
|
230
|
+
color: var(--text-dim);
|
|
231
|
+
margin-top: 4px;
|
|
232
|
+
letter-spacing: 0.05em;
|
|
233
|
+
}
|
|
156
234
|
|
|
157
235
|
.file-list {
|
|
158
236
|
flex: 1;
|
|
159
237
|
overflow-y: auto;
|
|
160
|
-
padding: 16px;
|
|
238
|
+
padding: 20px 16px;
|
|
161
239
|
}
|
|
162
240
|
|
|
163
241
|
.file-item {
|
|
164
242
|
padding: 14px 16px;
|
|
165
|
-
border-radius:
|
|
243
|
+
border-radius: 10px;
|
|
166
244
|
cursor: pointer;
|
|
167
245
|
margin-bottom: 8px;
|
|
168
|
-
|
|
246
|
+
background: var(--card-bg);
|
|
247
|
+
border: 1px solid var(--border);
|
|
248
|
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
169
249
|
display: flex;
|
|
170
250
|
flex-direction: column;
|
|
171
|
-
gap:
|
|
172
|
-
|
|
251
|
+
gap: 8px;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.file-item:hover {
|
|
255
|
+
background: var(--card-hover);
|
|
256
|
+
border-color: rgba(0, 240, 255, 0.2);
|
|
257
|
+
transform: translateX(4px);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.file-item.active {
|
|
261
|
+
background: rgba(0, 240, 255, 0.04);
|
|
262
|
+
border-color: var(--accent);
|
|
263
|
+
box-shadow: 0 0 15px rgba(0, 240, 255, 0.08);
|
|
173
264
|
}
|
|
174
265
|
|
|
175
|
-
.file-item
|
|
176
|
-
|
|
177
|
-
|
|
266
|
+
.file-item .name {
|
|
267
|
+
font-size: 0.85rem;
|
|
268
|
+
font-family: var(--font-mono);
|
|
269
|
+
font-weight: 500;
|
|
270
|
+
word-break: break-all;
|
|
271
|
+
color: var(--text-dim);
|
|
272
|
+
transition: color 0.2s;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.file-item.active .name {
|
|
276
|
+
color: var(--accent);
|
|
277
|
+
text-shadow: 0 0 8px rgba(0, 240, 255, 0.4);
|
|
278
|
+
}
|
|
178
279
|
|
|
179
280
|
.severity-pills { display: flex; gap: 4px; }
|
|
180
|
-
.pill {
|
|
181
|
-
|
|
281
|
+
.pill {
|
|
282
|
+
font-size: 0.65rem;
|
|
283
|
+
padding: 2px 6px;
|
|
284
|
+
border-radius: 4px;
|
|
285
|
+
background: rgba(0,0,0,0.3);
|
|
286
|
+
font-weight: 700;
|
|
287
|
+
border: 1px solid rgba(255,255,255,0.05);
|
|
288
|
+
}
|
|
182
289
|
|
|
183
|
-
/* Main Content */
|
|
290
|
+
/* Main Content area */
|
|
184
291
|
main {
|
|
185
292
|
flex: 1;
|
|
186
293
|
overflow-y: auto;
|
|
187
|
-
padding: 60px;
|
|
188
|
-
|
|
294
|
+
padding: 60px 80px;
|
|
295
|
+
position: relative;
|
|
296
|
+
z-index: 5;
|
|
189
297
|
}
|
|
190
298
|
|
|
191
299
|
.content-container { max-width: 900px; margin: 0 auto; }
|
|
192
300
|
|
|
193
301
|
.page-header {
|
|
194
|
-
margin-bottom:
|
|
195
|
-
animation: fadeInDown 0.
|
|
302
|
+
margin-bottom: 40px;
|
|
303
|
+
animation: fadeInDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
196
304
|
}
|
|
197
305
|
|
|
198
306
|
@keyframes fadeInDown {
|
|
199
|
-
from { opacity: 0; transform: translateY(-
|
|
307
|
+
from { opacity: 0; transform: translateY(-12px); }
|
|
200
308
|
to { opacity: 1; transform: translateY(0); }
|
|
201
309
|
}
|
|
202
310
|
|
|
203
|
-
.page-header h2 {
|
|
204
|
-
|
|
311
|
+
.page-header h2 {
|
|
312
|
+
font-size: 1.85rem;
|
|
313
|
+
font-family: var(--font-mono);
|
|
314
|
+
margin-bottom: 12px;
|
|
315
|
+
color: #fff;
|
|
316
|
+
letter-spacing: -0.5px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.page-header p { color: var(--text-dim); font-size: 0.95rem; }
|
|
205
320
|
|
|
321
|
+
/* Anomaly Findings Cards */
|
|
206
322
|
.issue-card {
|
|
207
323
|
background: var(--card-bg);
|
|
208
324
|
border: 1px solid var(--border);
|
|
209
|
-
border-radius:
|
|
210
|
-
padding:
|
|
211
|
-
margin-bottom:
|
|
325
|
+
border-radius: 16px;
|
|
326
|
+
padding: 30px;
|
|
327
|
+
margin-bottom: 24px;
|
|
212
328
|
position: relative;
|
|
213
|
-
box-shadow: 0 10px
|
|
214
|
-
transition:
|
|
329
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
|
330
|
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
331
|
+
animation: fadeInUp 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
@keyframes fadeInUp {
|
|
335
|
+
from { opacity: 0; transform: translateY(12px); }
|
|
336
|
+
to { opacity: 1; transform: translateY(0); }
|
|
215
337
|
}
|
|
216
338
|
|
|
217
|
-
.issue-card:hover {
|
|
339
|
+
.issue-card:hover {
|
|
340
|
+
transform: translateY(-2px);
|
|
341
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
342
|
+
box-shadow: 0 15px 35px rgba(0,0,0,0.4);
|
|
343
|
+
}
|
|
218
344
|
|
|
219
345
|
.card-accent {
|
|
220
346
|
position: absolute;
|
|
221
|
-
left: 0; top:
|
|
222
|
-
width:
|
|
347
|
+
left: 0; top: 30px; bottom: 30px;
|
|
348
|
+
width: 4px;
|
|
223
349
|
border-radius: 0 4px 4px 0;
|
|
224
350
|
}
|
|
225
351
|
|
|
@@ -236,46 +362,89 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
236
362
|
}
|
|
237
363
|
|
|
238
364
|
.severity-tag {
|
|
239
|
-
font-size: 0.
|
|
365
|
+
font-size: 0.7rem;
|
|
240
366
|
text-transform: uppercase;
|
|
241
|
-
font-weight:
|
|
242
|
-
padding:
|
|
243
|
-
border-radius:
|
|
367
|
+
font-weight: 850;
|
|
368
|
+
padding: 4px 12px;
|
|
369
|
+
border-radius: 6px;
|
|
244
370
|
letter-spacing: 0.05em;
|
|
245
371
|
}
|
|
246
372
|
|
|
247
|
-
.severity-tag.Critical { background: rgba(
|
|
248
|
-
.severity-tag.High { background: rgba(
|
|
249
|
-
.severity-tag.Medium { background: rgba(
|
|
250
|
-
.severity-tag.Low { background: rgba(
|
|
373
|
+
.severity-tag.Critical { background: rgba(239, 68, 68, 0.1); color: var(--critical); border: 1px solid rgba(239, 68, 68, 0.2); }
|
|
374
|
+
.severity-tag.High { background: rgba(249, 115, 22, 0.1); color: var(--high); border: 1px solid rgba(249, 115, 22, 0.2); }
|
|
375
|
+
.severity-tag.Medium { background: rgba(234, 179, 8, 0.1); color: var(--medium); border: 1px solid rgba(234, 179, 8, 0.2); }
|
|
376
|
+
.severity-tag.Low { background: rgba(16, 185, 129, 0.1); color: var(--low); border: 1px solid rgba(16, 185, 129, 0.2); }
|
|
251
377
|
|
|
252
|
-
.line-info { font-family:
|
|
378
|
+
.line-info { font-family: var(--font-mono); color: var(--text-dim); font-size: 0.85rem; }
|
|
253
379
|
|
|
254
380
|
.field-group { margin-bottom: 24px; }
|
|
381
|
+
.field-group:last-child { margin-bottom: 0; }
|
|
382
|
+
|
|
255
383
|
.field-label {
|
|
256
384
|
font-size: 0.7rem;
|
|
257
385
|
font-weight: 800;
|
|
258
386
|
color: var(--text-dim);
|
|
259
387
|
text-transform: uppercase;
|
|
260
388
|
letter-spacing: 0.1em;
|
|
261
|
-
margin-bottom:
|
|
389
|
+
margin-bottom: 8px;
|
|
262
390
|
display: flex;
|
|
263
391
|
align-items: center;
|
|
264
|
-
gap:
|
|
392
|
+
gap: 6px;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.field-content { line-height: 1.6; color: #d1d5db; font-size: 0.95rem; }
|
|
396
|
+
|
|
397
|
+
/* Recommendation Pre block with visual copy btn */
|
|
398
|
+
.pre-wrapper {
|
|
399
|
+
position: relative;
|
|
400
|
+
background: #040405;
|
|
401
|
+
border: 1px solid rgba(255,255,255,0.04);
|
|
402
|
+
border-radius: 10px;
|
|
403
|
+
margin-top: 10px;
|
|
404
|
+
overflow: hidden;
|
|
265
405
|
}
|
|
266
406
|
|
|
267
|
-
.
|
|
407
|
+
.pre-header {
|
|
408
|
+
background: rgba(255,255,255,0.02);
|
|
409
|
+
border-bottom: 1px solid rgba(255,255,255,0.04);
|
|
410
|
+
padding: 6px 16px;
|
|
411
|
+
display: flex;
|
|
412
|
+
justify-content: space-between;
|
|
413
|
+
align-items: center;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.pre-lang {
|
|
417
|
+
font-size: 0.65rem;
|
|
418
|
+
font-weight: 700;
|
|
419
|
+
color: var(--text-dim);
|
|
420
|
+
text-transform: uppercase;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.card-copy-btn {
|
|
424
|
+
background: none;
|
|
425
|
+
border: 1px solid rgba(255,255,255,0.08);
|
|
426
|
+
color: var(--text-dim);
|
|
427
|
+
font-family: var(--font-sans);
|
|
428
|
+
font-size: 0.65rem;
|
|
429
|
+
font-weight: 600;
|
|
430
|
+
padding: 3px 6px;
|
|
431
|
+
border-radius: 4px;
|
|
432
|
+
cursor: pointer;
|
|
433
|
+
transition: all 0.2s ease;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.card-copy-btn:hover {
|
|
437
|
+
border-color: rgba(0, 240, 255, 0.4);
|
|
438
|
+
color: var(--accent);
|
|
439
|
+
}
|
|
268
440
|
|
|
269
441
|
pre {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
font-size: 0.9rem;
|
|
275
|
-
margin-top: 15px;
|
|
442
|
+
padding: 16px;
|
|
443
|
+
font-family: var(--font-mono);
|
|
444
|
+
font-size: 0.85rem;
|
|
445
|
+
line-height: 1.5;
|
|
276
446
|
overflow-x: auto;
|
|
277
|
-
|
|
278
|
-
color: #e2e8f0;
|
|
447
|
+
color: #c7d2fe;
|
|
279
448
|
}
|
|
280
449
|
|
|
281
450
|
.empty-state {
|
|
@@ -285,23 +454,57 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
285
454
|
justify-content: center;
|
|
286
455
|
height: 60vh;
|
|
287
456
|
text-align: center;
|
|
288
|
-
opacity: 0.6;
|
|
289
457
|
}
|
|
290
458
|
|
|
291
|
-
.empty-state svg {
|
|
459
|
+
.empty-state svg {
|
|
460
|
+
width: 64px;
|
|
461
|
+
height: 64px;
|
|
462
|
+
margin-bottom: 20px;
|
|
463
|
+
stroke: var(--text-dim);
|
|
464
|
+
opacity: 0.4;
|
|
465
|
+
filter: drop-shadow(0 0 8px rgba(0, 240, 255, 0.1));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
.empty-state h3 {
|
|
469
|
+
font-size: 1.25rem;
|
|
470
|
+
font-weight: 600;
|
|
471
|
+
margin-bottom: 8px;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
.empty-state p {
|
|
475
|
+
font-size: 0.9rem;
|
|
476
|
+
color: var(--text-dim);
|
|
477
|
+
max-width: 320px;
|
|
478
|
+
}
|
|
292
479
|
|
|
293
480
|
/* Scrollbar */
|
|
294
481
|
::-webkit-scrollbar { width: 6px; }
|
|
295
482
|
::-webkit-scrollbar-track { background: transparent; }
|
|
296
|
-
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.
|
|
483
|
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 10px; }
|
|
297
484
|
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
|
298
485
|
</style>
|
|
299
486
|
</head>
|
|
300
487
|
<body>
|
|
488
|
+
<div class="bg-grid"></div>
|
|
489
|
+
<div class="bg-radial"></div>
|
|
490
|
+
|
|
301
491
|
<aside>
|
|
302
492
|
<div class="brand">
|
|
303
|
-
<
|
|
304
|
-
|
|
493
|
+
<svg viewBox="0 0 100 100" class="brand-logo" fill="none">
|
|
494
|
+
<circle cx="50" cy="50" r="44" stroke="#00F0FF" stroke-width="2" stroke-dasharray="4 8" opacity="0.3" />
|
|
495
|
+
<polygon points="50,42 22,26 34,50" fill="#0c4a6e" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
496
|
+
<polygon points="50,42 78,26 66,50" fill="#0c4a6e" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
497
|
+
<polygon points="50,78 20,48 36,48" fill="#0f172a" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
498
|
+
<polygon points="50,78 80,48 64,48" fill="#0f172a" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
499
|
+
<polygon points="50,42 36,48 50,78" fill="#1e293b" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
500
|
+
<polygon points="50,42 64,48 50,78" fill="#1e293b" stroke="#00F0FF" stroke-width="1.5" stroke-linejoin="round" />
|
|
501
|
+
<polygon points="40,52 46,50 44,56" fill="#00F0FF" />
|
|
502
|
+
<polygon points="60,52 54,50 56,56" fill="#00F0FF" />
|
|
503
|
+
</svg>
|
|
504
|
+
<div class="brand-text">
|
|
505
|
+
<h1>Pika <span>Sentinel</span></h1>
|
|
506
|
+
<p>${projectName} • ${timestamp}</p>
|
|
507
|
+
</div>
|
|
305
508
|
</div>
|
|
306
509
|
<div class="sidebar-stats">
|
|
307
510
|
<div class="stat-box">
|
|
@@ -312,6 +515,10 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
312
515
|
<span class="stat-val" id="totalIssues">0</span>
|
|
313
516
|
<span class="stat-lab">Issues</span>
|
|
314
517
|
</div>
|
|
518
|
+
<div class="stat-box crit-stat">
|
|
519
|
+
<span class="stat-val" id="criticalIssues">0</span>
|
|
520
|
+
<span class="stat-lab">Critical</span>
|
|
521
|
+
</div>
|
|
315
522
|
</div>
|
|
316
523
|
<div class="file-list" id="fileList"></div>
|
|
317
524
|
</aside>
|
|
@@ -319,9 +526,9 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
319
526
|
<main>
|
|
320
527
|
<div class="content-container" id="mainContent">
|
|
321
528
|
<div class="empty-state">
|
|
322
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|
529
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
|
|
323
530
|
<h3>Select a file to inspect</h3>
|
|
324
|
-
<p>Architectural
|
|
531
|
+
<p>Architectural anomalies and security compliance findings will appear here.</p>
|
|
325
532
|
</div>
|
|
326
533
|
</div>
|
|
327
534
|
</main>
|
|
@@ -334,6 +541,7 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
334
541
|
const mainContent = document.getElementById('mainContent');
|
|
335
542
|
const totalFilesEl = document.getElementById('totalFiles');
|
|
336
543
|
const totalIssuesEl = document.getElementById('totalIssues');
|
|
544
|
+
const criticalIssuesEl = document.getElementById('criticalIssues');
|
|
337
545
|
|
|
338
546
|
function escape(str) {
|
|
339
547
|
if (!str) return '';
|
|
@@ -345,9 +553,17 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
345
553
|
function init() {
|
|
346
554
|
totalFilesEl.textContent = findings.length;
|
|
347
555
|
let issuesCount = 0;
|
|
556
|
+
let criticalCount = 0;
|
|
348
557
|
|
|
349
558
|
findings.forEach((f, index) => {
|
|
350
559
|
issuesCount += f.reviews.length;
|
|
560
|
+
|
|
561
|
+
f.reviews.forEach(r => {
|
|
562
|
+
if (r.severity === 'Critical') {
|
|
563
|
+
criticalCount++;
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
351
567
|
const item = document.createElement('div');
|
|
352
568
|
item.className = 'file-item';
|
|
353
569
|
|
|
@@ -386,6 +602,7 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
386
602
|
});
|
|
387
603
|
|
|
388
604
|
totalIssuesEl.textContent = issuesCount;
|
|
605
|
+
criticalIssuesEl.textContent = criticalCount;
|
|
389
606
|
}
|
|
390
607
|
|
|
391
608
|
function selectFile(index, element) {
|
|
@@ -420,6 +637,8 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
420
637
|
const card = document.createElement('div');
|
|
421
638
|
card.className = 'issue-card ' + r.severity;
|
|
422
639
|
|
|
640
|
+
const escRec = escape(r.recommendation);
|
|
641
|
+
|
|
423
642
|
card.innerHTML = [
|
|
424
643
|
'<div class="card-accent"></div>',
|
|
425
644
|
'<div class="issue-meta">',
|
|
@@ -436,7 +655,13 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
436
655
|
'</div>',
|
|
437
656
|
'<div class="field-group">',
|
|
438
657
|
'<div class="field-label">🛠️ Recommendation</div>',
|
|
439
|
-
'<
|
|
658
|
+
'<div class="pre-wrapper">',
|
|
659
|
+
'<div class="pre-header">',
|
|
660
|
+
'<span class="pre-lang">Code Recommendation</span>',
|
|
661
|
+
'<button class="card-copy-btn" onclick="copyRecommendation(this)">Copy Code</button>',
|
|
662
|
+
'</div>',
|
|
663
|
+
'<pre><code>' + escRec + '</code></pre>',
|
|
664
|
+
'</div>',
|
|
440
665
|
'</div>'
|
|
441
666
|
].join('');
|
|
442
667
|
|
|
@@ -446,6 +671,29 @@ export function writeHTMLReport(sessionDir, totalFiles, allFindings) {
|
|
|
446
671
|
mainContent.scrollTop = 0;
|
|
447
672
|
}
|
|
448
673
|
|
|
674
|
+
window.copyRecommendation = function(btn) {
|
|
675
|
+
const preElement = btn.closest('.pre-wrapper').querySelector('pre code');
|
|
676
|
+
const text = preElement ? preElement.textContent : '';
|
|
677
|
+
|
|
678
|
+
// Decode HTML entities before copying
|
|
679
|
+
const textarea = document.createElement('textarea');
|
|
680
|
+
textarea.innerHTML = text;
|
|
681
|
+
const decodedText = textarea.value;
|
|
682
|
+
|
|
683
|
+
navigator.clipboard.writeText(decodedText).then(() => {
|
|
684
|
+
const originalText = btn.textContent;
|
|
685
|
+
btn.textContent = 'Copied! ✓';
|
|
686
|
+
btn.style.color = 'var(--accent)';
|
|
687
|
+
btn.style.borderColor = 'rgba(0, 240, 255, 0.4)';
|
|
688
|
+
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
btn.textContent = originalText;
|
|
691
|
+
btn.style.color = '';
|
|
692
|
+
btn.style.borderColor = '';
|
|
693
|
+
}, 2000);
|
|
694
|
+
});
|
|
695
|
+
};
|
|
696
|
+
|
|
449
697
|
init();
|
|
450
698
|
})();
|
|
451
699
|
</script>
|
package/dist/core/scanner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFileSync } from "child_process";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import cliProgress from "cli-progress";
|
|
4
4
|
import pLimit from "p-limit";
|
|
5
|
+
import chalk from "chalk";
|
|
5
6
|
import { analyzeDiff } from "./ai.js";
|
|
6
7
|
import { setupReportDir, writeMarkdownReport, writeHTMLReport } from "./reporter.js";
|
|
7
8
|
import { getIgnoredFiles } from "../utils/config.js";
|
|
@@ -84,13 +85,17 @@ export async function runScan(target, isCI, specificFiles) {
|
|
|
84
85
|
if (files.length === 0) {
|
|
85
86
|
if (!isCI)
|
|
86
87
|
logger.success("No changes detected. Codebase is clean.");
|
|
87
|
-
return
|
|
88
|
+
return {
|
|
89
|
+
markdownReports: [],
|
|
90
|
+
htmlReport: "",
|
|
91
|
+
findings: []
|
|
92
|
+
};
|
|
88
93
|
}
|
|
89
94
|
const reportDir = setupReportDir();
|
|
90
95
|
if (!isCI)
|
|
91
96
|
logger.info(`Initializing Pika Review on ${files.length} file(s)...`);
|
|
92
97
|
const bar = isCI ? null : new cliProgress.SingleBar({
|
|
93
|
-
format: ' {bar} {percentage}%
|
|
98
|
+
format: ' ' + chalk.cyan('{bar}') + ' ' + chalk.bold('{percentage}%') + ' │ ' + chalk.dim('ETA: {eta}s') + ' │ ' + chalk.gray('{file}'),
|
|
94
99
|
barCompleteChar: '\u2588',
|
|
95
100
|
barIncompleteChar: '\u2591',
|
|
96
101
|
hideCursor: true,
|
|
@@ -191,7 +196,10 @@ export async function runScan(target, isCI, specificFiles) {
|
|
|
191
196
|
const htmlPath = writeHTMLReport(reportDir, files.length, allFindings);
|
|
192
197
|
if (!isCI) {
|
|
193
198
|
logger.success("\nScan complete!");
|
|
194
|
-
logger.info(`Interactive Report: ${htmlPath}`);
|
|
195
199
|
}
|
|
196
|
-
return
|
|
200
|
+
return {
|
|
201
|
+
markdownReports: generatedReports,
|
|
202
|
+
htmlReport: htmlPath,
|
|
203
|
+
findings: allFindings
|
|
204
|
+
};
|
|
197
205
|
}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,10 @@ import { initAction } from "./cmd/init.js";
|
|
|
5
5
|
import { scanAction } from "./cmd/scan.js";
|
|
6
6
|
import { viewAction } from "./cmd/view.js";
|
|
7
7
|
import { statsAction } from "./cmd/stats.js";
|
|
8
|
+
import { modelsAction } from "./cmd/models.js";
|
|
9
|
+
import { hookAction } from "./cmd/hook.js";
|
|
10
|
+
import { rulesAction } from "./cmd/rules.js";
|
|
11
|
+
import { logger } from "./utils/logger.js";
|
|
8
12
|
/**
|
|
9
13
|
* Pika Review: The Enterprise-Grade AI Code Reviewer.
|
|
10
14
|
* Modular entry point for CLI operations.
|
|
@@ -12,32 +16,31 @@ import { statsAction } from "./cmd/stats.js";
|
|
|
12
16
|
const program = new Command();
|
|
13
17
|
// Branding Header
|
|
14
18
|
const BRAND = `
|
|
15
|
-
${chalk.
|
|
16
|
-
${chalk.
|
|
19
|
+
${chalk.cyan.bold("◆ Pika Sentinel")} ${chalk.dim(`v2.2.0 (Enterprise)`)}
|
|
20
|
+
${chalk.dim("─".repeat(42))}
|
|
21
|
+
${chalk.italic.gray("AI Architectural & Security Safeguard")}
|
|
17
22
|
`;
|
|
18
23
|
const HELPER_TEXT = `
|
|
19
|
-
${chalk.bold
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
${chalk.cyan.bold("Commands:")}
|
|
25
|
+
${chalk.bold("scan")} Scan git staged changes for issues (default)
|
|
26
|
+
-u, --unstaged Scan unstaged changes instead of staged
|
|
27
|
+
-i, --interactive Interactively pick files to scan
|
|
28
|
+
--ci Fail CI pipeline if critical/high issues are found
|
|
29
|
+
${chalk.bold("view")} Open the latest interactive HTML report in browser
|
|
30
|
+
${chalk.bold("stats")} Print scan history & key quality trends
|
|
31
|
+
${chalk.bold("models")} Interactively select Ollama models for offline audit
|
|
32
|
+
${chalk.bold("hook")} Install Git pre-commit scanner safeguard hook
|
|
33
|
+
${chalk.bold("rules")} AI-generate architectural '.pika-rules.md'
|
|
26
34
|
|
|
27
|
-
${chalk.bold
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
- Review artifacts are stored in ${chalk.yellow(".pika-reports/")} automatically.
|
|
32
|
-
|
|
33
|
-
${chalk.bold.cyan("📊 Progress & Concurrency:")}
|
|
34
|
-
- Pika uses ${chalk.green("p-limit(3)")} to scan files in parallel without hitting rate limits.
|
|
35
|
-
- Real-time progress bars will show you the exact status of each analysis.
|
|
35
|
+
${chalk.cyan.bold("Options & Advanced:")}
|
|
36
|
+
• Custom Rules: Create a ${chalk.yellow(".pika-rules.md")} to feed custom codebase standards to the AI.
|
|
37
|
+
• Bypasses: Add ${chalk.yellow("// pika-ignore")} or ${chalk.yellow("/* pika-ignore */")} in code to skip lines.
|
|
38
|
+
• Local LLM: Select an offline model via '${chalk.green("pika-review models")}' for passwordless private runs.
|
|
36
39
|
`;
|
|
37
40
|
program
|
|
38
41
|
.name("pika-review")
|
|
39
42
|
.description("Enterprise-grade AI Architectural Code Reviewer")
|
|
40
|
-
.version("2.
|
|
43
|
+
.version("2.2.0")
|
|
41
44
|
.addHelpText("before", BRAND)
|
|
42
45
|
.addHelpText("after", HELPER_TEXT);
|
|
43
46
|
program
|
|
@@ -61,6 +64,38 @@ program
|
|
|
61
64
|
console.log(BRAND);
|
|
62
65
|
await statsAction();
|
|
63
66
|
});
|
|
67
|
+
program
|
|
68
|
+
.command("models")
|
|
69
|
+
.description("Select and configure local Ollama models interactively")
|
|
70
|
+
.action(async () => {
|
|
71
|
+
console.log(BRAND);
|
|
72
|
+
await modelsAction();
|
|
73
|
+
});
|
|
74
|
+
program
|
|
75
|
+
.command("hook <action>")
|
|
76
|
+
.description("Install or uninstall Git pre-commit scanner safeguard hook")
|
|
77
|
+
.addHelpText("after", `\nActions:\n install Install Git pre-commit hook\n uninstall Remove Git pre-commit hook`)
|
|
78
|
+
.action(async (action) => {
|
|
79
|
+
if (action !== "install" && action !== "uninstall") {
|
|
80
|
+
logger.error("Invalid action. Use 'install' or 'uninstall'.");
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log(BRAND);
|
|
84
|
+
await hookAction(action);
|
|
85
|
+
});
|
|
86
|
+
program
|
|
87
|
+
.command("rules")
|
|
88
|
+
.description("AI architectural rules utilities")
|
|
89
|
+
.option("-g, --generate", "Auto-generate .pika-rules.md based on codebase")
|
|
90
|
+
.action(async (options) => {
|
|
91
|
+
console.log(BRAND);
|
|
92
|
+
if (options.generate) {
|
|
93
|
+
await rulesAction();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
logger.info("Use 'pika-review rules --generate' to auto-generate architecture rules.");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
64
99
|
program
|
|
65
100
|
.command("scan [files...]", { isDefault: true }) // Set scan as the default command
|
|
66
101
|
.description("Scan git changes or specific files for architectural anomalies")
|
package/dist/utils/config.js
CHANGED
|
@@ -6,11 +6,12 @@ import { logger } from "./logger.js";
|
|
|
6
6
|
const CONFIG_PATH = path.join(os.homedir(), ".pika-review.yaml");
|
|
7
7
|
const DEFAULT_CONFIG = {
|
|
8
8
|
ai: {
|
|
9
|
-
|
|
9
|
+
provider: "openai",
|
|
10
|
+
accountId: "", // Account ID if required (e.g. Cloudflare)
|
|
10
11
|
apiKey: "",
|
|
11
|
-
model: "
|
|
12
|
+
model: "gpt-4o",
|
|
12
13
|
prompt: "",
|
|
13
|
-
baseURL: "", //
|
|
14
|
+
baseURL: "https://api.openai.com/v1", // Default OpenAI endpoint
|
|
14
15
|
},
|
|
15
16
|
};
|
|
16
17
|
/**
|
|
@@ -39,6 +40,19 @@ export function getConfig() {
|
|
|
39
40
|
}
|
|
40
41
|
return yaml.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
|
|
41
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Save configuration back to disk with correct permissions.
|
|
45
|
+
*/
|
|
46
|
+
export function saveConfig(config) {
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(CONFIG_PATH, yaml.stringify(config), {
|
|
49
|
+
mode: 0o600,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
logger.error(`Failed to save configuration: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
42
56
|
/**
|
|
43
57
|
* Get files to ignore during scanning.
|
|
44
58
|
*/
|