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 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,6 @@
1
+ Internal modules:
2
+
3
+ - watcher.js: chokidar batching + approve/revert prompt
4
+ - snapshot.js: local snapshot/restore under .voraiguard
5
+ - state.js: file hash/size/function count scan + diffs
6
+ - risk.js: thresholds and heuristics
@@ -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
+ };
@@ -0,0 +1 @@
1
+ // reserved for future shared exports
@@ -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
+ };
@@ -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 };