minovative-mind-cli 1.3.0 → 1.4.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 +61 -23
- package/dist/commands/chat.js +4 -5
- package/dist/services/agent-tools.js +94 -31
- package/dist/services/agent.js +211 -34
- package/dist/services/changeLogger.d.ts +6 -0
- package/dist/services/changeLogger.js +41 -1
- package/dist/utils/analysisRunner.js +4 -2
- package/dist/utils/dependencyTracer.js +1 -1
- package/dist/utils/logo.d.ts +2 -0
- package/dist/utils/logo.js +12 -0
- package/dist/utils/performanceAuditor.js +106 -8
- package/dist/utils/projectStorage.d.ts +30 -0
- package/dist/utils/projectStorage.js +157 -0
- package/oclif.manifest.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,43 +1,47 @@
|
|
|
1
1
|
# Minovative Mind CLI
|
|
2
2
|
|
|
3
|
-
## The CLI agent that makes Flash models perform like Pro.
|
|
4
|
-
|
|
5
|
-
### The result: **Genuine Pro-level accuracy at Flash-level cost.**
|
|
3
|
+
## The CLI agent that makes Flash models perform like a Pro reasoning model.
|
|
6
4
|
|
|
7
5
|
Most AI coding tools throw your entire codebase at the biggest model available
|
|
8
|
-
and hope for the best.
|
|
6
|
+
and hope for the best.
|
|
7
|
+
|
|
8
|
+
This CLI does the opposite — it uses a custom built agentic system called,
|
|
9
9
|
**Precision-Context Verification (PCV)** engine to feed lightweight Flash models
|
|
10
|
-
exactly the right context, verify the output compiles, and self-correct
|
|
11
|
-
the build
|
|
10
|
+
exactly the right context, execute code, verify the output compiles, and self-correct (if it even needs to), until
|
|
11
|
+
the build/performance metrics are green.
|
|
12
|
+
|
|
13
|
+
> The result: **Genuine Pro reasoning accuracy at Flash-level speed and efficiency.**
|
|
12
14
|
|
|
13
15
|
[](https://oclif.io)
|
|
14
16
|
[](https://npmjs.org/package/minovative-mind-cli)
|
|
15
17
|
[](https://npmjs.org/package/minovative-mind-cli)
|
|
16
18
|
|
|
19
|
+
- Official Website: [https://www.minovativemind.dev/](https://www.minovativemind.dev/)
|
|
20
|
+
- Latest Updates: [https://www.minovativemind.dev/updates](https://www.minovativemind.dev/updates)
|
|
21
|
+
|
|
17
22
|
---
|
|
18
23
|
|
|
19
24
|
## How it works
|
|
20
25
|
|
|
21
|
-
Standard AI coding agents: big model + noisy context = expensive and redundant mistakes
|
|
22
|
-
|
|
23
|
-
Minovative Mind CLI: flash models + precise context + verified output = better results.
|
|
26
|
+
- Standard AI coding agents: big model + noisy context = expensive and redundant mistakes
|
|
27
|
+
- Minovative Mind CLI: flash models + precise context + verified output = better and cheaper results.
|
|
24
28
|
|
|
25
|
-
The PCV engine runs in three stages before returning a single line of code:
|
|
29
|
+
### The PCV engine runs in three stages before returning a single line of code:
|
|
26
30
|
|
|
27
31
|
**1. Investigate**
|
|
28
|
-
|
|
29
|
-
dependencies
|
|
30
|
-
external documentation
|
|
32
|
+
|
|
33
|
+
- **Maps your project:** Traces import dependencies and identifies recently modified files to understand your focus.
|
|
34
|
+
- **Researches context:** Gathers external web documentation and completes background research before touching any code.
|
|
31
35
|
|
|
32
36
|
**2. Execute with precision**
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
40–50% fewer broken generations on large files
|
|
37
|
+
|
|
38
|
+
- **Surgical snapshots:** Feeds the agent a compressed, relevant slice of code instead of dumping the entire codebase.
|
|
39
|
+
- **Higher success rates:** Delivers 40–50% fewer broken generations on large files compared to standard zero-shot prompts.
|
|
36
40
|
|
|
37
41
|
**3. Verify and self-correct**
|
|
38
|
-
|
|
39
|
-
`
|
|
40
|
-
up to 3 correction cycles
|
|
42
|
+
|
|
43
|
+
- **Runs native builds & audits:** Automatically executes your actual build commands (`npm run build`, `cargo check`, etc.) and scans for performance flaws on modified files only.
|
|
44
|
+
- **Autonomously debugs:** Reads the exact compiler output and fixes its own errors across up to 3 hands-free correction cycles.
|
|
41
45
|
|
|
42
46
|
---
|
|
43
47
|
|
|
@@ -54,6 +58,12 @@ up to 3 correction cycles without you typing a word.
|
|
|
54
58
|
npm install -g minovative-mind-cli
|
|
55
59
|
```
|
|
56
60
|
|
|
61
|
+
### Update to latest version
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm update -g minovative-mind-cli
|
|
65
|
+
```
|
|
66
|
+
|
|
57
67
|
Create a free account at [minovativemind.dev](https://www.minovativemind.dev),
|
|
58
68
|
then:
|
|
59
69
|
|
|
@@ -63,7 +73,7 @@ cd your-project
|
|
|
63
73
|
minovative-mind-cli chat # Start coding
|
|
64
74
|
```
|
|
65
75
|
|
|
66
|
-
Works in any terminal — SSH, Docker, CI pipelines, Vim, anywhere Node runs.
|
|
76
|
+
Works in any terminal — SSH, Docker, CI pipelines, Vim, Windows Command Prompt, PowerShell, anywhere Node runs.
|
|
67
77
|
|
|
68
78
|
---
|
|
69
79
|
|
|
@@ -77,9 +87,9 @@ Hot-swap during a session with `/models`:
|
|
|
77
87
|
| **Gemini 3.1 Pro** | Complex architectural changes |
|
|
78
88
|
| **Gemini 3.1 Flash Lite** | Maximum speed and cost efficiency |
|
|
79
89
|
|
|
80
|
-
>
|
|
81
|
-
>
|
|
82
|
-
>
|
|
90
|
+
> Background tasks (routing, compression, commits) always use lightweight
|
|
91
|
+
> models automatically. You only pay for what your selected model costs
|
|
92
|
+
> during chat and code execution. Use `/debug` to see exactly what's running.
|
|
83
93
|
|
|
84
94
|
---
|
|
85
95
|
|
|
@@ -97,6 +107,34 @@ Hot-swap during a session with `/models`:
|
|
|
97
107
|
|
|
98
108
|
---
|
|
99
109
|
|
|
110
|
+
## 🌐 Supported Languages
|
|
111
|
+
|
|
112
|
+
Minovative Mind CLI features deep, context-aware analysis across **11 major programming language families**. Our core engines—**Smart Dependency Tracing**, **Performance Auditing**, and **Ephemeral Analysis Scripts**—provide tailored support depending on the language's syntax and runtime model:
|
|
113
|
+
|
|
114
|
+
| Language Family | Supported Extensions | Dependency Tracing | Performance Auditing | Ephemeral Analysis |
|
|
115
|
+
| :--------------------------- | :------------------------------------------- | :----------------: | :------------------: | :----------------: |
|
|
116
|
+
| **JavaScript / TypeScript** | `.js`, `.jsx`, `.ts`, `.tsx`, `.mjs`, `.cjs` | ✅ | ✅ | ✅ |
|
|
117
|
+
| **Python** | `.py`, `.pyw` | ✅ | ✅ | ✅ |
|
|
118
|
+
| **Rust** | `.rs` | ✅ | ✅ | ✅ |
|
|
119
|
+
| **Go** | `.go` | ✅ | ✅ | ✅ |
|
|
120
|
+
| **C / C++** | `.c`, `.h`, `.cpp`, `.hpp`, `.cc` | ✅ | ⚠️ \* | ⚠️ \* |
|
|
121
|
+
| **Java / Kotlin** | `.java`, `.kt`, `.kts` | ✅ | ⚠️ \* | ⚠️ \* |
|
|
122
|
+
| **PHP** | `.php` | ✅ | ✅ | ❌ |
|
|
123
|
+
| **CSS / SCSS / SASS / LESS** | `.css`, `.scss`, `.sass`, `.less` | ✅ | ❌ | ❌ |
|
|
124
|
+
| **Ruby** | `.rb` | ✅ | ❌ | ❌ |
|
|
125
|
+
| **Swift** | `.swift` | ✅ | ❌ | ❌ |
|
|
126
|
+
| **Dart** | `.dart` | ✅ | ❌ | ❌ |
|
|
127
|
+
|
|
128
|
+
_⚠️\*Support is limited or requires custom local system tooling/environment setup._
|
|
129
|
+
|
|
130
|
+
### Why do some features only support specific languages?
|
|
131
|
+
|
|
132
|
+
1. **Dependency Tracing:** Fully supported for all 11 languages. It uses lightning-fast static regex pattern matching to resolve local import structures and determine the "blast radius" of code changes without requiring local compilations.
|
|
133
|
+
2. **Performance Auditing:** Uses regex-based heuristic analysis to scan for issues. Extending this to complex grammars (such as Ruby or Swift) with pure regex can cause high false-positive rates; those require full AST parsing, which runs counter to the auditor's ultra-fast (<50ms) and zero-dependency goals.
|
|
134
|
+
3. **Ephemeral Analysis Scripts:** Runs temporary files in sandbox directories to parse and extract local symbols. This requires having local runtimes/interpreters installed (e.g., Node, Python, Rust, Go compilers) and standard single-file execution support, which is not suitable for complex build setups (Java Maven/Gradle, C++ Makefiles) or non-executable languages (CSS).
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
100
138
|
## Legal
|
|
101
139
|
|
|
102
140
|
[Terms of Service](https://www.minovativemind.dev/legal/cli-terms) ·
|
package/dist/commands/chat.js
CHANGED
|
@@ -6,6 +6,7 @@ import * as p from '@clack/prompts';
|
|
|
6
6
|
import pc from 'picocolors';
|
|
7
7
|
import { startAgentLoop } from '../services/agent.js';
|
|
8
8
|
import { getAuthorizedIdToken, login } from '../services/auth.js';
|
|
9
|
+
import { printLogo } from '../utils/logo.js';
|
|
9
10
|
import { updateWorkspaceStatus } from '../services/workspace.js';
|
|
10
11
|
/**
|
|
11
12
|
* @class DefaultCommand
|
|
@@ -36,10 +37,7 @@ Chat Controls:
|
|
|
36
37
|
/**
|
|
37
38
|
* Example usages for the chat command shown in help output.
|
|
38
39
|
*/
|
|
39
|
-
static examples = [
|
|
40
|
-
'<%= config.bin %> chat',
|
|
41
|
-
'<%= config.bin %> chat --help',
|
|
42
|
-
];
|
|
40
|
+
static examples = ['<%= config.bin %> chat', '<%= config.bin %> chat --help'];
|
|
43
41
|
/**
|
|
44
42
|
* The main execution logic for the chat command.
|
|
45
43
|
* - Initializes update notifications.
|
|
@@ -53,7 +51,8 @@ Chat Controls:
|
|
|
53
51
|
// Setup update notifier
|
|
54
52
|
const pkg = JSON.parse(await fs.promises.readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
55
53
|
updateNotifier({ pkg }).notify();
|
|
56
|
-
|
|
54
|
+
printLogo();
|
|
55
|
+
p.intro(`${pc.bgCyan(pc.black(' Minovative Mind CLI '))} ${pc.dim('v' + this.config.version)}`);
|
|
57
56
|
// Check authentication
|
|
58
57
|
let idToken = await getAuthorizedIdToken();
|
|
59
58
|
if (!idToken) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { exec } from 'node:child_process';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
5
6
|
import { SchemaType } from '@google/generative-ai';
|
|
@@ -283,7 +284,7 @@ async function getIgnoredPaths(workspaceRoot) {
|
|
|
283
284
|
.map((l) => l.trim())
|
|
284
285
|
.filter((l) => l && !l.startsWith('#'));
|
|
285
286
|
for (const line of lines) {
|
|
286
|
-
const cleanLine = line.replace(/^\//, '').replace(/\/$/, '');
|
|
287
|
+
const cleanLine = line.replace(/\\/g, '/').replace(/^\//, '').replace(/\/$/, '');
|
|
287
288
|
if (!cleanLine.includes('*')) {
|
|
288
289
|
ignoredDirs.add(cleanLine);
|
|
289
290
|
ignoredFiles.add(cleanLine);
|
|
@@ -525,18 +526,18 @@ export async function runCommand(workspaceRoot, command, abortSignal) {
|
|
|
525
526
|
});
|
|
526
527
|
let output = [stdout, stderr].filter(Boolean).join('\n');
|
|
527
528
|
// Truncate command output to prevent memory blowout from massive build logs
|
|
528
|
-
const MAX_CMD_OUTPUT =
|
|
529
|
+
const MAX_CMD_OUTPUT = 15_000;
|
|
529
530
|
if (output.length > MAX_CMD_OUTPUT) {
|
|
530
|
-
output = output.substring(0, MAX_CMD_OUTPUT) +
|
|
531
|
+
output = output.substring(0, MAX_CMD_OUTPUT) + `\n\n... (Output truncated: ${output.length} bytes exceeded 15KB limit. To view the rest, pipe the command to a file and read it in chunks, or use grep.)`;
|
|
531
532
|
}
|
|
532
533
|
return { output: output || '(command produced no output)' };
|
|
533
534
|
}
|
|
534
535
|
catch (err) {
|
|
535
536
|
const message = err instanceof Error ? err.message : String(err);
|
|
536
537
|
// Also truncate error output
|
|
537
|
-
const MAX_ERR_OUTPUT =
|
|
538
|
+
const MAX_ERR_OUTPUT = 15_000;
|
|
538
539
|
const truncatedMsg = message.length > MAX_ERR_OUTPUT
|
|
539
|
-
? message.substring(0, MAX_ERR_OUTPUT) +
|
|
540
|
+
? message.substring(0, MAX_ERR_OUTPUT) + `\n\n... (Error output truncated: ${message.length} bytes exceeded 15KB limit. Pipe to a file if you need full logs.)`
|
|
540
541
|
: message;
|
|
541
542
|
return { output: '', error: `Command failed: ${truncatedMsg}` };
|
|
542
543
|
}
|
|
@@ -544,29 +545,86 @@ export async function runCommand(workspaceRoot, command, abortSignal) {
|
|
|
544
545
|
export async function grepSearch(workspaceRoot, pattern, fileGlob, abortSignal) {
|
|
545
546
|
try {
|
|
546
547
|
const { ignoredDirs } = await getIgnoredPaths(workspaceRoot);
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
.
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
548
|
+
if (os.platform() !== 'win32') {
|
|
549
|
+
const excludeDirArgs = Array.from(ignoredDirs)
|
|
550
|
+
.map((dir) => `--exclude-dir="${dir}"`)
|
|
551
|
+
.concat('--exclude-dir=".*"') // Exclude all hidden directories
|
|
552
|
+
.join(' ');
|
|
553
|
+
const excludeFileArgs = EXCLUDED_EXTENSIONS.map((ext) => `--exclude="${ext}"`)
|
|
554
|
+
.concat('--exclude=".*"') // Exclude all hidden files
|
|
555
|
+
.join(' ');
|
|
556
|
+
// Build a grep command that works cross-platform via node
|
|
557
|
+
// We use grep -rnIEi for recursive, line numbers, skip binary, extended regex, case-insensitive
|
|
558
|
+
const globArg = fileGlob ? ` --include="${fileGlob}"` : '';
|
|
559
|
+
const cmd = `grep -rnIEi --color=never ${excludeDirArgs} ${excludeFileArgs}${globArg} "${pattern.replace(/"/g, '\\"')}" .`;
|
|
560
|
+
const { stdout } = await execAsync(cmd, {
|
|
561
|
+
cwd: workspaceRoot,
|
|
562
|
+
timeout: 15_000,
|
|
563
|
+
maxBuffer: 1024 * 1024 * 2,
|
|
564
|
+
signal: abortSignal,
|
|
565
|
+
});
|
|
566
|
+
// Limit output to 50 results
|
|
567
|
+
const lines = stdout.trim().split('\n');
|
|
568
|
+
const limited = lines.slice(0, 50);
|
|
569
|
+
const resultText = limited.join('\n') + (lines.length > 50 ? `\n\n... (${lines.length - 50} more results truncated)` : '');
|
|
570
|
+
if (!resultText)
|
|
571
|
+
return { output: `No matches found for "${pattern}".` };
|
|
572
|
+
const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
|
|
573
|
+
return { output: wrappedResult };
|
|
574
|
+
}
|
|
575
|
+
// Node.js fallback for Windows
|
|
576
|
+
const regexPattern = new RegExp(pattern, 'gi'); // Emulate grep -i and global match
|
|
577
|
+
const globPattern = fileGlob ? new RegExp('^' + fileGlob.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$', 'i') : null;
|
|
578
|
+
const results = [];
|
|
579
|
+
async function walk(dir) {
|
|
580
|
+
if (results.length >= 50)
|
|
581
|
+
return;
|
|
582
|
+
let entries;
|
|
583
|
+
try {
|
|
584
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
for (const entry of entries) {
|
|
590
|
+
if (results.length >= 50)
|
|
591
|
+
return;
|
|
592
|
+
if (entry.name.startsWith('.'))
|
|
593
|
+
continue;
|
|
594
|
+
if (entry.isDirectory()) {
|
|
595
|
+
if (ignoredDirs.has(entry.name))
|
|
596
|
+
continue;
|
|
597
|
+
await walk(path.join(dir, entry.name));
|
|
598
|
+
}
|
|
599
|
+
else if (entry.isFile()) {
|
|
600
|
+
if (EXCLUDED_EXTENSIONS.some((ext) => entry.name.endsWith(ext.replace('*', ''))))
|
|
601
|
+
continue;
|
|
602
|
+
if (globPattern && !globPattern.test(entry.name))
|
|
603
|
+
continue;
|
|
604
|
+
try {
|
|
605
|
+
const filePath = path.join(dir, entry.name);
|
|
606
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
607
|
+
const lines = content.split(/\r?\n/);
|
|
608
|
+
for (let i = 0; i < lines.length; i++) {
|
|
609
|
+
if (results.length >= 50)
|
|
610
|
+
break;
|
|
611
|
+
regexPattern.lastIndex = 0;
|
|
612
|
+
if (regexPattern.test(lines[i])) {
|
|
613
|
+
const relPath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/');
|
|
614
|
+
results.push(`${relPath}:${i + 1}:${lines[i]}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
// Ignore unreadable files (binaries, etc)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
await walk(workspaceRoot);
|
|
625
|
+
if (results.length === 0)
|
|
569
626
|
return { output: `No matches found for "${pattern}".` };
|
|
627
|
+
const resultText = results.join('\n') + (results.length >= 50 ? `\n\n... (more results truncated)` : '');
|
|
570
628
|
const wrappedResult = `<workspace_file path="grep_search_results">\n<content_data><![CDATA[\n${sanitizeForCDATA(resultText)}\n]]></content_data>\n</workspace_file>`;
|
|
571
629
|
return { output: wrappedResult };
|
|
572
630
|
}
|
|
@@ -633,7 +691,7 @@ export async function findRecentChanges(workspaceRoot, dirPath = '.', minutes =
|
|
|
633
691
|
const filePath = path.join(currentPath, entry.name);
|
|
634
692
|
const stats = await fs.stat(filePath);
|
|
635
693
|
if (stats.mtimeMs >= thresholdMs) {
|
|
636
|
-
const relPath = path.relative(workspaceRoot, filePath);
|
|
694
|
+
const relPath = path.relative(workspaceRoot, filePath).replace(/\\/g, '/');
|
|
637
695
|
recentFiles.push({ path: relPath, mtime: stats.mtimeMs });
|
|
638
696
|
}
|
|
639
697
|
}
|
|
@@ -698,9 +756,13 @@ export async function runDebugScript(workspaceRoot, language, code, abortSignal)
|
|
|
698
756
|
case 'go':
|
|
699
757
|
cmd = `go run ${tmpFileName}`;
|
|
700
758
|
break;
|
|
701
|
-
case 'rust':
|
|
702
|
-
|
|
759
|
+
case 'rust': {
|
|
760
|
+
const binExt = os.platform() === 'win32' ? '.exe' : '';
|
|
761
|
+
const binPath = tmpFileName.replace('.rs', binExt);
|
|
762
|
+
const runCmd = os.platform() === 'win32' ? binPath : `./${binPath}`;
|
|
763
|
+
cmd = `rustc ${tmpFileName} -o ${binPath} && ${runCmd}`;
|
|
703
764
|
break;
|
|
765
|
+
}
|
|
704
766
|
default:
|
|
705
767
|
return { output: '', error: `Unsupported language runtime: ${language}` };
|
|
706
768
|
}
|
|
@@ -738,7 +800,8 @@ export async function runDebugScript(workspaceRoot, language, code, abortSignal)
|
|
|
738
800
|
try {
|
|
739
801
|
await fs.rm(absPath, { force: true });
|
|
740
802
|
if (language.toLowerCase() === 'rust') {
|
|
741
|
-
const
|
|
803
|
+
const binExt = os.platform() === 'win32' ? '.exe' : '';
|
|
804
|
+
const binPath = absPath.replace('.rs', binExt);
|
|
742
805
|
await fs.rm(binPath, { force: true });
|
|
743
806
|
}
|
|
744
807
|
}
|
package/dist/services/agent.js
CHANGED
|
@@ -22,6 +22,8 @@ import path from 'node:path';
|
|
|
22
22
|
import { exec } from 'node:child_process';
|
|
23
23
|
import { promisify } from 'node:util';
|
|
24
24
|
import { debugLog, toggleDebugMode } from '../utils/logger.js';
|
|
25
|
+
import { ensureProjectStorage, ensureIgnored, readCache, writeCache, invalidateCacheForDependents } from '../utils/projectStorage.js';
|
|
26
|
+
import * as crypto from 'node:crypto';
|
|
25
27
|
const execAsync = promisify(exec);
|
|
26
28
|
/**
|
|
27
29
|
* Asynchronous Input Handler (AsyncInputHandler)
|
|
@@ -181,6 +183,7 @@ export class AsyncInputHandler {
|
|
|
181
183
|
const originalMessage = spinner.message.bind(spinner);
|
|
182
184
|
spinner.message = (msg) => {
|
|
183
185
|
if (msg) {
|
|
186
|
+
;
|
|
184
187
|
spinner._lastMessage = msg;
|
|
185
188
|
}
|
|
186
189
|
originalMessage(msg);
|
|
@@ -188,6 +191,7 @@ export class AsyncInputHandler {
|
|
|
188
191
|
const originalStart = spinner.start.bind(spinner);
|
|
189
192
|
spinner.start = (msg) => {
|
|
190
193
|
if (msg) {
|
|
194
|
+
;
|
|
191
195
|
spinner._lastMessage = msg;
|
|
192
196
|
}
|
|
193
197
|
originalStart(msg);
|
|
@@ -231,6 +235,7 @@ import { consumeSkipOnce, executeTool, getApprovalMode, setApprovalMode } from '
|
|
|
231
235
|
import { createSharedChatSession, getGeneralChatConfig, getPlanExecutionConfig, ProxyChatSession, compressTextUsingFlashLite, } from './ai.js';
|
|
232
236
|
import { changeLogger } from './changeLogger.js';
|
|
233
237
|
import { gatherContext, routeIntent } from './contextAgent.js';
|
|
238
|
+
import { printLogo } from '../utils/logo.js';
|
|
234
239
|
import { verifyChangedFiles } from './verificationService.js';
|
|
235
240
|
import { buildContextInjection } from '../utils/contextPrompts.js';
|
|
236
241
|
import { readPaste } from '../utils/paste.js';
|
|
@@ -309,30 +314,108 @@ function formatToolCall(name, args) {
|
|
|
309
314
|
const details = formatter ? formatter() : '';
|
|
310
315
|
return `${icon} ${label}${details}`;
|
|
311
316
|
}
|
|
317
|
+
function isCommandDestructive(command) {
|
|
318
|
+
const destructivePatterns = [
|
|
319
|
+
// Unix deletion (catches rm -r, rm -rf, rm -fr, rm --recursive, rm --force, anywhere in args)
|
|
320
|
+
/\brm\s+.*?(?:-[a-zA-Z]*[rfR]|--(?:recursive|force))\b/i,
|
|
321
|
+
/\bmv\s+.*\/dev\/null\b/i, // Move to void
|
|
322
|
+
// Windows deletion (catches rd /s, del /f /s)
|
|
323
|
+
/\b(?:rd|rmdir)\s+.*\/[sq]\b/i,
|
|
324
|
+
/\bdel\s+.*\/[fs]\b/i,
|
|
325
|
+
// Remote script execution (catches curl/wget piped to any shell or runtime)
|
|
326
|
+
/\b(?:curl|wget)\b.*?\|\s*(?:sh|bash|zsh|dash|python|node|ruby|perl)\b/i,
|
|
327
|
+
// Git overwriting (catches hard resets, clean -f, force pushes, hard branch deletes)
|
|
328
|
+
/\bgit\s+(?:reset|clean)\s+.*(?:--hard|-f|--force)\b/i,
|
|
329
|
+
/\bgit\s+push\s+.*(?:-f|--force)\b/i,
|
|
330
|
+
/\bgit\s+branch\s+.*-D\b/i,
|
|
331
|
+
// DB & Cluster Drops (catches truncates, deletes, drop db/table, docker volume/system prunes, kubectl wipes)
|
|
332
|
+
/\b(?:drop|truncate|delete)\s+(?:database|table|schema)\b/i,
|
|
333
|
+
/\bdocker\s+system\s+prune\b/i,
|
|
334
|
+
/\bdocker\s+volume\s+(?:rm|prune)\b/i,
|
|
335
|
+
/\bkubectl\s+delete\s+(?:namespace|all|--all)\b/i,
|
|
336
|
+
// Disk & System destruction (format, fdisk, dd, overwriting block devices, shutdown, fork bombs)
|
|
337
|
+
/\b(?:mkfs|fdisk|parted|mkswap)\b/i,
|
|
338
|
+
/\bformat\s+[A-Z]:/i,
|
|
339
|
+
/\bdd\s+.*(?:if=|of=)\b/i,
|
|
340
|
+
/>\s*\/dev\/(?:sda|disk|hda|nvme)\b/i,
|
|
341
|
+
/\b(?:shutdown|reboot|halt|poweroff)\b/i,
|
|
342
|
+
/:\(\)\{\s*:\|:&\s*\};:/, // Fork bomb
|
|
343
|
+
// User data/config erasure
|
|
344
|
+
/\bcrontab\s+-r\b/i,
|
|
345
|
+
/\bhistory\s+-c\b/i,
|
|
346
|
+
// Massive permission shifts (chmod 777, recursive chowns)
|
|
347
|
+
/\bchmod\s+(?:-[R\w]*\s+)?777\b/i,
|
|
348
|
+
/\bchown\s+-[R\w]*\b/i,
|
|
349
|
+
];
|
|
350
|
+
return destructivePatterns.some((pattern) => pattern.test(command));
|
|
351
|
+
}
|
|
352
|
+
function isCommandSafe(command) {
|
|
353
|
+
// Do not auto-approve chained, redirected, or sudo commands
|
|
354
|
+
if (/[;&|>]/.test(command) || /\bsudo\b/.test(command)) {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
const safePatterns = [
|
|
358
|
+
/^\s*ls\b/i,
|
|
359
|
+
/^\s*pwd\b/i,
|
|
360
|
+
/^\s*whoami\b/i,
|
|
361
|
+
/^\s*cat\b/i,
|
|
362
|
+
/^\s*echo\b/i,
|
|
363
|
+
/^\s*grep\b/i,
|
|
364
|
+
/^\s*find\b/i,
|
|
365
|
+
/^\s*npm\s+(install|i|ci|run\b)/i,
|
|
366
|
+
/^\s*yarn\s+(install|add|build|lint|test|run\b)/i,
|
|
367
|
+
/^\s*pnpm\s+(install|i|add|build|lint|test|run\b)/i,
|
|
368
|
+
/^\s*bun\s+(install|i|add|run\b)/i,
|
|
369
|
+
/^\s*cargo\s+(build|check|add|test|run\b)/i,
|
|
370
|
+
/^\s*go\s+(mod|get|build|test|run\b)/i,
|
|
371
|
+
/^\s*pip\s+(install|list|show)\b/i,
|
|
372
|
+
/^\s*uv\s+(add|pip|sync|run\b)/i,
|
|
373
|
+
/^\s*rustc\b/i,
|
|
374
|
+
/^\s*git\s+(status|log|diff|show|branch)\b/i,
|
|
375
|
+
/^\s*(node|python|ruby|java|go|rustc)\s+(--version|-v)\b/i,
|
|
376
|
+
/^\s*tsc\b/i,
|
|
377
|
+
/^\s*eslint\b/i,
|
|
378
|
+
/^\s*prettier\b/i,
|
|
379
|
+
];
|
|
380
|
+
return safePatterns.some((pattern) => pattern.test(command));
|
|
381
|
+
}
|
|
312
382
|
/**
|
|
313
|
-
*
|
|
314
|
-
* Implements a safety gate to protect the host machine's environment from arbitrary code execution.
|
|
383
|
+
* Executes an interactive approval prompt via `@clack/prompts`.
|
|
315
384
|
*
|
|
316
|
-
* Supports three approval models:
|
|
317
|
-
* 1. **Ask**: Prompts the user for every shell execution command.
|
|
385
|
+
* Supports pattern-based approvals and three user approval models:
|
|
386
|
+
* 1. **Ask**: Prompts the user for every shell execution command (except implicitly safe ones).
|
|
318
387
|
* 2. **Skip-Once**: Automatically grants permission to the current command, then resets.
|
|
319
|
-
* 3. **Skip-All / Auto-Approve**: Grants permission to all future terminal commands
|
|
388
|
+
* 3. **Skip-All / Auto-Approve**: Grants permission to all future terminal commands.
|
|
389
|
+
*
|
|
390
|
+
* Destructive commands will ALWAYS prompt the user, regardless of mode.
|
|
320
391
|
*
|
|
321
392
|
* @param command - The terminal string requested for execution.
|
|
322
393
|
* @returns A promise resolving to `true` if approved, or `false` if denied/cancelled.
|
|
323
394
|
*/
|
|
324
395
|
async function requestCommandApproval(command) {
|
|
325
396
|
const mode = getApprovalMode();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
397
|
+
const isDestructive = isCommandDestructive(command);
|
|
398
|
+
const isSafe = isCommandSafe(command);
|
|
399
|
+
// Force prompt for destructive commands
|
|
400
|
+
if (isDestructive) {
|
|
401
|
+
p.log.warn(`${pc.bgRed(pc.white(' WARNING '))} Destructive command detected. Explicit approval required.`);
|
|
329
402
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
403
|
+
else {
|
|
404
|
+
if (mode === 'skip-all') {
|
|
405
|
+
p.log.info(`${pc.dim('Auto-approved (skip-all):')} ${pc.yellow(command)}`);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (mode === 'skip-once') {
|
|
409
|
+
p.log.info(`${pc.dim('Auto-approved (skip-once):')} ${pc.yellow(command)}`);
|
|
410
|
+
consumeSkipOnce();
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
if (isSafe) {
|
|
414
|
+
p.log.info(`${pc.dim('Auto-approved (safe command):')} ${pc.yellow(command)}`);
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
334
417
|
}
|
|
335
|
-
// mode === 'ask'
|
|
418
|
+
// mode === 'ask' or isDestructive
|
|
336
419
|
const result = await p.select({
|
|
337
420
|
message: `Approve command: ${pc.yellow(command)}`,
|
|
338
421
|
options: [
|
|
@@ -599,6 +682,11 @@ async function processResponse(chat, result, workspaceRoot, inputHandler, agentS
|
|
|
599
682
|
* @param version - The Semantic Version string of the active tool distribution.
|
|
600
683
|
*/
|
|
601
684
|
export async function startAgentLoop(workspaceRoot, version) {
|
|
685
|
+
// Initialize project-level storage and ensure it is safely ignored from version control
|
|
686
|
+
ensureProjectStorage(workspaceRoot);
|
|
687
|
+
ensureIgnored(workspaceRoot);
|
|
688
|
+
// Initialize persistent change ledger
|
|
689
|
+
changeLogger.init(workspaceRoot);
|
|
602
690
|
const chat = createSharedChatSession();
|
|
603
691
|
const inputHandler = new AsyncInputHandler();
|
|
604
692
|
let isRawPasteMode = false;
|
|
@@ -643,8 +731,9 @@ export async function startAgentLoop(workspaceRoot, version) {
|
|
|
643
731
|
}
|
|
644
732
|
}
|
|
645
733
|
let line = (userInputRaw || '');
|
|
646
|
-
|
|
647
|
-
|
|
734
|
+
// Require a space before the backslash to prevent Windows directory paths (e.g., C:\foo\) from triggering multiline
|
|
735
|
+
if (line.trimEnd().endsWith(' \\')) {
|
|
736
|
+
const cleanLine = line.trimEnd().slice(0, -2);
|
|
648
737
|
lines.push(cleanLine);
|
|
649
738
|
isMultiLine = true;
|
|
650
739
|
// Clack's text prompt dims the submitted value, which can be completely invisible on some terminal themes.
|
|
@@ -720,7 +809,8 @@ export async function startAgentLoop(workspaceRoot, version) {
|
|
|
720
809
|
else if (userInput.toLowerCase() === '/clear') {
|
|
721
810
|
chat.clearHistory();
|
|
722
811
|
process.stdout.write('\x1B[2J\x1B[3J\x1B[H'); // Hard clear screen and scrollback
|
|
723
|
-
|
|
812
|
+
printLogo();
|
|
813
|
+
p.intro(`${pc.bgCyan(pc.black(' Minovative Mind CLI '))} ${pc.dim('v' + version)}`);
|
|
724
814
|
p.log.info(`${pc.dim('Workspace:')} ${pc.cyan(workspaceRoot)}`);
|
|
725
815
|
p.log.info(`${pc.dim('Commands:')} Type ${pc.yellow('/')} to open the command menu and "${pc.yellow('stop')}" to stop the ai generation. Type ${pc.yellow('exit')} to leave.`);
|
|
726
816
|
p.log.success('Chat history cleared.');
|
|
@@ -772,25 +862,62 @@ export async function startAgentLoop(workspaceRoot, version) {
|
|
|
772
862
|
continue;
|
|
773
863
|
}
|
|
774
864
|
if (userInput.toLowerCase() === '/revert') {
|
|
775
|
-
const
|
|
776
|
-
if (!
|
|
865
|
+
const history = changeLogger.getHistory();
|
|
866
|
+
if (!history || history.length === 0) {
|
|
777
867
|
p.log.warn('No changes to revert.');
|
|
778
868
|
continue;
|
|
779
869
|
}
|
|
870
|
+
const lastChangeSet = history[history.length - 1];
|
|
871
|
+
const truncate = (str, max) => (str.length > max ? str.substring(0, max - 3) + '...' : str);
|
|
872
|
+
const revertMenu = await p.select({
|
|
873
|
+
message: 'Revert Menu',
|
|
874
|
+
options: [
|
|
875
|
+
{ value: 'revert_last', label: `Revert last change (${truncate(lastChangeSet.description, 50)})` },
|
|
876
|
+
{ value: 'view_history', label: 'View history' },
|
|
877
|
+
{ value: 'cancel', label: 'Cancel' },
|
|
878
|
+
],
|
|
879
|
+
});
|
|
880
|
+
if (p.isCancel(revertMenu) || revertMenu === 'cancel') {
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
let targetTimestamp = lastChangeSet.timestamp;
|
|
884
|
+
if (revertMenu === 'view_history') {
|
|
885
|
+
// Reverse history so newest is at the top
|
|
886
|
+
const historyOptions = history
|
|
887
|
+
.slice()
|
|
888
|
+
.reverse()
|
|
889
|
+
.map((cs, i) => ({
|
|
890
|
+
value: cs.timestamp,
|
|
891
|
+
label: `[${i === 0 ? 'Latest' : `-${i}`}] ${truncate(cs.description, 50)} (${new Date(cs.timestamp).toLocaleTimeString()})`,
|
|
892
|
+
hint: `Reverts this and all ${i} changes after it`,
|
|
893
|
+
}));
|
|
894
|
+
const selectedHistory = await p.select({
|
|
895
|
+
message: 'Select the point in history to revert back to:',
|
|
896
|
+
options: [...historyOptions, { value: -1, label: 'Cancel' }],
|
|
897
|
+
});
|
|
898
|
+
if (p.isCancel(selectedHistory) || selectedHistory === -1) {
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
targetTimestamp = selectedHistory;
|
|
902
|
+
}
|
|
780
903
|
const spinner = p.spinner();
|
|
781
|
-
spinner.start('Reverting
|
|
904
|
+
spinner.start('Reverting selected changes...');
|
|
782
905
|
try {
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
906
|
+
const changesToRevert = changeLogger.popUntil(targetTimestamp);
|
|
907
|
+
// changesToRevert is ordered from newest to oldest. We must apply them sequentially.
|
|
908
|
+
for (const changeSet of changesToRevert) {
|
|
909
|
+
for (const change of changeSet.changes) {
|
|
910
|
+
const absPath = path.resolve(workspaceRoot, change.filePath);
|
|
911
|
+
if (change.action === 'create') {
|
|
912
|
+
await fs.rm(absPath, { force: true });
|
|
913
|
+
}
|
|
914
|
+
else if (change.originalContent !== null) {
|
|
915
|
+
await fs.writeFile(absPath, change.originalContent, 'utf-8');
|
|
916
|
+
}
|
|
790
917
|
}
|
|
791
918
|
}
|
|
792
919
|
spinner.stop('Reverted successfully.');
|
|
793
|
-
p.log.success(`Reverted
|
|
920
|
+
p.log.success(`Reverted ${changesToRevert.length} changeset(s) successfully.`);
|
|
794
921
|
}
|
|
795
922
|
catch (err) {
|
|
796
923
|
spinner.stop('Revert failed.');
|
|
@@ -839,6 +966,7 @@ ${diffOut}
|
|
|
839
966
|
continue;
|
|
840
967
|
}
|
|
841
968
|
// ─── Main Execution Cycle ────────────────────────────────────────
|
|
969
|
+
const turnStartTime = Date.now();
|
|
842
970
|
const spinner = p.spinner();
|
|
843
971
|
const ac = new AbortController();
|
|
844
972
|
inputHandler.setAbortController(ac);
|
|
@@ -890,11 +1018,46 @@ ${diffOut}
|
|
|
890
1018
|
let dynamicSystemInstruction = config.systemInstruction;
|
|
891
1019
|
// Inject gathered workspace directories, dependency configurations, and matching search patterns
|
|
892
1020
|
if (gatherRes.contextResult) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
// Compress
|
|
896
|
-
|
|
897
|
-
|
|
1021
|
+
const cachedContext = readCache(workspaceRoot, 'context_cache.json') || {};
|
|
1022
|
+
let cacheUpdated = false;
|
|
1023
|
+
// Compress each relevant file individually to allow granular cache hits
|
|
1024
|
+
const compressedFiles = new Map();
|
|
1025
|
+
for (const [filePath, content] of gatherRes.contextResult.relevantFiles.entries()) {
|
|
1026
|
+
if (content.length < 2000) {
|
|
1027
|
+
debugLog(`Bypassing cache and compression for ${filePath} (length ${content.length} < 2000)`);
|
|
1028
|
+
compressedFiles.set(filePath, content);
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
const fileHash = crypto.createHash('sha256').update(filePath + content).digest('hex');
|
|
1032
|
+
if (cachedContext[fileHash]) {
|
|
1033
|
+
debugLog(`Context cache HIT for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
|
|
1034
|
+
compressedFiles.set(filePath, cachedContext[fileHash]);
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
debugLog(`Context cache MISS for file ${filePath} (hash ${fileHash.substring(0, 8)})`);
|
|
1038
|
+
const compressPrompt = `Summarize the following file contents concisely. Preserve all exports, functions, classes, variables, and architectural purpose. Keep it under 2000 characters if possible. File: ${filePath}`;
|
|
1039
|
+
const summary = await compressTextUsingFlashLite(content, compressPrompt);
|
|
1040
|
+
compressedFiles.set(filePath, summary);
|
|
1041
|
+
cachedContext[fileHash] = summary;
|
|
1042
|
+
cacheUpdated = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (cacheUpdated) {
|
|
1046
|
+
// Keep cache size manageable by restricting to recent 100 file entries
|
|
1047
|
+
const keys = Object.keys(cachedContext);
|
|
1048
|
+
if (keys.length > 100) {
|
|
1049
|
+
const toDelete = keys.length - 100;
|
|
1050
|
+
for (let i = 0; i < toDelete; i++) {
|
|
1051
|
+
delete cachedContext[keys[i]];
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
writeCache(workspaceRoot, 'context_cache.json', cachedContext);
|
|
1055
|
+
}
|
|
1056
|
+
// Replace raw file contents with their compressed summaries
|
|
1057
|
+
gatherRes.contextResult.relevantFiles = compressedFiles;
|
|
1058
|
+
// Assemble the final context injection string
|
|
1059
|
+
const contextInjection = buildContextInjection(gatherRes.contextResult);
|
|
1060
|
+
debugLog(`Final compressed context injection size: ${contextInjection.length} chars`);
|
|
898
1061
|
dynamicSystemInstruction += '\n\n' + contextInjection;
|
|
899
1062
|
}
|
|
900
1063
|
// Apply the dynamic prompt updates and tool registrations to the active chat session
|
|
@@ -993,8 +1156,13 @@ ${diffOut}
|
|
|
993
1156
|
result = await chat.sendMessage(correctionPrompt);
|
|
994
1157
|
spinner.stop('');
|
|
995
1158
|
}
|
|
996
|
-
|
|
997
|
-
|
|
1159
|
+
const postExecutionChanges = changeLogger.getCurrentChangeSet()?.changes || [];
|
|
1160
|
+
const modifiedFiles = postExecutionChanges
|
|
1161
|
+
.filter((c) => c.action === 'modify' || c.action === 'create' || c.action === 'delete')
|
|
1162
|
+
.map((c) => path.relative(workspaceRoot, c.filePath));
|
|
1163
|
+
if (modifiedFiles.length > 0) {
|
|
1164
|
+
await invalidateCacheForDependents(workspaceRoot, modifiedFiles);
|
|
1165
|
+
}
|
|
998
1166
|
if (finalText) {
|
|
999
1167
|
console.log(`\n${pc.blue('◆')} ${pc.bold('Minovative Mind')} ${pc.dim(`(${chat.getModel()})`)}\n`);
|
|
1000
1168
|
// Strip out excessive empty lines generated by LLMs to prevent huge visual gaps in marked-terminal
|
|
@@ -1004,11 +1172,20 @@ ${diffOut}
|
|
|
1004
1172
|
if (latestUsage && latestUsage.remainingBalance !== undefined) {
|
|
1005
1173
|
p.log.info(`${pc.dim('Credits Remaining:')} ${pc.cyan(latestUsage.remainingBalance.toLocaleString())}`);
|
|
1006
1174
|
}
|
|
1175
|
+
const turnEndTime = Date.now();
|
|
1176
|
+
const turnDuration = ((turnEndTime - turnStartTime) / 1000).toFixed(1);
|
|
1177
|
+
p.log.info(`${pc.dim('Generated in')} ${pc.cyan(turnDuration + 's')}`);
|
|
1007
1178
|
}
|
|
1008
1179
|
catch (err) {
|
|
1009
1180
|
spinner.stop('');
|
|
1010
1181
|
const message = err instanceof Error ? err.message : String(err);
|
|
1011
1182
|
p.log.error(`${pc.red('Error:')} ${message}`);
|
|
1012
1183
|
}
|
|
1184
|
+
finally {
|
|
1185
|
+
// Persist any verified or partial changes into our session change ledger.
|
|
1186
|
+
// This ensures that if the process crashes, errors out, or is aborted mid-generation,
|
|
1187
|
+
// the user can still use /revert to undo the partial file mutations.
|
|
1188
|
+
changeLogger.commitChangeSet();
|
|
1189
|
+
}
|
|
1013
1190
|
}
|
|
1014
1191
|
}
|
|
@@ -11,6 +11,12 @@ export interface ChangeSet {
|
|
|
11
11
|
declare class ChangeLogger {
|
|
12
12
|
private changeStack;
|
|
13
13
|
private currentChangeSet;
|
|
14
|
+
private workspaceRoot;
|
|
15
|
+
private readonly MAX_HISTORY;
|
|
16
|
+
init(workspaceRoot: string): void;
|
|
17
|
+
private saveState;
|
|
18
|
+
getHistory(): ChangeSet[];
|
|
19
|
+
popUntil(timestamp: number): ChangeSet[];
|
|
14
20
|
startChangeSet(description: string): void;
|
|
15
21
|
logChange(filePath: string, originalContent: string | null, action: 'create' | 'modify' | 'delete'): void;
|
|
16
22
|
commitChangeSet(): void;
|
|
@@ -1,6 +1,41 @@
|
|
|
1
|
+
import { readCache, writeCache } from '../utils/projectStorage.js';
|
|
1
2
|
class ChangeLogger {
|
|
2
3
|
changeStack = [];
|
|
3
4
|
currentChangeSet = null;
|
|
5
|
+
workspaceRoot = '';
|
|
6
|
+
MAX_HISTORY = 10;
|
|
7
|
+
init(workspaceRoot) {
|
|
8
|
+
this.workspaceRoot = workspaceRoot;
|
|
9
|
+
const savedState = readCache(workspaceRoot, 'revert_state.json');
|
|
10
|
+
if (savedState && Array.isArray(savedState)) {
|
|
11
|
+
this.changeStack = savedState;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
saveState() {
|
|
15
|
+
if (!this.workspaceRoot)
|
|
16
|
+
return;
|
|
17
|
+
// Enforce history limit
|
|
18
|
+
if (this.changeStack.length > this.MAX_HISTORY) {
|
|
19
|
+
this.changeStack = this.changeStack.slice(-this.MAX_HISTORY);
|
|
20
|
+
}
|
|
21
|
+
writeCache(this.workspaceRoot, 'revert_state.json', this.changeStack);
|
|
22
|
+
}
|
|
23
|
+
getHistory() {
|
|
24
|
+
return [...this.changeStack];
|
|
25
|
+
}
|
|
26
|
+
popUntil(timestamp) {
|
|
27
|
+
const changesToRevert = [];
|
|
28
|
+
while (this.changeStack.length > 0) {
|
|
29
|
+
const top = this.changeStack[this.changeStack.length - 1];
|
|
30
|
+
changesToRevert.push(top);
|
|
31
|
+
this.changeStack.pop();
|
|
32
|
+
if (top.timestamp === timestamp) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
this.saveState();
|
|
37
|
+
return changesToRevert;
|
|
38
|
+
}
|
|
4
39
|
startChangeSet(description) {
|
|
5
40
|
if (this.currentChangeSet) {
|
|
6
41
|
this.commitChangeSet();
|
|
@@ -34,6 +69,7 @@ class ChangeLogger {
|
|
|
34
69
|
commitChangeSet() {
|
|
35
70
|
if (this.currentChangeSet && this.currentChangeSet.changes.length > 0) {
|
|
36
71
|
this.changeStack.push(this.currentChangeSet);
|
|
72
|
+
this.saveState();
|
|
37
73
|
}
|
|
38
74
|
this.currentChangeSet = null;
|
|
39
75
|
}
|
|
@@ -48,7 +84,11 @@ class ChangeLogger {
|
|
|
48
84
|
popLastChangeSet() {
|
|
49
85
|
if (this.changeStack.length === 0)
|
|
50
86
|
return null;
|
|
51
|
-
|
|
87
|
+
const popped = this.changeStack.pop() || null;
|
|
88
|
+
if (popped) {
|
|
89
|
+
this.saveState();
|
|
90
|
+
}
|
|
91
|
+
return popped;
|
|
52
92
|
}
|
|
53
93
|
hasChanges() {
|
|
54
94
|
return this.changeStack.length > 0;
|
|
@@ -39,7 +39,8 @@ function buildCommand(language, scriptPath) {
|
|
|
39
39
|
case 'go':
|
|
40
40
|
return `go run "${scriptPath}"`;
|
|
41
41
|
case 'rust': {
|
|
42
|
-
const
|
|
42
|
+
const binExt = os.platform() === 'win32' ? '.exe' : '';
|
|
43
|
+
const binPath = scriptPath.replace('.rs', binExt);
|
|
43
44
|
return `rustc "${scriptPath}" -o "${binPath}" && "${binPath}"`;
|
|
44
45
|
}
|
|
45
46
|
default:
|
|
@@ -87,7 +88,8 @@ export async function runEphemeralScript(workspaceRoot, language, code, options)
|
|
|
87
88
|
// Collect all temp files created so we can clean them up unconditionally
|
|
88
89
|
const tempFiles = [scriptPath];
|
|
89
90
|
if (language.toLowerCase() === 'rust') {
|
|
90
|
-
|
|
91
|
+
const binExt = os.platform() === 'win32' ? '.exe' : '';
|
|
92
|
+
tempFiles.push(scriptPath.replace('.rs', binExt));
|
|
91
93
|
}
|
|
92
94
|
try {
|
|
93
95
|
await fs.writeFile(scriptPath, code, 'utf-8');
|
|
@@ -438,7 +438,7 @@ async function walkWorkspace(workspaceRoot) {
|
|
|
438
438
|
.map((l) => l.trim())
|
|
439
439
|
.filter((l) => l && !l.startsWith('#'));
|
|
440
440
|
for (const line of lines) {
|
|
441
|
-
const clean = line.replace(/^\//, '').replace(/\/$/, '');
|
|
441
|
+
const clean = line.replace(/\\/g, '/').replace(/^\//, '').replace(/\/$/, '');
|
|
442
442
|
if (!clean.includes('*')) {
|
|
443
443
|
extraIgnored.add(clean);
|
|
444
444
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
export const LOGO = `
|
|
3
|
+
${pc.blue('///')} ${pc.cyan('//')} ${pc.cyan('\\\\')} ${pc.blue('\\\\\\')} ${pc.blue(' __ __ __ __ ____ _ ___ ')}
|
|
4
|
+
${pc.blue('///')} ${pc.cyan('//')} ${pc.cyan('\\\\')} ${pc.blue('\\\\\\')} ${pc.blue(' | \\/ || \\/ | / ___| | |_ _|')}
|
|
5
|
+
${pc.blue('///')} ${pc.cyan('//')} ${pc.cyan('●')} ${pc.cyan('\\\\')} ${pc.blue('\\\\\\')} ${pc.blue(' | |\\/| || |\\/| | | | | | | | ')}
|
|
6
|
+
${pc.blue('\\\\\\')} ${pc.cyan('\\\\')} ${pc.cyan('//')} ${pc.blue('///')} ${pc.blue(' | | | || | | | | |___| |___ | | ')}
|
|
7
|
+
${pc.blue('\\\\\\')} ${pc.cyan('\\\\')} ${pc.cyan('//')} ${pc.blue('///')} ${pc.blue(' |_| |_||_| |_| \\____|_____|___|')}
|
|
8
|
+
${pc.blue('\\\\\\')} ${pc.cyan('\\\\')} ${pc.cyan('//')} ${pc.blue('///')}
|
|
9
|
+
`;
|
|
10
|
+
export function printLogo() {
|
|
11
|
+
console.log(pc.bold(LOGO));
|
|
12
|
+
}
|
|
@@ -5,6 +5,7 @@ const EXTENSION_MAP = {
|
|
|
5
5
|
'.py': 'python', '.pyw': 'python',
|
|
6
6
|
'.go': 'go',
|
|
7
7
|
'.rs': 'rust',
|
|
8
|
+
'.php': 'php',
|
|
8
9
|
};
|
|
9
10
|
function detectLanguage(filePath) {
|
|
10
11
|
return EXTENSION_MAP[path.extname(filePath).toLowerCase()] ?? 'unknown';
|
|
@@ -26,16 +27,16 @@ function stripStringsAndComments(source, lang) {
|
|
|
26
27
|
while (i < source.length) {
|
|
27
28
|
const ch = source[i];
|
|
28
29
|
const next = source[i + 1];
|
|
29
|
-
// ── JS/TS/Go/Rust: line comments (//) ─────────────────────
|
|
30
|
-
if ((lang === 'js' || lang === 'go' || lang === 'rust') && ch === '/' && next === '/') {
|
|
30
|
+
// ── JS/TS/Go/Rust/PHP: line comments (//) ─────────────────────
|
|
31
|
+
if ((lang === 'js' || lang === 'go' || lang === 'rust' || lang === 'php') && ch === '/' && next === '/') {
|
|
31
32
|
const eol = source.indexOf('\n', i);
|
|
32
33
|
const end = eol === -1 ? source.length : eol;
|
|
33
34
|
result += ' '.repeat(end - i);
|
|
34
35
|
i = end;
|
|
35
36
|
continue;
|
|
36
37
|
}
|
|
37
|
-
// ── JS/TS/Go/Rust: block comments (/* ... */) ─────────────
|
|
38
|
-
if ((lang === 'js' || lang === 'go' || lang === 'rust') && ch === '/' && next === '*') {
|
|
38
|
+
// ── JS/TS/Go/Rust/PHP: block comments (/* ... */) ─────────────
|
|
39
|
+
if ((lang === 'js' || lang === 'go' || lang === 'rust' || lang === 'php') && ch === '/' && next === '*') {
|
|
39
40
|
const end = source.indexOf('*/', i + 2);
|
|
40
41
|
const closePos = end === -1 ? source.length : end + 2;
|
|
41
42
|
// Preserve newlines for line counting
|
|
@@ -44,8 +45,8 @@ function stripStringsAndComments(source, lang) {
|
|
|
44
45
|
i = closePos;
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
|
-
// ── Python: line comments (#) ─────────────────────────────
|
|
48
|
-
if (lang === 'python' && ch === '#') {
|
|
48
|
+
// ── Python/PHP: line comments (#) ─────────────────────────────
|
|
49
|
+
if ((lang === 'python' || lang === 'php') && ch === '#') {
|
|
49
50
|
const eol = source.indexOf('\n', i);
|
|
50
51
|
const end = eol === -1 ? source.length : eol;
|
|
51
52
|
result += ' '.repeat(end - i);
|
|
@@ -62,6 +63,26 @@ function stripStringsAndComments(source, lang) {
|
|
|
62
63
|
i = closePos;
|
|
63
64
|
continue;
|
|
64
65
|
}
|
|
66
|
+
// ── PHP: Heredoc/Nowdoc (<<<EOF ... EOF;) ─────────────────────
|
|
67
|
+
if (lang === 'php' && ch === '<' && next === '<' && source[i + 2] === '<') {
|
|
68
|
+
const eol = source.indexOf('\n', i);
|
|
69
|
+
if (eol !== -1) {
|
|
70
|
+
// e.g. <<<EOF or <<<'EOF'
|
|
71
|
+
const idMatch = source.substring(i + 3, eol).match(/[A-Za-z0-9_]+/);
|
|
72
|
+
if (idMatch) {
|
|
73
|
+
const identifier = idMatch[0];
|
|
74
|
+
const closePattern = new RegExp(`\\n${identifier}(;|\\n|$)`);
|
|
75
|
+
const match = source.substring(eol).match(closePattern);
|
|
76
|
+
if (match) {
|
|
77
|
+
const end = eol + match.index + match[0].length;
|
|
78
|
+
const segment = source.substring(i, end);
|
|
79
|
+
result += segment.replace(/[^\n]/g, ' ');
|
|
80
|
+
i = end;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
65
86
|
// ── All languages: string literals ────────────────────────
|
|
66
87
|
if (ch === '"' || ch === "'" || (lang === 'js' && ch === '`')) {
|
|
67
88
|
const quote = ch;
|
|
@@ -119,7 +140,7 @@ const detectNestedLoops = (source, stripped, lang, findings) => {
|
|
|
119
140
|
return;
|
|
120
141
|
}
|
|
121
142
|
// C-family: scan for loop keywords and track brace depth
|
|
122
|
-
const loopPattern = /\b(for|while|do)\s*[\s(]/g;
|
|
143
|
+
const loopPattern = /\b(for|foreach|while|do)\s*[\s(]/g;
|
|
123
144
|
const lines = stripped.split('\n');
|
|
124
145
|
for (let i = 0; i < lines.length; i++) {
|
|
125
146
|
const line = lines[i];
|
|
@@ -144,7 +165,7 @@ const detectNestedLoops = (source, stripped, lang, findings) => {
|
|
|
144
165
|
}
|
|
145
166
|
// Check lines inside the outer loop body for nested loops
|
|
146
167
|
if (j > i && foundOpening && depth > 0) {
|
|
147
|
-
if (/\b(for|while|do)\s*[\s(]/.test(innerLine)) {
|
|
168
|
+
if (/\b(for|foreach|while|do)\s*[\s(]/.test(innerLine)) {
|
|
148
169
|
findings.push({
|
|
149
170
|
severity: 'WARNING',
|
|
150
171
|
code: 'PERF-001',
|
|
@@ -484,6 +505,21 @@ const detectUnboundedFetch = (_source, stripped, lang, findings) => {
|
|
|
484
505
|
}
|
|
485
506
|
}
|
|
486
507
|
}
|
|
508
|
+
if (lang === 'php') {
|
|
509
|
+
if (/\.(all|get|findAll|query)\s*\(|::all\s*\(/.test(line)) {
|
|
510
|
+
const window = lines.slice(i, Math.min(i + 5, lines.length)).join('\n');
|
|
511
|
+
if (!/\.(limit|take|paginate|offset)\s*\(/.test(window) &&
|
|
512
|
+
!/\bLIMIT\b/i.test(window)) {
|
|
513
|
+
findings.push({
|
|
514
|
+
severity: 'WARNING',
|
|
515
|
+
code: 'PERF-008',
|
|
516
|
+
line: i + 1,
|
|
517
|
+
message: 'Database/ORM query without limit() or paginate() — may fetch unbounded rows into memory.',
|
|
518
|
+
suggestion: 'Add a limit() or use pagination.',
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
487
523
|
// Go / Rust: detect SQL queries without LIMIT
|
|
488
524
|
if (lang === 'go' || lang === 'rust') {
|
|
489
525
|
if (/SELECT\s+/i.test(line) && !/\bLIMIT\b/i.test(line)) {
|
|
@@ -502,6 +538,67 @@ const detectUnboundedFetch = (_source, stripped, lang, findings) => {
|
|
|
502
538
|
}
|
|
503
539
|
}
|
|
504
540
|
};
|
|
541
|
+
// ── PERF-PHP1 / PHP2 / PHP3: PHP-specific loop anti-patterns ───────
|
|
542
|
+
const detectPhpInLoopOperations = (_source, stripped, lang, findings) => {
|
|
543
|
+
if (lang !== 'php')
|
|
544
|
+
return;
|
|
545
|
+
const lines = stripped.split('\n');
|
|
546
|
+
let loopDepth = 0;
|
|
547
|
+
let braceDepth = 0;
|
|
548
|
+
let inLoop = false;
|
|
549
|
+
let loopBraceStart = 0;
|
|
550
|
+
for (let i = 0; i < lines.length; i++) {
|
|
551
|
+
const line = lines[i];
|
|
552
|
+
if (/\b(for|foreach|while|do)\s*[\s(]/.test(line) && !inLoop) {
|
|
553
|
+
inLoop = true;
|
|
554
|
+
loopBraceStart = braceDepth;
|
|
555
|
+
loopDepth++;
|
|
556
|
+
}
|
|
557
|
+
for (const ch of line) {
|
|
558
|
+
if (ch === '{')
|
|
559
|
+
braceDepth++;
|
|
560
|
+
else if (ch === '}') {
|
|
561
|
+
braceDepth--;
|
|
562
|
+
if (inLoop && braceDepth <= loopBraceStart) {
|
|
563
|
+
inLoop = false;
|
|
564
|
+
loopDepth--;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (loopDepth > 0) {
|
|
569
|
+
// PERF-PHP1: array_merge
|
|
570
|
+
if (/\barray_merge\s*\(/.test(line)) {
|
|
571
|
+
findings.push({
|
|
572
|
+
severity: 'WARNING',
|
|
573
|
+
code: 'PERF-PHP1',
|
|
574
|
+
line: i + 1,
|
|
575
|
+
message: 'array_merge() inside a loop — causes O(n²) memory inflation and copying.',
|
|
576
|
+
suggestion: 'Append to array with $arr[] = $val and merge once outside the loop, or use the splat operator.',
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
// PERF-PHP2: N+1 queries
|
|
580
|
+
if (/(->get\(|->find\(|->query\(|->findAll\(|::find\(|DB::|->execute\()/.test(line)) {
|
|
581
|
+
findings.push({
|
|
582
|
+
severity: 'WARNING',
|
|
583
|
+
code: 'PERF-PHP2',
|
|
584
|
+
line: i + 1,
|
|
585
|
+
message: 'Database query execution inside a loop (N+1 query problem).',
|
|
586
|
+
suggestion: 'Pre-fetch data outside the loop using eager loading (e.g., with()) or a single IN() query.',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
// PERF-PHP3: Blocking I/O
|
|
590
|
+
if (/\b(file_get_contents|curl_exec|fopen)\s*\(/.test(line)) {
|
|
591
|
+
findings.push({
|
|
592
|
+
severity: 'WARNING',
|
|
593
|
+
code: 'PERF-PHP3',
|
|
594
|
+
line: i + 1,
|
|
595
|
+
message: 'Blocking I/O operation inside a loop.',
|
|
596
|
+
suggestion: 'Perform I/O in parallel using curl_multi_exec, or bulk fetch data before the loop.',
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
505
602
|
// ─── Rule Registry ───────────────────────────────────────────────────
|
|
506
603
|
const ALL_RULES = [
|
|
507
604
|
detectNestedLoops,
|
|
@@ -512,6 +609,7 @@ const ALL_RULES = [
|
|
|
512
609
|
detectDangerousEval,
|
|
513
610
|
detectUnsafeJsonParse,
|
|
514
611
|
detectUnboundedFetch,
|
|
612
|
+
detectPhpInLoopOperations,
|
|
515
613
|
];
|
|
516
614
|
// ─── Supported Extensions for Auditing ───────────────────────────────
|
|
517
615
|
const AUDITABLE_EXTENSIONS = new Set(Object.keys(EXTENSION_MAP));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gets the path to the project's .minovativemind storage directory.
|
|
3
|
+
*/
|
|
4
|
+
export declare function getProjectStorageDir(workspaceRoot: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Ensures that the .minovativemind directory exists in the workspace root.
|
|
7
|
+
* This is a self-healing directory used for crash-safe state and caching.
|
|
8
|
+
*/
|
|
9
|
+
export declare function ensureProjectStorage(workspaceRoot: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* Ensures that the .minovativemind directory is safely ignored from version control,
|
|
12
|
+
* docker images, and npm packages to prevent state leakage.
|
|
13
|
+
*/
|
|
14
|
+
export declare function ensureIgnored(workspaceRoot: string): void;
|
|
15
|
+
/**
|
|
16
|
+
* Reads a JSON file from the project's .minovativemind directory.
|
|
17
|
+
* Returns null if the file does not exist or is corrupted.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readCache<T = any>(workspaceRoot: string, filename: string): T | null;
|
|
20
|
+
/**
|
|
21
|
+
* Writes data to a JSON file in the project's .minovativemind directory using an atomic write pattern
|
|
22
|
+
* to prevent corruption from concurrent accesses.
|
|
23
|
+
*/
|
|
24
|
+
export declare function writeCache(workspaceRoot: string, filename: string, data: any): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Invalidates the context cache for files that depend on the given modified files.
|
|
27
|
+
* This ensures that if a core file changes, its dependents are forced to generate
|
|
28
|
+
* fresh summaries reflecting the new behavior.
|
|
29
|
+
*/
|
|
30
|
+
export declare function invalidateCacheForDependents(workspaceRoot: string, changedFiles: string[]): Promise<void>;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as pc from 'picocolors';
|
|
4
|
+
import { buildDependencyGraph } from './dependencyTracer.js';
|
|
5
|
+
/**
|
|
6
|
+
* Gets the path to the project's .minovativemind storage directory.
|
|
7
|
+
*/
|
|
8
|
+
export function getProjectStorageDir(workspaceRoot) {
|
|
9
|
+
return path.join(workspaceRoot, '.minovativemind');
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Ensures that the .minovativemind directory exists in the workspace root.
|
|
13
|
+
* This is a self-healing directory used for crash-safe state and caching.
|
|
14
|
+
*/
|
|
15
|
+
export function ensureProjectStorage(workspaceRoot) {
|
|
16
|
+
const storageDir = getProjectStorageDir(workspaceRoot);
|
|
17
|
+
if (!fs.existsSync(storageDir)) {
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(storageDir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// Non-fatal error, CLI can proceed without cache if permissions fail
|
|
23
|
+
console.warn(pc.yellow(`Failed to create .minovativemind directory: ${err instanceof Error ? err.message : String(err)}`));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Ensures that the .minovativemind directory is safely ignored from version control,
|
|
29
|
+
* docker images, and npm packages to prevent state leakage.
|
|
30
|
+
*/
|
|
31
|
+
export function ensureIgnored(workspaceRoot) {
|
|
32
|
+
const ignoreFiles = ['.gitignore', '.dockerignore', '.npmignore', '.eslintignore', '.prettierignore'];
|
|
33
|
+
const gitDirExists = fs.existsSync(path.join(workspaceRoot, '.git'));
|
|
34
|
+
// If .git exists but .gitignore doesn't, we should create a minimal .gitignore
|
|
35
|
+
if (gitDirExists && !fs.existsSync(path.join(workspaceRoot, '.gitignore'))) {
|
|
36
|
+
try {
|
|
37
|
+
fs.writeFileSync(path.join(workspaceRoot, '.gitignore'), '# Minovative CLI Data\n.minovativemind/\n', 'utf-8');
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// ignore
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const ignoreFile of ignoreFiles) {
|
|
44
|
+
const filePath = path.join(workspaceRoot, ignoreFile);
|
|
45
|
+
// Only modify the file if it actually exists (except for the .gitignore creation above)
|
|
46
|
+
if (fs.existsSync(filePath)) {
|
|
47
|
+
try {
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
// Check if it already ignores .minovativemind or .minovativemind/
|
|
50
|
+
if (!content.includes('.minovativemind')) {
|
|
51
|
+
// Append with a newline just in case the file doesn't end with one
|
|
52
|
+
const appendContent = content.endsWith('\n') || content.length === 0 ? '.minovativemind/\n' : '\n.minovativemind/\n';
|
|
53
|
+
fs.appendFileSync(filePath, appendContent, 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
// Silently ignore permissions errors etc.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Reads a JSON file from the project's .minovativemind directory.
|
|
64
|
+
* Returns null if the file does not exist or is corrupted.
|
|
65
|
+
*/
|
|
66
|
+
export function readCache(workspaceRoot, filename) {
|
|
67
|
+
const filePath = path.join(getProjectStorageDir(workspaceRoot), filename);
|
|
68
|
+
if (!fs.existsSync(filePath))
|
|
69
|
+
return null;
|
|
70
|
+
try {
|
|
71
|
+
const data = fs.readFileSync(filePath, 'utf-8');
|
|
72
|
+
return JSON.parse(data);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// Treat parsing errors as cache misses
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Writes data to a JSON file in the project's .minovativemind directory using an atomic write pattern
|
|
81
|
+
* to prevent corruption from concurrent accesses.
|
|
82
|
+
*/
|
|
83
|
+
export async function writeCache(workspaceRoot, filename, data) {
|
|
84
|
+
ensureProjectStorage(workspaceRoot); // Self-healing check
|
|
85
|
+
const filePath = path.join(getProjectStorageDir(workspaceRoot), filename);
|
|
86
|
+
const tempPath = `${filePath}.${Date.now()}-${Math.floor(Math.random() * 1000)}.tmp`;
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
89
|
+
// Windows retry logic for EPERM/EBUSY on file replace
|
|
90
|
+
const maxRetries = 3;
|
|
91
|
+
let attempt = 0;
|
|
92
|
+
while (attempt <= maxRetries) {
|
|
93
|
+
try {
|
|
94
|
+
fs.renameSync(tempPath, filePath);
|
|
95
|
+
break; // Success
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (attempt === maxRetries || (err.code !== 'EPERM' && err.code !== 'EBUSY')) {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
attempt++;
|
|
102
|
+
const delay = attempt === 1 ? 10 : attempt === 2 ? 50 : 100;
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// Non-fatal, clean up temp file if possible
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(tempPath))
|
|
111
|
+
fs.unlinkSync(tempPath);
|
|
112
|
+
}
|
|
113
|
+
catch { }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Invalidates the context cache for files that depend on the given modified files.
|
|
118
|
+
* This ensures that if a core file changes, its dependents are forced to generate
|
|
119
|
+
* fresh summaries reflecting the new behavior.
|
|
120
|
+
*/
|
|
121
|
+
export async function invalidateCacheForDependents(workspaceRoot, changedFiles) {
|
|
122
|
+
if (!changedFiles || changedFiles.length === 0)
|
|
123
|
+
return;
|
|
124
|
+
try {
|
|
125
|
+
const cachedContext = readCache(workspaceRoot, 'context_cache.json');
|
|
126
|
+
if (!cachedContext)
|
|
127
|
+
return;
|
|
128
|
+
let updated = false;
|
|
129
|
+
const graph = await buildDependencyGraph(workspaceRoot);
|
|
130
|
+
for (const file of changedFiles) {
|
|
131
|
+
const reverseDeps = graph.getImportedBy(file);
|
|
132
|
+
for (const dep of reverseDeps) {
|
|
133
|
+
try {
|
|
134
|
+
const content = fs.readFileSync(path.join(workspaceRoot, dep), 'utf-8');
|
|
135
|
+
const crypto = await import('crypto');
|
|
136
|
+
const fileHash = crypto
|
|
137
|
+
.createHash('sha256')
|
|
138
|
+
.update(dep + content)
|
|
139
|
+
.digest('hex');
|
|
140
|
+
if (cachedContext[fileHash]) {
|
|
141
|
+
delete cachedContext[fileHash];
|
|
142
|
+
updated = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
// If we can't read the dependent file, it might have been deleted. Skip it.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (updated) {
|
|
151
|
+
writeCache(workspaceRoot, 'context_cache.json', cachedContext);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
console.warn(pc.yellow(`Failed to invalidate cache for dependents: ${err}`));
|
|
156
|
+
}
|
|
157
|
+
}
|
package/oclif.manifest.json
CHANGED
package/package.json
CHANGED