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.
- package/README.md +94 -28
- package/README.zh-CN.md +91 -25
- package/ROADMAP.md +51 -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.0.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 -433
- 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 +51 -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 -433
- 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
|
@@ -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
|
|
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
|
|
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/
|
|
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 {
|
|
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 };
|