cursor-guard 4.9.1 → 4.9.6
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 +94 -28
- package/README.zh-CN.md +91 -25
- package/ROADMAP.md +45 -9
- package/SKILL.md +32 -22
- package/package.json +1 -1
- package/references/config-reference.md +68 -7
- package/references/config-reference.zh-CN.md +68 -7
- package/references/cursor-guard.example.json +11 -7
- package/references/cursor-guard.schema.json +30 -7
- package/references/dashboard/public/app.js +73 -27
- package/references/dashboard/public/index.html +8 -7
- package/references/lib/auto-backup.js +40 -2
- package/references/lib/core/backups.js +46 -16
- package/references/lib/core/core.test.js +101 -22
- package/references/lib/core/dashboard.js +37 -23
- package/references/lib/core/doctor.js +19 -13
- package/references/lib/core/pre-warning.js +296 -0
- package/references/lib/core/snapshot.js +24 -2
- package/references/lib/core/status.js +15 -7
- package/references/lib/utils.js +46 -20
- package/references/mcp/mcp.test.js +60 -12
- package/references/mcp/server.js +72 -60
- package/references/quickstart.zh-CN.md +46 -21
- package/references/vscode-extension/build-vsix.js +4 -3
- package/references/vscode-extension/dist/LICENSE +65 -0
- package/references/vscode-extension/dist/{cursor-guard-ide-4.9.1.vsix → cursor-guard-ide-4.9.6.vsix} +0 -0
- package/references/vscode-extension/dist/dashboard/public/app.js +73 -27
- package/references/vscode-extension/dist/dashboard/public/index.html +8 -7
- package/references/vscode-extension/dist/extension.js +498 -296
- package/references/vscode-extension/dist/guard-version.json +1 -1
- package/references/vscode-extension/dist/lib/auto-backup.js +40 -2
- package/references/vscode-extension/dist/lib/core/backups.js +46 -16
- package/references/vscode-extension/dist/lib/core/dashboard.js +37 -23
- package/references/vscode-extension/dist/lib/core/doctor.js +19 -13
- package/references/vscode-extension/dist/lib/core/pre-warning.js +296 -0
- package/references/vscode-extension/dist/lib/core/snapshot.js +24 -2
- package/references/vscode-extension/dist/lib/core/status.js +15 -7
- package/references/vscode-extension/dist/lib/sidebar-webview.js +502 -447
- package/references/vscode-extension/dist/lib/status-bar.js +95 -68
- package/references/vscode-extension/dist/lib/tree-view.js +174 -114
- package/references/vscode-extension/dist/lib/utils.js +46 -20
- package/references/vscode-extension/dist/mcp/server.js +393 -30
- package/references/vscode-extension/dist/package.json +1 -1
- package/references/vscode-extension/dist/skill/ROADMAP.md +45 -9
- package/references/vscode-extension/dist/skill/SKILL.md +32 -22
- package/references/vscode-extension/dist/skill/config-reference.md +68 -7
- package/references/vscode-extension/dist/skill/config-reference.zh-CN.md +68 -7
- package/references/vscode-extension/dist/skill/cursor-guard.example.json +11 -7
- package/references/vscode-extension/dist/skill/cursor-guard.schema.json +30 -7
- package/references/vscode-extension/extension.js +498 -296
- package/references/vscode-extension/lib/sidebar-webview.js +502 -447
- package/references/vscode-extension/lib/status-bar.js +95 -68
- package/references/vscode-extension/lib/tree-view.js +174 -114
- package/references/vscode-extension/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"4.9.
|
|
1
|
+
{"version":"4.9.6"}
|
|
@@ -22,6 +22,45 @@ function isProcessAlive(pid) {
|
|
|
22
22
|
catch { return false; }
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Ignore fs.watch events that originate inside the Git directory (including our snapshots).
|
|
27
|
+
* This is the **full** fix for the feedback loop: snapshot writes under `.git/` must never
|
|
28
|
+
* schedule another backup. No time-based cooldown — rely entirely on path semantics.
|
|
29
|
+
*
|
|
30
|
+
* On Windows, `filename` is often relative to the repo root but WITHOUT a `.git/` prefix
|
|
31
|
+
* (e.g. `HEAD`, `objects/ab/...`, `refs/guard/auto-backup`).
|
|
32
|
+
*/
|
|
33
|
+
const GIT_ONLY_TOP_NAMES = new Set([
|
|
34
|
+
'HEAD', 'FETCH_HEAD', 'ORIG_HEAD', 'MERGE_HEAD', 'COMMIT_EDITMSG', 'index', 'packed-refs',
|
|
35
|
+
'cursor-guard-index', 'cursor-guard-index.lock', 'cursor-guard.lock',
|
|
36
|
+
'refs', 'objects', 'logs', 'hooks', 'info', 'worktrees', 'modules', 'description',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function shouldIgnoreFsWatchEvent(projectDir, filename, realGitDir) {
|
|
40
|
+
if (!filename) return true;
|
|
41
|
+
const norm = String(filename).replace(/\\/g, '/');
|
|
42
|
+
if (norm.startsWith('.git/') || norm === '.git') return true;
|
|
43
|
+
if (norm.startsWith('.cursor-guard-backup')) return true;
|
|
44
|
+
|
|
45
|
+
const gitRoot = realGitDir || path.join(projectDir, '.git');
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
if (norm.includes('/') || GIT_ONLY_TOP_NAMES.has(norm)) {
|
|
49
|
+
const candidate = path.join(gitRoot, norm);
|
|
50
|
+
if (fs.existsSync(candidate)) return true;
|
|
51
|
+
}
|
|
52
|
+
} catch { /* ignore */ }
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const abs = path.resolve(projectDir, filename);
|
|
56
|
+
const gitDirAbs = path.resolve(gitRoot);
|
|
57
|
+
const rel = path.relative(gitDirAbs, abs);
|
|
58
|
+
if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) return true;
|
|
59
|
+
} catch { /* ignore */ }
|
|
60
|
+
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
25
64
|
// ── Main ────────────────────────────────────────────────────────
|
|
26
65
|
|
|
27
66
|
async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
@@ -323,9 +362,8 @@ async function runBackup(projectDir, intervalOverride, opts = {}) {
|
|
|
323
362
|
|
|
324
363
|
watcher.on('change', (_eventType, filename) => {
|
|
325
364
|
if (!filename) return;
|
|
365
|
+
if (shouldIgnoreFsWatchEvent(projectDir, filename, gDir)) return;
|
|
326
366
|
const f = filename.replace(/\\/g, '/');
|
|
327
|
-
if (f.startsWith('.git/') || f.startsWith('.git\\')) return;
|
|
328
|
-
if (f.startsWith('.cursor-guard-backup')) return;
|
|
329
367
|
if (f === '.cursor-guard.json') {
|
|
330
368
|
hotReloadConfig();
|
|
331
369
|
return;
|
|
@@ -400,9 +400,38 @@ function cleanGitRetention(branchRef, gitDirPath, cfg, cwd) {
|
|
|
400
400
|
|
|
401
401
|
// ── Get backup file details ─────────────────────────────────────
|
|
402
402
|
|
|
403
|
+
/** Git's canonical empty tree (used as diff base for root/orphan commits). */
|
|
404
|
+
const GIT_EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
405
|
+
|
|
406
|
+
/** Normalize paths so numstat / name-status keys match (Windows vs /, quotes). */
|
|
407
|
+
function _normalizeBackupPath(p) {
|
|
408
|
+
if (!p) return p;
|
|
409
|
+
let s = String(p).replace(/\\/g, '/');
|
|
410
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
411
|
+
s = s.slice(1, -1).replace(/\\"/g, '"');
|
|
412
|
+
}
|
|
413
|
+
return s;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Parse one line of `git diff --numstat` (same semantics as CLI; binary => `- -`). */
|
|
417
|
+
function _parseGitDiffNumstatLine(add, del) {
|
|
418
|
+
if (add === '-' || del === '-') {
|
|
419
|
+
return { added: 0, deleted: 0, binary: true };
|
|
420
|
+
}
|
|
421
|
+
const a = parseInt(add, 10);
|
|
422
|
+
const d = parseInt(del, 10);
|
|
423
|
+
return {
|
|
424
|
+
added: Number.isNaN(a) ? 0 : a,
|
|
425
|
+
deleted: Number.isNaN(d) ? 0 : d,
|
|
426
|
+
binary: false,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
403
430
|
/**
|
|
404
431
|
* Get structured file-level changes for a specific git backup commit.
|
|
405
|
-
*
|
|
432
|
+
* Uses **only** `git diff --numstat` + `git diff --name-status` (same as terminal),
|
|
433
|
+
* so +/- counts match `git diff parent..commit` 100%. Root/orphan commits diff
|
|
434
|
+
* against the standard empty tree.
|
|
406
435
|
*
|
|
407
436
|
* @param {string} projectDir
|
|
408
437
|
* @param {string} commitHash - Full or short commit hash
|
|
@@ -418,25 +447,18 @@ function getBackupFiles(projectDir, commitHash) {
|
|
|
418
447
|
return { files: [], error: `cannot resolve commit: ${commitHash}` };
|
|
419
448
|
}
|
|
420
449
|
|
|
421
|
-
const
|
|
450
|
+
const parentCommit = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
|
|
451
|
+
const parent = parentCommit || GIT_EMPTY_TREE_SHA;
|
|
422
452
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!lsOut) return { files: [] };
|
|
426
|
-
return {
|
|
427
|
-
files: lsOut.split('\n').filter(Boolean).map(f => ({ path: f, action: 'added', added: 0, deleted: 0 })),
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const nameStatusOut = git(['diff-tree', '--no-commit-id', '--name-status', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
432
|
-
const numstatOut = git(['diff-tree', '--no-commit-id', '--numstat', '-r', `${resolved}^`, resolved], { cwd: projectDir, allowFail: true });
|
|
453
|
+
const numstatOut = git(['diff', '--numstat', parent, resolved], { cwd: projectDir, allowFail: true });
|
|
454
|
+
const nameStatusOut = git(['diff', '--name-status', parent, resolved], { cwd: projectDir, allowFail: true });
|
|
433
455
|
|
|
434
456
|
const stats = {};
|
|
435
457
|
if (numstatOut) {
|
|
436
458
|
for (const line of numstatOut.split('\n').filter(Boolean)) {
|
|
437
459
|
const [add, del, ...nameParts] = line.split('\t');
|
|
438
|
-
const fname = nameParts.join('\t');
|
|
439
|
-
stats[fname] =
|
|
460
|
+
const fname = _normalizeBackupPath(nameParts.join('\t'));
|
|
461
|
+
stats[fname] = _parseGitDiffNumstatLine(add, del);
|
|
440
462
|
}
|
|
441
463
|
}
|
|
442
464
|
|
|
@@ -448,9 +470,17 @@ function getBackupFiles(projectDir, commitHash) {
|
|
|
448
470
|
if (tab < 0) continue;
|
|
449
471
|
const code = line.substring(0, tab).trim();
|
|
450
472
|
const filePart = line.substring(tab + 1);
|
|
451
|
-
|
|
473
|
+
let action = ACTION_MAP[code];
|
|
474
|
+
if (code.startsWith('R')) action = 'renamed';
|
|
475
|
+
else if (code.startsWith('C')) action = 'copied';
|
|
476
|
+
else if (!action) action = 'modified';
|
|
477
|
+
|
|
452
478
|
const fileName = filePart.split('\t').pop();
|
|
453
|
-
const
|
|
479
|
+
const norm = _normalizeBackupPath(fileName);
|
|
480
|
+
let s = stats[norm];
|
|
481
|
+
if (!s && fileName !== norm) s = stats[fileName];
|
|
482
|
+
if (!s) s = { added: 0, deleted: 0, binary: false };
|
|
483
|
+
|
|
454
484
|
files.push({ path: fileName, action, added: s.added, deleted: s.deleted });
|
|
455
485
|
}
|
|
456
486
|
}
|
|
@@ -6,9 +6,10 @@ const {
|
|
|
6
6
|
loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir,
|
|
7
7
|
diskFreeGB, walkDir, filterFiles,
|
|
8
8
|
} = require('../utils');
|
|
9
|
-
const { getBackupStatus } = require('./status');
|
|
10
|
-
const { loadActiveAlert } = require('./anomaly');
|
|
11
|
-
const {
|
|
9
|
+
const { getBackupStatus } = require('./status');
|
|
10
|
+
const { loadActiveAlert } = require('./anomaly');
|
|
11
|
+
const { loadActivePreWarnings } = require('./pre-warning');
|
|
12
|
+
const { parseShadowTimestamp } = require('./backups');
|
|
12
13
|
|
|
13
14
|
// ── Helpers ─────────────────────────────────────────────────────
|
|
14
15
|
|
|
@@ -184,26 +185,39 @@ function getDashboard(projectDir) {
|
|
|
184
185
|
|
|
185
186
|
// ── Active alerts ───────────────────────────────────────────
|
|
186
187
|
const activeAlert = loadActiveAlert(projectDir);
|
|
187
|
-
const alerts = {
|
|
188
|
-
active: !!activeAlert,
|
|
189
|
-
latest: activeAlert || undefined,
|
|
190
|
-
};
|
|
191
|
-
if (activeAlert) {
|
|
192
|
-
if (healthStatus === 'healthy') healthStatus = 'warning';
|
|
193
|
-
issues.push(`Active alert: ${activeAlert.type} — ${activeAlert.fileCount} files in ${activeAlert.windowSeconds}s`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
188
|
+
const alerts = {
|
|
189
|
+
active: !!activeAlert,
|
|
190
|
+
latest: activeAlert || undefined,
|
|
191
|
+
};
|
|
192
|
+
if (activeAlert) {
|
|
193
|
+
if (healthStatus === 'healthy') healthStatus = 'warning';
|
|
194
|
+
issues.push(`Active alert: ${activeAlert.type} — ${activeAlert.fileCount} files in ${activeAlert.windowSeconds}s`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const activePreWarnings = loadActivePreWarnings(projectDir);
|
|
198
|
+
const preWarnings = {
|
|
199
|
+
active: activePreWarnings.length > 0,
|
|
200
|
+
count: activePreWarnings.length,
|
|
201
|
+
latest: activePreWarnings[0] || undefined,
|
|
202
|
+
warnings: activePreWarnings,
|
|
203
|
+
};
|
|
204
|
+
if (preWarnings.active) {
|
|
205
|
+
if (healthStatus === 'healthy') healthStatus = 'warning';
|
|
206
|
+
issues.push(`Pre-warning active: ${preWarnings.latest?.summary || `${preWarnings.count} pending destructive edit warning(s)`}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
strategy,
|
|
211
|
+
lastBackup,
|
|
212
|
+
counts,
|
|
200
213
|
diskUsage,
|
|
201
|
-
protectionScope,
|
|
202
|
-
health: { status: healthStatus, issues },
|
|
203
|
-
alerts,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
214
|
+
protectionScope,
|
|
215
|
+
health: { status: healthStatus, issues },
|
|
216
|
+
alerts,
|
|
217
|
+
preWarnings,
|
|
218
|
+
watcher: status.watcher,
|
|
219
|
+
disk: status.disk,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
208
222
|
|
|
209
223
|
module.exports = { getDashboard, dirSizeBytes, formatBytes, relativeTime };
|
|
@@ -170,19 +170,25 @@ function runDiagnostics(projectDir) {
|
|
|
170
170
|
check('Config: backup_strategy', 'FAIL', `invalid value '${cfg.backup_strategy}'`);
|
|
171
171
|
}
|
|
172
172
|
const validPreRestore = ['always', 'ask', 'never'];
|
|
173
|
-
if (cfg.pre_restore_backup && !validPreRestore.includes(cfg.pre_restore_backup)) {
|
|
174
|
-
check('Config: pre_restore_backup', 'FAIL', `invalid value '${cfg.pre_restore_backup}'`);
|
|
175
|
-
} else if (cfg.pre_restore_backup === 'never') {
|
|
176
|
-
check('Config: pre_restore_backup', 'WARN', "set to 'never' — restores won't auto-preserve current version");
|
|
177
|
-
}
|
|
178
|
-
if (cfg.auto_backup_interval_seconds && cfg.auto_backup_interval_seconds < 5) {
|
|
179
|
-
check('Config: interval', 'WARN', `${cfg.auto_backup_interval_seconds}s is below minimum (5s), will be clamped`);
|
|
180
|
-
}
|
|
181
|
-
if (cfg.
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
173
|
+
if (cfg.pre_restore_backup && !validPreRestore.includes(cfg.pre_restore_backup)) {
|
|
174
|
+
check('Config: pre_restore_backup', 'FAIL', `invalid value '${cfg.pre_restore_backup}'`);
|
|
175
|
+
} else if (cfg.pre_restore_backup === 'never') {
|
|
176
|
+
check('Config: pre_restore_backup', 'WARN', "set to 'never' — restores won't auto-preserve current version");
|
|
177
|
+
}
|
|
178
|
+
if (cfg.auto_backup_interval_seconds && cfg.auto_backup_interval_seconds < 5) {
|
|
179
|
+
check('Config: interval', 'WARN', `${cfg.auto_backup_interval_seconds}s is below minimum (5s), will be clamped`);
|
|
180
|
+
}
|
|
181
|
+
if (cfg.pre_warning_threshold < 1 || cfg.pre_warning_threshold > 100) {
|
|
182
|
+
check('Config: pre_warning_threshold', 'FAIL', `invalid value '${cfg.pre_warning_threshold}'`);
|
|
183
|
+
}
|
|
184
|
+
if (!['popup', 'dashboard', 'silent'].includes(cfg.pre_warning_mode)) {
|
|
185
|
+
check('Config: pre_warning_mode', 'FAIL', `invalid value '${cfg.pre_warning_mode}'`);
|
|
186
|
+
}
|
|
187
|
+
if (cfg.retention && cfg.retention.mode) {
|
|
188
|
+
const validModes = ['days', 'count', 'size'];
|
|
189
|
+
if (!validModes.includes(cfg.retention.mode)) {
|
|
190
|
+
check('Config: retention.mode', 'FAIL', `invalid value '${cfg.retention.mode}'`);
|
|
191
|
+
}
|
|
186
192
|
}
|
|
187
193
|
if (cfg.git_retention && cfg.git_retention.mode) {
|
|
188
194
|
const validGitModes = ['days', 'count'];
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { isGitRepo, gitDir: getGitDir, matchesAny } = require('../utils');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_EXPIRY_MS = 10 * 60 * 1000;
|
|
8
|
+
const MAX_ACTIVE_WARNINGS = 20;
|
|
9
|
+
const MAX_HISTORY = 100;
|
|
10
|
+
|
|
11
|
+
function warningFilePath(projectDir) {
|
|
12
|
+
if (isGitRepo(projectDir)) {
|
|
13
|
+
const gDir = getGitDir(projectDir);
|
|
14
|
+
if (gDir) return path.join(gDir, 'cursor-guard-pre-warning.json');
|
|
15
|
+
}
|
|
16
|
+
return path.join(projectDir, '.cursor-guard-backup', 'cursor-guard-pre-warning.json');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function historyFilePath(projectDir) {
|
|
20
|
+
if (isGitRepo(projectDir)) {
|
|
21
|
+
const gDir = getGitDir(projectDir);
|
|
22
|
+
if (gDir) return path.join(gDir, 'cursor-guard-pre-warning-history.json');
|
|
23
|
+
}
|
|
24
|
+
return path.join(projectDir, '.cursor-guard-backup', 'cursor-guard-pre-warning-history.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function _readActiveStore(projectDir) {
|
|
28
|
+
const filePath = warningFilePath(projectDir);
|
|
29
|
+
try {
|
|
30
|
+
if (!fs.existsSync(filePath)) return [];
|
|
31
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
32
|
+
const warnings = Array.isArray(parsed?.warnings) ? parsed.warnings : [];
|
|
33
|
+
return warnings.filter(w => w && typeof w.file === 'string');
|
|
34
|
+
} catch { return []; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _writeActiveStore(projectDir, warnings) {
|
|
38
|
+
const filePath = warningFilePath(projectDir);
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
41
|
+
fs.writeFileSync(filePath, JSON.stringify({
|
|
42
|
+
updatedAt: new Date().toISOString(),
|
|
43
|
+
warnings,
|
|
44
|
+
}, null, 2));
|
|
45
|
+
} catch { /* best-effort */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _readHistory(projectDir) {
|
|
49
|
+
const filePath = historyFilePath(projectDir);
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(filePath)) return [];
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
53
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
54
|
+
} catch { return []; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _writeHistory(projectDir, warnings) {
|
|
58
|
+
const filePath = historyFilePath(projectDir);
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
61
|
+
fs.writeFileSync(filePath, JSON.stringify(warnings.slice(0, MAX_HISTORY), null, 2));
|
|
62
|
+
} catch { /* best-effort */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _normalizeWarning(warning, opts = {}) {
|
|
66
|
+
const now = warning.detectedAt || Date.now();
|
|
67
|
+
return {
|
|
68
|
+
type: 'destructive_edit_risk',
|
|
69
|
+
detectedAt: now,
|
|
70
|
+
timestamp: warning.timestamp || new Date(now).toISOString(),
|
|
71
|
+
expiresAt: warning.expiresAt || new Date(now + (opts.expiryMs || DEFAULT_EXPIRY_MS)).toISOString(),
|
|
72
|
+
...warning,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPreWarningEnabled(cfg) {
|
|
77
|
+
return cfg?.enable_pre_warning === true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function shouldExcludePreWarning(file, cfg) {
|
|
81
|
+
if (!file || !Array.isArray(cfg?.pre_warning_exclude_patterns)) return false;
|
|
82
|
+
return matchesAny(cfg.pre_warning_exclude_patterns, file);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordPreWarning(projectDir, warning, opts = {}) {
|
|
86
|
+
const normalized = _normalizeWarning(warning, opts);
|
|
87
|
+
|
|
88
|
+
const history = _readHistory(projectDir);
|
|
89
|
+
_writeHistory(projectDir, [normalized, ...history]);
|
|
90
|
+
|
|
91
|
+
if (opts.setActive === false) return normalized;
|
|
92
|
+
|
|
93
|
+
const active = loadActivePreWarnings(projectDir)
|
|
94
|
+
.filter(w => w.file !== normalized.file);
|
|
95
|
+
active.unshift(normalized);
|
|
96
|
+
_writeActiveStore(projectDir, active.slice(0, MAX_ACTIVE_WARNINGS));
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadActivePreWarnings(projectDir) {
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
return _readActiveStore(projectDir)
|
|
103
|
+
.filter(w => !w.expiresAt || now < new Date(w.expiresAt).getTime())
|
|
104
|
+
.sort((a, b) => (b.detectedAt || 0) - (a.detectedAt || 0));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function loadActivePreWarning(projectDir) {
|
|
108
|
+
return loadActivePreWarnings(projectDir)[0] || null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listPreWarningHistory(projectDir, limit = 20) {
|
|
112
|
+
return _readHistory(projectDir)
|
|
113
|
+
.sort((a, b) => (b.detectedAt || 0) - (a.detectedAt || 0))
|
|
114
|
+
.slice(0, limit);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function clearPreWarning(projectDir, file) {
|
|
118
|
+
if (!file) {
|
|
119
|
+
_writeActiveStore(projectDir, []);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const active = loadActivePreWarnings(projectDir).filter(w => w.file !== file);
|
|
123
|
+
if (active.length === 0) {
|
|
124
|
+
const filePath = warningFilePath(projectDir);
|
|
125
|
+
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
_writeActiveStore(projectDir, active);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function clearExpiredPreWarnings(projectDir) {
|
|
132
|
+
const active = loadActivePreWarnings(projectDir);
|
|
133
|
+
if (active.length === 0) {
|
|
134
|
+
const filePath = warningFilePath(projectDir);
|
|
135
|
+
try {
|
|
136
|
+
if (fs.existsSync(filePath)) {
|
|
137
|
+
fs.unlinkSync(filePath);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
} catch { /* ignore */ }
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
_writeActiveStore(projectDir, active);
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _splitLines(text) {
|
|
148
|
+
return String(text || '').replace(/\r\n/g, '\n').split('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _normalizeLine(line) {
|
|
152
|
+
return String(line || '').trim().replace(/\s+/g, ' ');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _countLines(lines) {
|
|
156
|
+
const counts = new Map();
|
|
157
|
+
for (const line of lines) {
|
|
158
|
+
const normalized = _normalizeLine(line);
|
|
159
|
+
if (!normalized) continue;
|
|
160
|
+
counts.set(normalized, (counts.get(normalized) || 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
return counts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _extractRemovedLines(prevLines, nextLines) {
|
|
166
|
+
const nextCounts = _countLines(nextLines);
|
|
167
|
+
const removed = [];
|
|
168
|
+
for (let i = 0; i < prevLines.length; i++) {
|
|
169
|
+
const raw = prevLines[i];
|
|
170
|
+
const normalized = _normalizeLine(raw);
|
|
171
|
+
if (!normalized) continue;
|
|
172
|
+
const remaining = nextCounts.get(normalized) || 0;
|
|
173
|
+
if (remaining > 0) {
|
|
174
|
+
nextCounts.set(normalized, remaining - 1);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
removed.push({
|
|
178
|
+
line: raw,
|
|
179
|
+
normalized,
|
|
180
|
+
lineNumber: i + 1,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return removed;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _extractDefinitions(lines) {
|
|
187
|
+
const defs = [];
|
|
188
|
+
const patterns = [
|
|
189
|
+
/^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
|
|
190
|
+
/^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^=]*\)\s*=>/,
|
|
191
|
+
/^\s*(?:public|private|protected|static|async|get|set|\s)*([A-Za-z_$][\w$]*)\s*\([^;]*\)\s*(?:\{|=>)\s*$/,
|
|
192
|
+
/^\s*(?:public|private|protected|internal|static|final|virtual|override|abstract|synchronized|\s)+(?:[\w<>\[\],.?]+\s+)+([A-Za-z_]\w*)\s*\([^;]*\)\s*(?:\{|=>)\s*$/,
|
|
193
|
+
/^\s*def\s+([A-Za-z_]\w*[!?=]?)\s*\(/,
|
|
194
|
+
/^\s*func\s+(?:\([^)]+\)\s*)?([A-Za-z_]\w*)\s*\(/,
|
|
195
|
+
/^\s*(?:public|private|protected|static|\s)*function\s+([A-Za-z_]\w*)\s*\(/,
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (let i = 0; i < lines.length; i++) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
for (const pattern of patterns) {
|
|
201
|
+
const match = line.match(pattern);
|
|
202
|
+
if (!match) continue;
|
|
203
|
+
defs.push({
|
|
204
|
+
name: match[1],
|
|
205
|
+
signature: _normalizeLine(line),
|
|
206
|
+
lineNumber: i + 1,
|
|
207
|
+
});
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return defs;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _removedDefinitions(prevLines, nextLines) {
|
|
216
|
+
const prevDefs = _extractDefinitions(prevLines);
|
|
217
|
+
const nextDefs = _extractDefinitions(nextLines);
|
|
218
|
+
const nextNames = new Set(nextDefs.map(d => d.name));
|
|
219
|
+
return prevDefs.filter(d => !nextNames.has(d.name));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assessDeletionRisk(prevText, nextText, opts = {}) {
|
|
223
|
+
const threshold = Math.max(1, parseInt(opts.threshold, 10) || 30);
|
|
224
|
+
const prevLines = _splitLines(prevText);
|
|
225
|
+
const nextLines = _splitLines(nextText);
|
|
226
|
+
const previousNonEmptyLines = prevLines.filter(line => _normalizeLine(line)).length;
|
|
227
|
+
const nextNonEmptyLines = nextLines.filter(line => _normalizeLine(line)).length;
|
|
228
|
+
|
|
229
|
+
if (String(prevText || '') === String(nextText || '')) {
|
|
230
|
+
return {
|
|
231
|
+
triggered: false,
|
|
232
|
+
threshold,
|
|
233
|
+
previousNonEmptyLines,
|
|
234
|
+
nextNonEmptyLines,
|
|
235
|
+
deletedLines: 0,
|
|
236
|
+
removedMethodCount: 0,
|
|
237
|
+
removedMethods: [],
|
|
238
|
+
riskPercent: 0,
|
|
239
|
+
summary: 'No deletion risk detected.',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const removedLines = _extractRemovedLines(prevLines, nextLines);
|
|
244
|
+
const removedMethods = _removedDefinitions(prevLines, nextLines);
|
|
245
|
+
const deletedLines = removedLines.length;
|
|
246
|
+
const baseRisk = previousNonEmptyLines > 0
|
|
247
|
+
? Math.round((deletedLines / previousNonEmptyLines) * 100)
|
|
248
|
+
: 0;
|
|
249
|
+
const methodBoost = removedMethods.length > 0
|
|
250
|
+
? Math.min(100, baseRisk + removedMethods.length * 20)
|
|
251
|
+
: baseRisk;
|
|
252
|
+
const riskPercent = Math.min(100, Math.max(baseRisk, methodBoost));
|
|
253
|
+
const triggered = deletedLines > 0 && (riskPercent >= threshold || removedMethods.length > 0);
|
|
254
|
+
|
|
255
|
+
const methodSummary = removedMethods.length > 0
|
|
256
|
+
? `${removedMethods.length} method${removedMethods.length === 1 ? '' : 's'} removed`
|
|
257
|
+
: null;
|
|
258
|
+
const lineSummary = deletedLines > 0
|
|
259
|
+
? `${deletedLines} line${deletedLines === 1 ? '' : 's'} deleted`
|
|
260
|
+
: null;
|
|
261
|
+
const summaryParts = [methodSummary, lineSummary].filter(Boolean);
|
|
262
|
+
const deletedLineSamples = removedLines
|
|
263
|
+
.map(r => r.normalized)
|
|
264
|
+
.filter(Boolean)
|
|
265
|
+
.slice(0, 5);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
triggered,
|
|
269
|
+
threshold,
|
|
270
|
+
previousNonEmptyLines,
|
|
271
|
+
nextNonEmptyLines,
|
|
272
|
+
deletedLines,
|
|
273
|
+
netLineDelta: nextNonEmptyLines - previousNonEmptyLines,
|
|
274
|
+
removedMethodCount: removedMethods.length,
|
|
275
|
+
removedMethods,
|
|
276
|
+
riskPercent,
|
|
277
|
+
deletedLineSamples,
|
|
278
|
+
summary: summaryParts.length > 0
|
|
279
|
+
? `${summaryParts.join(', ')} (risk ${riskPercent}%)`
|
|
280
|
+
: `Deletion risk ${riskPercent}%`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
assessDeletionRisk,
|
|
286
|
+
isPreWarningEnabled,
|
|
287
|
+
shouldExcludePreWarning,
|
|
288
|
+
recordPreWarning,
|
|
289
|
+
loadActivePreWarning,
|
|
290
|
+
loadActivePreWarnings,
|
|
291
|
+
listPreWarningHistory,
|
|
292
|
+
clearPreWarning,
|
|
293
|
+
clearExpiredPreWarnings,
|
|
294
|
+
warningFilePath,
|
|
295
|
+
historyFilePath,
|
|
296
|
+
};
|
|
@@ -197,6 +197,7 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
197
197
|
if (parts.length) incrementalSummary = parts.join('; ');
|
|
198
198
|
}
|
|
199
199
|
} else {
|
|
200
|
+
const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf899d15f3b60ea6a';
|
|
200
201
|
const lsInitial = git(['ls-tree', '--name-only', '-r', newTree], { cwd, allowFail: true });
|
|
201
202
|
if (lsInitial) {
|
|
202
203
|
const files = lsInitial.split('\n').filter(Boolean)
|
|
@@ -204,8 +205,29 @@ function createGitSnapshot(projectDir, cfg, opts = {}) {
|
|
|
204
205
|
.filter(f => cfg.protect.length === 0 || matchesAny(cfg.protect, f, { strict: true }));
|
|
205
206
|
changedCount = files.length;
|
|
206
207
|
const sample = files.slice(0, 5).join(', ');
|
|
207
|
-
|
|
208
|
-
|
|
208
|
+
|
|
209
|
+
const numstatInit = git(['diff-tree', '--no-commit-id', '--numstat', '-r', EMPTY_TREE, newTree], { cwd, allowFail: true });
|
|
210
|
+
const stats = {};
|
|
211
|
+
if (numstatInit) {
|
|
212
|
+
for (const line of numstatInit.split('\n').filter(Boolean)) {
|
|
213
|
+
const [add, del, ...nameParts] = line.split('\t');
|
|
214
|
+
const fname = nameParts.join('\t');
|
|
215
|
+
stats[fname] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
changedFiles = files.map(f => {
|
|
220
|
+
const s = stats[f] || { added: 0, deleted: 0 };
|
|
221
|
+
return { path: f, action: 'added', added: s.added, deleted: s.deleted };
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function fmtFilesInit(arr) {
|
|
225
|
+
return arr.slice(0, 5).map(f => {
|
|
226
|
+
const s = stats[f];
|
|
227
|
+
return s ? `${f} (+${s.added} -${s.deleted})` : f;
|
|
228
|
+
}).join(', ');
|
|
229
|
+
}
|
|
230
|
+
incrementalSummary = `Added ${files.length}: ${fmtFilesInit(files)}${files.length > 5 ? ', ...' : ''}`;
|
|
209
231
|
}
|
|
210
232
|
}
|
|
211
233
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const {
|
|
6
|
-
loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir, diskFreeGB,
|
|
7
|
-
} = require('../utils');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const {
|
|
6
|
+
loadConfig, gitAvailable, git, isGitRepo, gitDir: getGitDir, diskFreeGB,
|
|
7
|
+
} = require('../utils');
|
|
8
|
+
const { loadActivePreWarnings } = require('./pre-warning');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Gather comprehensive backup system status.
|
|
@@ -157,7 +158,14 @@ function getBackupStatus(projectDir) {
|
|
|
157
158
|
else if (freeGB < 5) disk.warning = 'low';
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
const activePreWarnings = loadActivePreWarnings(projectDir);
|
|
162
|
+
const preWarnings = {
|
|
163
|
+
active: activePreWarnings.length > 0,
|
|
164
|
+
count: activePreWarnings.length,
|
|
165
|
+
latest: activePreWarnings[0] || undefined,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return { watcher, config, lastBackup, refs, disk, preWarnings };
|
|
169
|
+
}
|
|
162
170
|
|
|
163
171
|
module.exports = { getBackupStatus };
|