voraiguard 0.1.2
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 +31 -0
- package/package.json +23 -0
- package/src/cli.js +148 -0
- package/src/lib/README.md +6 -0
- package/src/lib/config.js +39 -0
- package/src/lib/fsutil.js +75 -0
- package/src/lib/index.js +1 -0
- package/src/lib/risk.js +57 -0
- package/src/lib/snapshot.js +131 -0
- package/src/lib/state.js +100 -0
- package/src/lib/textmetrics.js +67 -0
- package/src/lib/version.js +1 -0
- package/src/lib/watcher.js +167 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Vorai Guard (MVP)
|
|
2
|
+
|
|
3
|
+
## Install
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g voraiguard
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Use
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
voraiguard init .
|
|
13
|
+
voraiguard watch .
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it does
|
|
17
|
+
|
|
18
|
+
Stops AI tools from deleting or corrupting your code.
|
|
19
|
+
|
|
20
|
+
Detects risky changes and lets you approve or rollback instantly.
|
|
21
|
+
|
|
22
|
+
## 10-second demo
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
🚨 Risky change detected
|
|
26
|
+
Reasons:
|
|
27
|
+
- 12 files changed
|
|
28
|
+
- Size shrink: 41%
|
|
29
|
+
|
|
30
|
+
[A]pprove [R]ollback
|
|
31
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "voraiguard",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Vorai Guard — detects risky file changes and can auto-rollback with local snapshots.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"voraiguard": "src/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "node src/cli.js",
|
|
15
|
+
"start": "node src/cli.js",
|
|
16
|
+
"test": "node -e \"console.log('no tests yet')\""
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"chokidar": "^3.6.0",
|
|
20
|
+
"fast-glob": "^3.3.2",
|
|
21
|
+
"inquirer": "^8.2.6"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const fsp = fs.promises;
|
|
6
|
+
|
|
7
|
+
const { watchProject } = require('./lib/watcher');
|
|
8
|
+
const { listSnapshots, restoreSnapshot, getLatestSnapshotId } = require('./lib/snapshot');
|
|
9
|
+
const { loadConfig } = require('./lib/config');
|
|
10
|
+
|
|
11
|
+
async function ensureGitignoreHasSnapshots(projectRoot) {
|
|
12
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
13
|
+
const line = '.voraiguard/snapshots/';
|
|
14
|
+
const broadIgnores = new Set(['.voraiguard/', '.voraiguard', '.voraiguard/*']);
|
|
15
|
+
|
|
16
|
+
let existing = '';
|
|
17
|
+
try {
|
|
18
|
+
existing = await fsp.readFile(gitignorePath, 'utf8');
|
|
19
|
+
} catch {
|
|
20
|
+
existing = '';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inputLines = existing.split(/\r?\n/);
|
|
24
|
+
|
|
25
|
+
let removedBroad = 0;
|
|
26
|
+
const kept = [];
|
|
27
|
+
for (const l of inputLines) {
|
|
28
|
+
const t = l.trim();
|
|
29
|
+
if (broadIgnores.has(t)) {
|
|
30
|
+
removedBroad++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
kept.push(l);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasSnapshotsIgnore = kept.some((l) => l.trim() === line);
|
|
37
|
+
if (!hasSnapshotsIgnore) kept.push(line);
|
|
38
|
+
|
|
39
|
+
// Ensure trailing newline.
|
|
40
|
+
let next = kept.join('\n');
|
|
41
|
+
if (!next.endsWith('\n')) next += '\n';
|
|
42
|
+
|
|
43
|
+
const changed = next !== existing;
|
|
44
|
+
if (changed) await fsp.writeFile(gitignorePath, next, 'utf8');
|
|
45
|
+
return { changed, removedBroad, addedSnapshotsIgnore: !hasSnapshotsIgnore };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function printVersion() {
|
|
49
|
+
// Read from package.json so it's always the published version.
|
|
50
|
+
// eslint-disable-next-line import/no-dynamic-require
|
|
51
|
+
const pkg = require('../package.json');
|
|
52
|
+
console.log(pkg.version);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function printHelp() {
|
|
56
|
+
console.log(`Vorai Guard (MVP)
|
|
57
|
+
|
|
58
|
+
Usage:
|
|
59
|
+
voraiguard watch [folder]
|
|
60
|
+
voraiguard init [folder]
|
|
61
|
+
voraiguard list [folder]
|
|
62
|
+
voraiguard restore [folder] [snapshotId]
|
|
63
|
+
voraiguard version
|
|
64
|
+
|
|
65
|
+
Notes:
|
|
66
|
+
- Snapshots live inside the watched folder at .voraiguard/snapshots
|
|
67
|
+
- This is local-only and intentionally minimal.`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function main() {
|
|
71
|
+
const args = process.argv.slice(2);
|
|
72
|
+
const cmd = (args[0] || '').toLowerCase();
|
|
73
|
+
|
|
74
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
75
|
+
printVersion();
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (cmd === 'version') {
|
|
80
|
+
printVersion();
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
85
|
+
printHelp();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (cmd === 'watch') {
|
|
90
|
+
const folder = path.resolve(args[1] || process.cwd());
|
|
91
|
+
if (!fs.existsSync(folder)) {
|
|
92
|
+
console.error(`Folder not found: ${folder}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
await watchProject(folder);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (cmd === 'init') {
|
|
100
|
+
const folder = path.resolve(args[1] || process.cwd());
|
|
101
|
+
if (!fs.existsSync(folder)) {
|
|
102
|
+
console.error(`Folder not found: ${folder}`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await loadConfig(folder);
|
|
107
|
+
const result = await ensureGitignoreHasSnapshots(folder);
|
|
108
|
+
console.log(`Initialized Vorai Guard in: ${folder}`);
|
|
109
|
+
console.log('Created/verified: .voraiguard/config.json');
|
|
110
|
+
if (result.changed) {
|
|
111
|
+
if (result.removedBroad > 0) console.log('Updated: .gitignore (now tracking .voraiguard/config.json)');
|
|
112
|
+
if (result.addedSnapshotsIgnore) console.log('Updated: .gitignore (ignored .voraiguard/snapshots/)');
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (cmd === 'list') {
|
|
118
|
+
const folder = path.resolve(args[1] || process.cwd());
|
|
119
|
+
const snaps = await listSnapshots(folder);
|
|
120
|
+
if (snaps.length === 0) {
|
|
121
|
+
console.log('No snapshots found.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
for (const s of snaps) console.log(`${s.id}\t${s.createdAt}`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (cmd === 'restore') {
|
|
129
|
+
const folder = path.resolve(args[1] || process.cwd());
|
|
130
|
+
const snapshotId = args[2] || (await getLatestSnapshotId(folder));
|
|
131
|
+
if (!snapshotId) {
|
|
132
|
+
console.error('No snapshot available to restore.');
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
await restoreSnapshot(folder, snapshotId);
|
|
136
|
+
console.log(`Restored snapshot: ${snapshotId}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.error(`Unknown command: ${cmd}`);
|
|
141
|
+
printHelp();
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main().catch((err) => {
|
|
146
|
+
console.error(err && err.stack ? err.stack : String(err));
|
|
147
|
+
process.exit(1);
|
|
148
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { ensureDir } = require('./fsutil');
|
|
4
|
+
|
|
5
|
+
const DEFAULTS = {
|
|
6
|
+
debounceMs: 750,
|
|
7
|
+
burstWindowMs: 3000,
|
|
8
|
+
burstEventThreshold: 20,
|
|
9
|
+
burstFileThreshold: 8,
|
|
10
|
+
maxFilesChanged: 10,
|
|
11
|
+
shrinkPercentTrigger: 30,
|
|
12
|
+
maxDeletedFiles: 0,
|
|
13
|
+
functionDropPercentTrigger: 30,
|
|
14
|
+
renameStormMin: 10
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function configPath(projectRoot) {
|
|
18
|
+
return path.join(projectRoot, '.voraiguard', 'config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function loadConfig(projectRoot) {
|
|
22
|
+
const cfgPath = configPath(projectRoot);
|
|
23
|
+
await ensureDir(path.dirname(cfgPath));
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(cfgPath)) {
|
|
26
|
+
fs.writeFileSync(cfgPath, JSON.stringify(DEFAULTS, null, 2));
|
|
27
|
+
return { ...DEFAULTS };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
32
|
+
const parsed = JSON.parse(raw);
|
|
33
|
+
return { ...DEFAULTS, ...(parsed || {}) };
|
|
34
|
+
} catch {
|
|
35
|
+
return { ...DEFAULTS };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { loadConfig, DEFAULTS };
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const fsp = fs.promises;
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
async function ensureDir(dirPath) {
|
|
7
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isPathInside(childPath, parentPath) {
|
|
11
|
+
const rel = path.relative(parentPath, childPath);
|
|
12
|
+
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeRel(projectRoot, absPath) {
|
|
16
|
+
const rel = path.relative(projectRoot, absPath);
|
|
17
|
+
return rel.split(path.sep).join('/');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function shouldIgnoreRel(rel) {
|
|
21
|
+
const parts = rel.split('/');
|
|
22
|
+
if (parts.includes('node_modules')) return true;
|
|
23
|
+
if (parts.includes('.git')) return true;
|
|
24
|
+
if (parts[0] === '.voraiguard') return true;
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fileHash(absPath) {
|
|
29
|
+
const h = crypto.createHash('sha256');
|
|
30
|
+
const stream = fs.createReadStream(absPath);
|
|
31
|
+
return await new Promise((resolve, reject) => {
|
|
32
|
+
stream.on('data', (d) => h.update(d));
|
|
33
|
+
stream.on('error', reject);
|
|
34
|
+
stream.on('end', () => resolve(h.digest('hex')));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function looksLikeText(buf) {
|
|
39
|
+
// Simple heuristic: if it contains a NUL byte, treat as binary.
|
|
40
|
+
for (let i = 0; i < buf.length; i++) {
|
|
41
|
+
if (buf[i] === 0) return false;
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readTextFile(absPath, maxBytes = 1024 * 1024) {
|
|
47
|
+
const buf = await fsp.readFile(absPath);
|
|
48
|
+
if (!looksLikeText(buf)) return null;
|
|
49
|
+
const sliced = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf;
|
|
50
|
+
return sliced.toString('utf8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function copyFilePreserveDirs(srcAbs, destAbs) {
|
|
54
|
+
await ensureDir(path.dirname(destAbs));
|
|
55
|
+
await fsp.copyFile(srcAbs, destAbs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function rmIfExists(absPath) {
|
|
59
|
+
try {
|
|
60
|
+
await fsp.rm(absPath, { recursive: true, force: true });
|
|
61
|
+
} catch {
|
|
62
|
+
// ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
ensureDir,
|
|
68
|
+
isPathInside,
|
|
69
|
+
normalizeRel,
|
|
70
|
+
shouldIgnoreRel,
|
|
71
|
+
fileHash,
|
|
72
|
+
readTextFile,
|
|
73
|
+
copyFilePreserveDirs,
|
|
74
|
+
rmIfExists
|
|
75
|
+
};
|
package/src/lib/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
// reserved for future shared exports
|
package/src/lib/risk.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function pctDrop(before, after) {
|
|
2
|
+
if (before <= 0) return 0;
|
|
3
|
+
const drop = before - after;
|
|
4
|
+
return (drop / before) * 100;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function evaluateRisk(diff, cfg, context = {}) {
|
|
8
|
+
const reasons = [];
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
const burstEvents = typeof context.burstEvents === 'number' ? context.burstEvents : 0;
|
|
12
|
+
const burstFiles = typeof context.burstFiles === 'number' ? context.burstFiles : 0;
|
|
13
|
+
const windowMs = typeof cfg.burstWindowMs === 'number' ? cfg.burstWindowMs : 3000;
|
|
14
|
+
const eventThresh = typeof cfg.burstEventThreshold === 'number' ? cfg.burstEventThreshold : 20;
|
|
15
|
+
const fileThresh = typeof cfg.burstFileThreshold === 'number' ? cfg.burstFileThreshold : 8;
|
|
16
|
+
|
|
17
|
+
if (burstEvents >= eventThresh || burstFiles >= fileThresh) {
|
|
18
|
+
reasons.push(
|
|
19
|
+
`Burst editing detected (${burstEvents} events, ${burstFiles} files within ${Math.round(windowMs / 1000)}s)`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (diff.changedCount >= cfg.maxFilesChanged) {
|
|
25
|
+
reasons.push(`Many files changed at once (${diff.changedCount} >= ${cfg.maxFilesChanged})`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (diff.deleted.length > cfg.maxDeletedFiles) {
|
|
29
|
+
reasons.push(`File deletions detected (${diff.deleted.length})`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (diff.sizeBeforeChanged > 0) {
|
|
33
|
+
const shrink = pctDrop(diff.sizeBeforeChanged, diff.sizeAfterChanged);
|
|
34
|
+
if (shrink >= cfg.shrinkPercentTrigger) {
|
|
35
|
+
reasons.push(`Size shrink across changed files (${shrink.toFixed(1)}% >= ${cfg.shrinkPercentTrigger}%)`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (diff.funcBeforeChanged > 0) {
|
|
40
|
+
const fdrop = pctDrop(diff.funcBeforeChanged, diff.funcAfterChanged);
|
|
41
|
+
if (fdrop >= cfg.functionDropPercentTrigger) {
|
|
42
|
+
reasons.push(`Function count drop in changed files (${fdrop.toFixed(1)}% >= ${cfg.functionDropPercentTrigger}%)`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Rename storm heuristic: many adds + deletes together.
|
|
47
|
+
if ((diff.added.length + diff.deleted.length) >= cfg.renameStormMin && diff.deleted.length > 0 && diff.added.length > 0) {
|
|
48
|
+
reasons.push(`Possible rename storm (added ${diff.added.length}, deleted ${diff.deleted.length})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
risky: reasons.length > 0,
|
|
53
|
+
reasons
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { evaluateRisk };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const fsp = fs.promises;
|
|
4
|
+
const fg = require('fast-glob');
|
|
5
|
+
|
|
6
|
+
const { ensureDir, shouldIgnoreRel, copyFilePreserveDirs, rmIfExists } = require('./fsutil');
|
|
7
|
+
|
|
8
|
+
function guardRoot(projectRoot) {
|
|
9
|
+
return path.join(projectRoot, '.voraiguard');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function snapshotsRoot(projectRoot) {
|
|
13
|
+
return path.join(guardRoot(projectRoot), 'snapshots');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function snapshotPath(projectRoot, snapshotId) {
|
|
17
|
+
return path.join(snapshotsRoot(projectRoot), snapshotId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nowId() {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
23
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function createSnapshot(projectRoot, meta = {}) {
|
|
27
|
+
const id = nowId();
|
|
28
|
+
const root = snapshotPath(projectRoot, id);
|
|
29
|
+
const filesDir = path.join(root, 'files');
|
|
30
|
+
|
|
31
|
+
await ensureDir(filesDir);
|
|
32
|
+
|
|
33
|
+
const entries = await fg(['**/*'], {
|
|
34
|
+
cwd: projectRoot,
|
|
35
|
+
onlyFiles: true,
|
|
36
|
+
dot: true,
|
|
37
|
+
followSymbolicLinks: false
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const rel of entries) {
|
|
41
|
+
const relNorm = rel.split('\\').join('/');
|
|
42
|
+
if (shouldIgnoreRel(relNorm)) continue;
|
|
43
|
+
const srcAbs = path.join(projectRoot, rel);
|
|
44
|
+
const destAbs = path.join(filesDir, relNorm);
|
|
45
|
+
try {
|
|
46
|
+
await copyFilePreserveDirs(srcAbs, destAbs);
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore transient read errors
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const manifest = {
|
|
53
|
+
id,
|
|
54
|
+
createdAt: new Date().toISOString(),
|
|
55
|
+
meta
|
|
56
|
+
};
|
|
57
|
+
await fsp.writeFile(path.join(root, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8');
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function listSnapshots(projectRoot) {
|
|
62
|
+
const root = snapshotsRoot(projectRoot);
|
|
63
|
+
if (!fs.existsSync(root)) return [];
|
|
64
|
+
|
|
65
|
+
const dirs = (await fsp.readdir(root, { withFileTypes: true }))
|
|
66
|
+
.filter((d) => d.isDirectory())
|
|
67
|
+
.map((d) => d.name)
|
|
68
|
+
.sort();
|
|
69
|
+
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const id of dirs) {
|
|
72
|
+
const manifestPath = path.join(root, id, 'manifest.json');
|
|
73
|
+
try {
|
|
74
|
+
const raw = await fsp.readFile(manifestPath, 'utf8');
|
|
75
|
+
const m = JSON.parse(raw);
|
|
76
|
+
out.push({ id, createdAt: m.createdAt || '' });
|
|
77
|
+
} catch {
|
|
78
|
+
out.push({ id, createdAt: '' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function getLatestSnapshotId(projectRoot) {
|
|
85
|
+
const snaps = await listSnapshots(projectRoot);
|
|
86
|
+
if (snaps.length === 0) return null;
|
|
87
|
+
return snaps[snaps.length - 1].id;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function restoreSnapshot(projectRoot, snapshotId) {
|
|
91
|
+
const root = snapshotPath(projectRoot, snapshotId);
|
|
92
|
+
const filesDir = path.join(root, 'files');
|
|
93
|
+
|
|
94
|
+
if (!fs.existsSync(filesDir)) {
|
|
95
|
+
throw new Error(`Snapshot not found: ${snapshotId}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Remove current project files (excluding ignored), then copy from snapshot.
|
|
99
|
+
const current = await fg(['**/*'], {
|
|
100
|
+
cwd: projectRoot,
|
|
101
|
+
onlyFiles: true,
|
|
102
|
+
dot: true,
|
|
103
|
+
followSymbolicLinks: false
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
for (const rel of current) {
|
|
107
|
+
const relNorm = rel.split('\\').join('/');
|
|
108
|
+
if (shouldIgnoreRel(relNorm)) continue;
|
|
109
|
+
await rmIfExists(path.join(projectRoot, rel));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const snapFiles = await fg(['**/*'], {
|
|
113
|
+
cwd: filesDir,
|
|
114
|
+
onlyFiles: true,
|
|
115
|
+
dot: true,
|
|
116
|
+
followSymbolicLinks: false
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
for (const rel of snapFiles) {
|
|
120
|
+
const srcAbs = path.join(filesDir, rel);
|
|
121
|
+
const destAbs = path.join(projectRoot, rel);
|
|
122
|
+
await copyFilePreserveDirs(srcAbs, destAbs);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {
|
|
127
|
+
createSnapshot,
|
|
128
|
+
listSnapshots,
|
|
129
|
+
getLatestSnapshotId,
|
|
130
|
+
restoreSnapshot
|
|
131
|
+
};
|
package/src/lib/state.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fg = require('fast-glob');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const { normalizeRel, shouldIgnoreRel, fileHash, readTextFile } = require('./fsutil');
|
|
6
|
+
const { estimateFunctionCount } = require('./textmetrics');
|
|
7
|
+
|
|
8
|
+
async function scanProjectState(projectRoot) {
|
|
9
|
+
const entries = await fg(['**/*'], {
|
|
10
|
+
cwd: projectRoot,
|
|
11
|
+
onlyFiles: true,
|
|
12
|
+
dot: true,
|
|
13
|
+
followSymbolicLinks: false
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const files = {};
|
|
17
|
+
let totalFunctionCount = 0;
|
|
18
|
+
|
|
19
|
+
for (const rel of entries) {
|
|
20
|
+
const relNorm = rel.split('\\').join('/');
|
|
21
|
+
if (shouldIgnoreRel(relNorm)) continue;
|
|
22
|
+
|
|
23
|
+
const abs = path.join(projectRoot, rel);
|
|
24
|
+
let stat;
|
|
25
|
+
try {
|
|
26
|
+
stat = fs.statSync(abs);
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const size = stat.size;
|
|
32
|
+
const hash = await fileHash(abs);
|
|
33
|
+
|
|
34
|
+
let funcCount = 0;
|
|
35
|
+
const text = await readTextFile(abs);
|
|
36
|
+
if (text != null) funcCount = estimateFunctionCount(relNorm, text);
|
|
37
|
+
|
|
38
|
+
totalFunctionCount += funcCount;
|
|
39
|
+
files[relNorm] = { size, hash, funcCount };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { files, totalFunctionCount };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function diffStates(before, after) {
|
|
46
|
+
const beforeFiles = before.files || {};
|
|
47
|
+
const afterFiles = after.files || {};
|
|
48
|
+
|
|
49
|
+
const beforeSet = new Set(Object.keys(beforeFiles));
|
|
50
|
+
const afterSet = new Set(Object.keys(afterFiles));
|
|
51
|
+
|
|
52
|
+
const deleted = [];
|
|
53
|
+
const added = [];
|
|
54
|
+
const modified = [];
|
|
55
|
+
|
|
56
|
+
for (const f of beforeSet) {
|
|
57
|
+
if (!afterSet.has(f)) {
|
|
58
|
+
deleted.push(f);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (beforeFiles[f].hash !== afterFiles[f].hash) modified.push(f);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const f of afterSet) {
|
|
65
|
+
if (!beforeSet.has(f)) added.push(f);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const changed = [...new Set([...added, ...modified, ...deleted])];
|
|
69
|
+
|
|
70
|
+
let sizeBeforeChanged = 0;
|
|
71
|
+
let sizeAfterChanged = 0;
|
|
72
|
+
let funcBeforeChanged = 0;
|
|
73
|
+
let funcAfterChanged = 0;
|
|
74
|
+
|
|
75
|
+
for (const f of changed) {
|
|
76
|
+
if (beforeFiles[f]) {
|
|
77
|
+
sizeBeforeChanged += beforeFiles[f].size || 0;
|
|
78
|
+
funcBeforeChanged += beforeFiles[f].funcCount || 0;
|
|
79
|
+
}
|
|
80
|
+
if (afterFiles[f]) {
|
|
81
|
+
sizeAfterChanged += afterFiles[f].size || 0;
|
|
82
|
+
funcAfterChanged += afterFiles[f].funcCount || 0;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
added,
|
|
88
|
+
modified,
|
|
89
|
+
deleted,
|
|
90
|
+
changedCount: changed.length,
|
|
91
|
+
sizeBeforeChanged,
|
|
92
|
+
sizeAfterChanged,
|
|
93
|
+
funcBeforeChanged,
|
|
94
|
+
funcAfterChanged,
|
|
95
|
+
totalFuncBefore: before.totalFunctionCount || 0,
|
|
96
|
+
totalFuncAfter: after.totalFunctionCount || 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { scanProjectState, diffStates };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const SUPPORTED_EXTS = new Set([
|
|
4
|
+
'.js', '.jsx', '.ts', '.tsx',
|
|
5
|
+
'.py', '.go', '.java', '.cs',
|
|
6
|
+
'.rb', '.php', '.rs', '.kt'
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
function estimateFunctionCount(relPath, text) {
|
|
10
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
11
|
+
if (!SUPPORTED_EXTS.has(ext)) return 0;
|
|
12
|
+
|
|
13
|
+
// Intentionally simple heuristics (MVP).
|
|
14
|
+
let count = 0;
|
|
15
|
+
|
|
16
|
+
// JS/TS: function foo(, foo = ( ... ) =>, ( ... ) => { ... }
|
|
17
|
+
if (ext === '.js' || ext === '.jsx' || ext === '.ts' || ext === '.tsx') {
|
|
18
|
+
const fnDecl = /\bfunction\s+[A-Za-z0-9_$]+\s*\(/g;
|
|
19
|
+
const arrow = /=>\s*\{/g;
|
|
20
|
+
const methodLike = /\b[A-Za-z0-9_$]+\s*\([^\n]*\)\s*\{/g;
|
|
21
|
+
count += (text.match(fnDecl) || []).length;
|
|
22
|
+
count += (text.match(arrow) || []).length;
|
|
23
|
+
// methodLike is noisy; keep it light to avoid overcounting.
|
|
24
|
+
count += Math.floor((text.match(methodLike) || []).length * 0.25);
|
|
25
|
+
return count;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Python
|
|
29
|
+
if (ext === '.py') {
|
|
30
|
+
const defs = /^\s*def\s+[A-Za-z0-9_]+\s*\(/gm;
|
|
31
|
+
return (text.match(defs) || []).length;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Go
|
|
35
|
+
if (ext === '.go') {
|
|
36
|
+
const funcs = /^\s*func\s+(\([^)]+\)\s*)?[A-Za-z0-9_]+\s*\(/gm;
|
|
37
|
+
return (text.match(funcs) || []).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Java/C#/Kotlin
|
|
41
|
+
if (ext === '.java' || ext === '.cs' || ext === '.kt') {
|
|
42
|
+
const methods = /\b(public|private|protected|internal)?\s*(static\s+)?[A-Za-z0-9_<>\[\]]+\s+[A-Za-z0-9_]+\s*\([^;{]*\)\s*\{/g;
|
|
43
|
+
return Math.floor((text.match(methods) || []).length * 0.8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Rust
|
|
47
|
+
if (ext === '.rs') {
|
|
48
|
+
const fns = /^\s*fn\s+[A-Za-z0-9_]+\s*\(/gm;
|
|
49
|
+
return (text.match(fns) || []).length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Ruby
|
|
53
|
+
if (ext === '.rb') {
|
|
54
|
+
const defs = /^\s*def\s+[A-Za-z0-9_!?]+/gm;
|
|
55
|
+
return (text.match(defs) || []).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// PHP
|
|
59
|
+
if (ext === '.php') {
|
|
60
|
+
const defs = /\bfunction\s+[A-Za-z0-9_]+\s*\(/g;
|
|
61
|
+
return (text.match(defs) || []).length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { estimateFunctionCount };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { version: '0.1.0' };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
const chokidar = require('chokidar');
|
|
2
|
+
const inquirer = require('inquirer');
|
|
3
|
+
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
const { scanProjectState, diffStates } = require('./state');
|
|
6
|
+
const { createSnapshot, restoreSnapshot, getLatestSnapshotId } = require('./snapshot');
|
|
7
|
+
const { evaluateRisk } = require('./risk');
|
|
8
|
+
|
|
9
|
+
function summarize(diff) {
|
|
10
|
+
const lines = [];
|
|
11
|
+
lines.push(`Changed: ${diff.changedCount}`);
|
|
12
|
+
if (diff.added.length) lines.push(`Added: ${diff.added.length}`);
|
|
13
|
+
if (diff.modified.length) lines.push(`Modified: ${diff.modified.length}`);
|
|
14
|
+
if (diff.deleted.length) lines.push(`Deleted: ${diff.deleted.length}`);
|
|
15
|
+
return lines.join(' | ');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pctDrop(before, after) {
|
|
19
|
+
if (!before || before <= 0) return 0;
|
|
20
|
+
return ((before - after) / before) * 100;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatStats(diff) {
|
|
24
|
+
const stats = [];
|
|
25
|
+
if (diff.sizeBeforeChanged > 0) {
|
|
26
|
+
const shrink = pctDrop(diff.sizeBeforeChanged, diff.sizeAfterChanged);
|
|
27
|
+
stats.push(`Size shrink: ${shrink.toFixed(1)}%`);
|
|
28
|
+
}
|
|
29
|
+
if (diff.funcBeforeChanged > 0) {
|
|
30
|
+
const fdrop = pctDrop(diff.funcBeforeChanged, diff.funcAfterChanged);
|
|
31
|
+
stats.push(`Function drop: ${fdrop.toFixed(1)}%`);
|
|
32
|
+
}
|
|
33
|
+
return stats;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function promptApproveOrRevert(reasons, diff) {
|
|
37
|
+
console.log('\n🚨 Risky change detected');
|
|
38
|
+
console.log(summarize(diff));
|
|
39
|
+
for (const s of formatStats(diff)) console.log(s);
|
|
40
|
+
for (const r of reasons) console.log(`- ${r}`);
|
|
41
|
+
|
|
42
|
+
const ans = await inquirer.prompt([
|
|
43
|
+
{
|
|
44
|
+
type: 'list',
|
|
45
|
+
name: 'action',
|
|
46
|
+
message: 'Choose an action:',
|
|
47
|
+
choices: [
|
|
48
|
+
{ name: 'Approve', value: 'approve' },
|
|
49
|
+
{ name: 'Rollback', value: 'revert' }
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
return ans.action;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function watchProject(projectRoot) {
|
|
58
|
+
const cfg = await loadConfig(projectRoot);
|
|
59
|
+
|
|
60
|
+
console.log(`Vorai Guard watching: ${projectRoot}`);
|
|
61
|
+
console.log('Creating initial snapshot (baseline)...');
|
|
62
|
+
|
|
63
|
+
// Baseline snapshot represents the last approved state.
|
|
64
|
+
await createSnapshot(projectRoot, { reason: 'baseline' });
|
|
65
|
+
let approvedState = await scanProjectState(projectRoot);
|
|
66
|
+
|
|
67
|
+
let timer = null;
|
|
68
|
+
let pending = false;
|
|
69
|
+
let burstEvents = [];
|
|
70
|
+
const burstFileCounts = new Map();
|
|
71
|
+
|
|
72
|
+
function pruneBurst(now) {
|
|
73
|
+
const windowMs = typeof cfg.burstWindowMs === 'number' ? cfg.burstWindowMs : 3000;
|
|
74
|
+
const cutoff = now - windowMs;
|
|
75
|
+
while (burstEvents.length && burstEvents[0].t < cutoff) {
|
|
76
|
+
const ev = burstEvents.shift();
|
|
77
|
+
const c = (burstFileCounts.get(ev.file) || 0) - 1;
|
|
78
|
+
if (c <= 0) burstFileCounts.delete(ev.file);
|
|
79
|
+
else burstFileCounts.set(ev.file, c);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function recordEvent(filePath) {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
burstEvents.push({ t: now, file: String(filePath || '') });
|
|
86
|
+
burstFileCounts.set(String(filePath || ''), (burstFileCounts.get(String(filePath || '')) || 0) + 1);
|
|
87
|
+
pruneBurst(now);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function onBatch() {
|
|
91
|
+
if (pending) return;
|
|
92
|
+
pending = true;
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const current = await scanProjectState(projectRoot);
|
|
96
|
+
const diff = diffStates(approvedState, current);
|
|
97
|
+
|
|
98
|
+
// No actual content change (can happen with temp files/metadata).
|
|
99
|
+
if (diff.changedCount === 0) return;
|
|
100
|
+
|
|
101
|
+
pruneBurst(Date.now());
|
|
102
|
+
const burstEventCount = burstEvents.length;
|
|
103
|
+
const burstFileCount = burstFileCounts.size;
|
|
104
|
+
|
|
105
|
+
// reset burst buffers after batch evaluation
|
|
106
|
+
burstEvents = [];
|
|
107
|
+
burstFileCounts.clear();
|
|
108
|
+
|
|
109
|
+
const risk = evaluateRisk(diff, cfg, { burstEvents: burstEventCount, burstFiles: burstFileCount });
|
|
110
|
+
if (!risk.risky) {
|
|
111
|
+
// Auto-approve low-risk batches: update baseline snapshot/state.
|
|
112
|
+
await createSnapshot(projectRoot, { reason: 'auto-approve', summary: summarize(diff) });
|
|
113
|
+
approvedState = current;
|
|
114
|
+
console.log(`Auto-approved: ${summarize(diff)}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Risky: prompt user. Revert uses the latest snapshot (last approved).
|
|
119
|
+
const action = await promptApproveOrRevert(risk.reasons, diff);
|
|
120
|
+
if (action === 'revert') {
|
|
121
|
+
const latestId = await getLatestSnapshotId(projectRoot);
|
|
122
|
+
if (!latestId) throw new Error('No snapshot available to revert.');
|
|
123
|
+
console.log(`Reverting to snapshot: ${latestId}`);
|
|
124
|
+
await restoreSnapshot(projectRoot, latestId);
|
|
125
|
+
approvedState = await scanProjectState(projectRoot);
|
|
126
|
+
console.log('Reverted.');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Approved: take a new snapshot that becomes the new baseline.
|
|
131
|
+
await createSnapshot(projectRoot, { reason: 'approved', summary: summarize(diff), risk: risk.reasons });
|
|
132
|
+
approvedState = current;
|
|
133
|
+
console.log('Approved.');
|
|
134
|
+
} finally {
|
|
135
|
+
pending = false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const watcher = chokidar.watch(projectRoot, {
|
|
140
|
+
persistent: true,
|
|
141
|
+
ignoreInitial: true,
|
|
142
|
+
awaitWriteFinish: {
|
|
143
|
+
stabilityThreshold: 500,
|
|
144
|
+
pollInterval: 100
|
|
145
|
+
},
|
|
146
|
+
ignored: (p) => {
|
|
147
|
+
// chokidar passes absolute paths
|
|
148
|
+
return p.includes('\\node_modules\\') || p.includes('/node_modules/') || p.includes('\\.git\\') || p.includes('/.git/') || p.includes('\\.voraiguard\\') || p.includes('/.voraiguard/');
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const bump = (p) => {
|
|
153
|
+
recordEvent(p);
|
|
154
|
+
if (timer) clearTimeout(timer);
|
|
155
|
+
timer = setTimeout(onBatch, cfg.debounceMs);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
watcher
|
|
159
|
+
.on('add', (p) => bump(p))
|
|
160
|
+
.on('change', (p) => bump(p))
|
|
161
|
+
.on('unlink', (p) => bump(p))
|
|
162
|
+
.on('error', (err) => console.error('Watcher error:', err));
|
|
163
|
+
|
|
164
|
+
console.log('Watching. Press Ctrl+C to stop.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = { watchProject };
|