cursor-guard 4.9.0 → 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 +51 -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.0.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 -433
  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 +51 -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 -433
  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
@@ -10,10 +10,11 @@ const { runDiagnostics } = require('./doctor');
10
10
  const { createGitSnapshot, createShadowCopy } = require('./snapshot');
11
11
  const { listBackups, cleanShadowRetention } = require('./backups');
12
12
  const { restoreFile, previewProjectRestore, executeProjectRestore, createPreRestoreSnapshot, validateShadowSource } = require('./restore');
13
- const { runFixes } = require('./doctor-fix');
14
- const { getBackupStatus } = require('./status');
15
- const { createChangeTracker, recordChange, checkAnomaly, getAlertStatus, saveAlert, loadActiveAlert, clearExpiredAlert, clearAlert, alertFilePath } = require('./anomaly');
16
- const { getDashboard, dirSizeBytes, formatBytes, relativeTime } = require('./dashboard');
13
+ const { runFixes } = require('./doctor-fix');
14
+ const { getBackupStatus } = require('./status');
15
+ const { createChangeTracker, recordChange, checkAnomaly, getAlertStatus, saveAlert, loadActiveAlert, clearExpiredAlert, clearAlert, alertFilePath } = require('./anomaly');
16
+ const { getDashboard, dirSizeBytes, formatBytes, relativeTime } = require('./dashboard');
17
+ const { assessDeletionRisk, recordPreWarning, loadActivePreWarnings, listPreWarningHistory, clearPreWarning } = require('./pre-warning');
17
18
 
18
19
  let passed = 0;
19
20
  let failed = 0;
@@ -369,23 +370,23 @@ test('createGitSnapshot drops files when protect scope narrows', () => {
369
370
  }
370
371
  });
371
372
 
372
- test('createGitSnapshot with basename-only protect matches nested files', () => {
373
- const tmpDir = createTempGitRepo();
374
- try {
375
- const { loadConfig } = require('../utils');
376
- const cfg = { ...loadConfig(tmpDir).cfg, protect: ['app.js'] };
377
- const result = createGitSnapshot(tmpDir, cfg);
378
- assert.strictEqual(result.status, 'created');
379
-
380
- const treeFiles = execFileSync('git', ['ls-tree', '--name-only', '-r', result.commitHash], {
381
- cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
382
- }).trim().split('\n');
383
- assert.ok(treeFiles.includes('src/app.js'), 'basename "app.js" should match nested src/app.js');
384
- assert.ok(!treeFiles.includes('hello.txt'), 'unprotected files should be excluded');
385
- } finally {
386
- cleanupDir(tmpDir);
387
- }
388
- });
373
+ test('createGitSnapshot with basename-only protect does not match nested files in strict mode', () => {
374
+ const tmpDir = createTempGitRepo();
375
+ try {
376
+ const { loadConfig } = require('../utils');
377
+ const cfg = { ...loadConfig(tmpDir).cfg, protect: ['app.js'] };
378
+ const result = createGitSnapshot(tmpDir, cfg);
379
+ assert.strictEqual(result.status, 'created');
380
+
381
+ const treeFiles = execFileSync('git', ['ls-tree', '--name-only', '-r', result.commitHash], {
382
+ cwd: tmpDir, stdio: 'pipe', encoding: 'utf-8',
383
+ }).trim().split('\n');
384
+ assert.ok(!treeFiles.includes('src/app.js'), 'basename-only protect should NOT match nested src/app.js');
385
+ assert.ok(!treeFiles.includes('hello.txt'), 'unprotected files should be excluded');
386
+ } finally {
387
+ cleanupDir(tmpDir);
388
+ }
389
+ });
389
390
 
390
391
  test('createGitSnapshot with basename-only ignore excludes nested files', () => {
391
392
  const tmpDir = createTempGitRepo();
@@ -1369,7 +1370,85 @@ test('clearExpiredAlert removes expired file but leaves active ones', () => {
1369
1370
 
1370
1371
  // ── core/dashboard.js ───────────────────────────────────────────
1371
1372
 
1372
- console.log('\ncore/dashboard:');
1373
+ console.log('\ncore/pre-warning:');
1374
+
1375
+ test('assessDeletionRisk flags removed methods', () => {
1376
+ const prevText = [
1377
+ 'function keepMe() {',
1378
+ ' return true;',
1379
+ '}',
1380
+ '',
1381
+ 'function removeMe() {',
1382
+ ' return false;',
1383
+ '}',
1384
+ ].join('\n');
1385
+ const nextText = [
1386
+ 'function keepMe() {',
1387
+ ' return true;',
1388
+ '}',
1389
+ ].join('\n');
1390
+
1391
+ const result = assessDeletionRisk(prevText, nextText, { threshold: 30 });
1392
+ assert.strictEqual(result.triggered, true);
1393
+ assert.strictEqual(result.removedMethodCount, 1);
1394
+ assert.ok(result.removedMethods.some(method => method.name === 'removeMe'));
1395
+ assert.ok(result.deletedLines >= 3);
1396
+ assert.ok(result.riskPercent >= 40);
1397
+ });
1398
+
1399
+ test('recordPreWarning persists active warnings and history', () => {
1400
+ const tmpDir = createTempGitRepo();
1401
+ try {
1402
+ recordPreWarning(tmpDir, {
1403
+ file: 'src/app.js',
1404
+ riskPercent: 68,
1405
+ summary: '1 method removed, 5 lines deleted (risk 68%)',
1406
+ removedMethodCount: 1,
1407
+ removedMethods: [{ name: 'login', lineNumber: 12 }],
1408
+ deletedLines: 5,
1409
+ }, { setActive: true });
1410
+
1411
+ const active = loadActivePreWarnings(tmpDir);
1412
+ const history = listPreWarningHistory(tmpDir, 5);
1413
+ assert.strictEqual(active.length, 1);
1414
+ assert.strictEqual(active[0].file, 'src/app.js');
1415
+ assert.ok(active[0].expiresAt, 'active warning should have expiry');
1416
+ assert.strictEqual(history.length, 1);
1417
+
1418
+ clearPreWarning(tmpDir, 'src/app.js');
1419
+ assert.strictEqual(loadActivePreWarnings(tmpDir).length, 0);
1420
+ } finally {
1421
+ cleanupDir(tmpDir);
1422
+ }
1423
+ });
1424
+
1425
+ test('getBackupStatus and getDashboard expose active pre-warning state', () => {
1426
+ const tmpDir = createTempGitRepo();
1427
+ try {
1428
+ recordPreWarning(tmpDir, {
1429
+ file: 'src/app.js',
1430
+ riskPercent: 72,
1431
+ summary: '1 method removed, 6 lines deleted (risk 72%)',
1432
+ removedMethodCount: 1,
1433
+ removedMethods: [{ name: 'login', lineNumber: 15 }],
1434
+ deletedLines: 6,
1435
+ }, { setActive: true });
1436
+
1437
+ const status = getBackupStatus(tmpDir);
1438
+ const dashboard = getDashboard(tmpDir);
1439
+
1440
+ assert.strictEqual(status.preWarnings.active, true);
1441
+ assert.strictEqual(status.preWarnings.count, 1);
1442
+ assert.strictEqual(status.preWarnings.latest.file, 'src/app.js');
1443
+ assert.strictEqual(dashboard.preWarnings.active, true);
1444
+ assert.strictEqual(dashboard.preWarnings.count, 1);
1445
+ assert.ok(dashboard.health.issues.some(issue => issue.includes('Pre-warning active')));
1446
+ } finally {
1447
+ cleanupDir(tmpDir);
1448
+ }
1449
+ });
1450
+
1451
+ console.log('\ncore/dashboard:');
1373
1452
 
1374
1453
  test('formatBytes formats correctly', () => {
1375
1454
  assert.strictEqual(formatBytes(500), '500B');
@@ -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 };