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.
Files changed (54) hide show
  1. package/README.md +94 -28
  2. package/README.zh-CN.md +91 -25
  3. package/ROADMAP.md +45 -9
  4. package/SKILL.md +32 -22
  5. package/package.json +1 -1
  6. package/references/config-reference.md +68 -7
  7. package/references/config-reference.zh-CN.md +68 -7
  8. package/references/cursor-guard.example.json +11 -7
  9. package/references/cursor-guard.schema.json +30 -7
  10. package/references/dashboard/public/app.js +73 -27
  11. package/references/dashboard/public/index.html +8 -7
  12. package/references/lib/auto-backup.js +40 -2
  13. package/references/lib/core/backups.js +46 -16
  14. package/references/lib/core/core.test.js +101 -22
  15. package/references/lib/core/dashboard.js +37 -23
  16. package/references/lib/core/doctor.js +19 -13
  17. package/references/lib/core/pre-warning.js +296 -0
  18. package/references/lib/core/snapshot.js +24 -2
  19. package/references/lib/core/status.js +15 -7
  20. package/references/lib/utils.js +46 -20
  21. package/references/mcp/mcp.test.js +60 -12
  22. package/references/mcp/server.js +72 -60
  23. package/references/quickstart.zh-CN.md +46 -21
  24. package/references/vscode-extension/build-vsix.js +4 -3
  25. package/references/vscode-extension/dist/LICENSE +65 -0
  26. package/references/vscode-extension/dist/{cursor-guard-ide-4.9.1.vsix → cursor-guard-ide-4.9.6.vsix} +0 -0
  27. package/references/vscode-extension/dist/dashboard/public/app.js +73 -27
  28. package/references/vscode-extension/dist/dashboard/public/index.html +8 -7
  29. package/references/vscode-extension/dist/extension.js +498 -296
  30. package/references/vscode-extension/dist/guard-version.json +1 -1
  31. package/references/vscode-extension/dist/lib/auto-backup.js +40 -2
  32. package/references/vscode-extension/dist/lib/core/backups.js +46 -16
  33. package/references/vscode-extension/dist/lib/core/dashboard.js +37 -23
  34. package/references/vscode-extension/dist/lib/core/doctor.js +19 -13
  35. package/references/vscode-extension/dist/lib/core/pre-warning.js +296 -0
  36. package/references/vscode-extension/dist/lib/core/snapshot.js +24 -2
  37. package/references/vscode-extension/dist/lib/core/status.js +15 -7
  38. package/references/vscode-extension/dist/lib/sidebar-webview.js +502 -447
  39. package/references/vscode-extension/dist/lib/status-bar.js +95 -68
  40. package/references/vscode-extension/dist/lib/tree-view.js +174 -114
  41. package/references/vscode-extension/dist/lib/utils.js +46 -20
  42. package/references/vscode-extension/dist/mcp/server.js +393 -30
  43. package/references/vscode-extension/dist/package.json +1 -1
  44. package/references/vscode-extension/dist/skill/ROADMAP.md +45 -9
  45. package/references/vscode-extension/dist/skill/SKILL.md +32 -22
  46. package/references/vscode-extension/dist/skill/config-reference.md +68 -7
  47. package/references/vscode-extension/dist/skill/config-reference.zh-CN.md +68 -7
  48. package/references/vscode-extension/dist/skill/cursor-guard.example.json +11 -7
  49. package/references/vscode-extension/dist/skill/cursor-guard.schema.json +30 -7
  50. package/references/vscode-extension/extension.js +498 -296
  51. package/references/vscode-extension/lib/sidebar-webview.js +502 -447
  52. package/references/vscode-extension/lib/status-bar.js +95 -68
  53. package/references/vscode-extension/lib/tree-view.js +174 -114
  54. package/references/vscode-extension/package.json +1 -1
@@ -1 +1 @@
1
- {"version":"4.9.1"}
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
- * Runs diff-tree --numstat + --name-status against parent (or ls-tree for root).
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 parentCheck = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
450
+ const parentCommit = git(['rev-parse', '--verify', `${resolved}^`], { cwd: projectDir, allowFail: true });
451
+ const parent = parentCommit || GIT_EMPTY_TREE_SHA;
422
452
 
423
- if (!parentCheck) {
424
- const lsOut = git(['ls-tree', '--name-only', '-r', resolved], { cwd: projectDir, allowFail: true });
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] = { added: add === '-' ? 0 : parseInt(add, 10), deleted: del === '-' ? 0 : parseInt(del, 10) };
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
- const action = code.startsWith('R') ? 'renamed' : (ACTION_MAP[code] || 'modified');
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 s = stats[fileName] || { added: 0, deleted: 0 };
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 { parseShadowTimestamp } = require('./backups');
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
- return {
197
- strategy,
198
- lastBackup,
199
- counts,
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
- watcher: status.watcher,
205
- disk: status.disk,
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.retention && cfg.retention.mode) {
182
- const validModes = ['days', 'count', 'size'];
183
- if (!validModes.includes(cfg.retention.mode)) {
184
- check('Config: retention.mode', 'FAIL', `invalid value '${cfg.retention.mode}'`);
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
- incrementalSummary = `Added ${files.length}: ${sample}${files.length > 5 ? ', ...' : ''}`;
208
- changedFiles = files.map(f => ({ path: f, action: 'added', added: 0, deleted: 0 }));
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
- return { watcher, config, lastBackup, refs, disk };
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 };