cursor-guard 2.0.2 → 2.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-guard",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Protects code from accidental AI overwrite or deletion in Cursor IDE — mandatory pre-write snapshots, review-before-apply, local Git safety net, and deterministic recovery. | 保护代码免受 Cursor AI 代理意外覆写或删除——强制写前快照、预览再执行、本地 Git 安全网、确定性恢复。",
5
5
  "keywords": [
6
6
  "cursor",
@@ -23,6 +23,9 @@
23
23
  "engines": {
24
24
  "node": ">=18"
25
25
  },
26
+ "scripts": {
27
+ "test": "node references/lib/utils.test.js"
28
+ },
26
29
  "bin": {
27
30
  "cursor-guard-backup": "references/bin/cursor-guard-backup.js",
28
31
  "cursor-guard-doctor": "references/bin/cursor-guard-doctor.js"
@@ -32,7 +35,8 @@
32
35
  "README.md",
33
36
  "README.zh-CN.md",
34
37
  "LICENSE",
35
- "references/"
38
+ "references/",
39
+ "!references/lib/utils.test.js"
36
40
  ],
37
41
  "main": "SKILL.md"
38
42
  }
@@ -5,9 +5,26 @@ const path = require('path');
5
5
  const { parseArgs } = require('../lib/utils');
6
6
 
7
7
  const args = parseArgs(process.argv);
8
+
9
+ if (args.help || args.h) {
10
+ console.log(`Usage: cursor-guard-backup [options]
11
+
12
+ Options:
13
+ --path <dir> Project directory to watch (default: current dir)
14
+ --interval <sec> Override backup interval in seconds
15
+ --help, -h Show this help message
16
+ --version, -v Show version number`);
17
+ process.exit(0);
18
+ }
19
+
20
+ if (args.version || args.v) {
21
+ const pkg = require('../../package.json');
22
+ console.log(pkg.version);
23
+ process.exit(0);
24
+ }
25
+
8
26
  const targetPath = args.path || '.';
9
27
  const interval = parseInt(args.interval, 10) || 0;
10
-
11
28
  const resolved = path.resolve(targetPath);
12
29
 
13
30
  const { runBackup } = require('../lib/auto-backup');
@@ -5,6 +5,23 @@ const path = require('path');
5
5
  const { parseArgs } = require('../lib/utils');
6
6
 
7
7
  const args = parseArgs(process.argv);
8
+
9
+ if (args.help || args.h) {
10
+ console.log(`Usage: cursor-guard-doctor [options]
11
+
12
+ Options:
13
+ --path <dir> Project directory to check (default: current dir)
14
+ --help, -h Show this help message
15
+ --version, -v Show version number`);
16
+ process.exit(0);
17
+ }
18
+
19
+ if (args.version || args.v) {
20
+ const pkg = require('../../package.json');
21
+ console.log(pkg.version);
22
+ process.exit(0);
23
+ }
24
+
8
25
  const targetPath = args.path || '.';
9
26
  const resolved = path.resolve(targetPath);
10
27
 
@@ -312,8 +312,6 @@ function isProcessAlive(pid) {
312
312
  // ── Main ────────────────────────────────────────────────────────
313
313
 
314
314
  async function runBackup(projectDir, intervalOverride) {
315
- process.chdir(projectDir);
316
-
317
315
  const hasGit = gitAvailable();
318
316
  const repo = hasGit && isGitRepo(projectDir);
319
317
  const gDir = repo ? getGitDir(projectDir) : null;
@@ -326,7 +324,7 @@ async function runBackup(projectDir, intervalOverride) {
326
324
  const guardIndex = gDir ? path.join(gDir, 'cursor-guard-index') : null;
327
325
 
328
326
  // Load config
329
- let { cfg, loaded, error } = loadConfig(projectDir);
327
+ let { cfg, loaded, error, warnings } = loadConfig(projectDir);
330
328
  let interval = intervalOverride || cfg.auto_backup_interval_seconds || 60;
331
329
  if (interval < 5) interval = 5;
332
330
  let cfgMtime = 0;
@@ -337,6 +335,9 @@ async function runBackup(projectDir, intervalOverride) {
337
335
  console.log(color.yellow(`[guard] WARNING: .cursor-guard.json parse error — using defaults. ${error}`));
338
336
  } else if (loaded) {
339
337
  console.log(color.cyan(`[guard] Config loaded protect=${cfg.protect.length} ignore=${cfg.ignore.length} strategy=${cfg.backup_strategy} git_retention=${cfg.git_retention.enabled ? 'on' : 'off'}`));
338
+ if (warnings && warnings.length > 0) {
339
+ for (const w of warnings) console.log(color.yellow(`[guard] WARNING: ${w}`));
340
+ }
340
341
  }
341
342
 
342
343
  // Strategy check
@@ -420,6 +421,16 @@ async function runBackup(projectDir, intervalOverride) {
420
421
 
421
422
  const logger = createLogger(logFilePath);
422
423
 
424
+ // Global error handlers
425
+ process.on('uncaughtException', (err) => {
426
+ logger.error(`Uncaught exception: ${err.message}`);
427
+ cleanup();
428
+ process.exit(1);
429
+ });
430
+ process.on('unhandledRejection', (reason) => {
431
+ logger.error(`Unhandled rejection: ${reason}`);
432
+ });
433
+
423
434
  // Banner
424
435
  console.log('');
425
436
  console.log(color.cyan(`[guard] Watching '${projectDir}' every ${interval}s (Ctrl+C to stop)`));
@@ -52,18 +52,22 @@ const ALWAYS_SKIP = /[/\\](\.git|\.cursor-guard-backup|node_modules)[/\\]/;
52
52
 
53
53
  function walkDir(dir, rootDir) {
54
54
  const results = [];
55
- let entries;
56
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
57
- catch { return results; }
58
- for (const entry of entries) {
59
- const full = path.join(dir, entry.name);
60
- const rel = path.relative(rootDir, full).replace(/\\/g, '/');
61
- if (ALWAYS_SKIP.test('/' + rel + '/')) continue;
62
- if (entry.isSymbolicLink()) continue;
63
- if (entry.isDirectory()) {
64
- results.push(...walkDir(full, rootDir));
65
- } else if (entry.isFile()) {
66
- results.push({ full, rel, name: entry.name });
55
+ const stack = [dir];
56
+ while (stack.length > 0) {
57
+ const current = stack.pop();
58
+ let entries;
59
+ try { entries = fs.readdirSync(current, { withFileTypes: true }); }
60
+ catch { continue; }
61
+ for (const entry of entries) {
62
+ const full = path.join(current, entry.name);
63
+ const rel = path.relative(rootDir, full).replace(/\\/g, '/');
64
+ if (ALWAYS_SKIP.test('/' + rel + '/')) continue;
65
+ if (entry.isSymbolicLink()) continue;
66
+ if (entry.isDirectory()) {
67
+ stack.push(full);
68
+ } else if (entry.isFile()) {
69
+ results.push({ full, rel, name: entry.name });
70
+ }
67
71
  }
68
72
  }
69
73
  return results;
@@ -73,6 +77,11 @@ function walkDir(dir, rootDir) {
73
77
 
74
78
  const DEFAULT_SECRETS = ['.env', '.env.*', '*.key', '*.pem', '*.p12', '*.pfx', 'credentials*'];
75
79
 
80
+ const VALID_STRATEGIES = ['git', 'shadow', 'both'];
81
+ const VALID_PRE_RESTORE = ['always', 'ask', 'never'];
82
+ const VALID_RETENTION_MODES = ['days', 'count', 'size'];
83
+ const VALID_GIT_RETENTION_MODES = ['days', 'count'];
84
+
76
85
  const DEFAULT_CONFIG = {
77
86
  protect: [],
78
87
  ignore: [],
@@ -101,22 +110,47 @@ function loadConfig(projectDir) {
101
110
  const merged = [...new Set([...cfg.secrets_patterns, ...raw.secrets_patterns_extra])];
102
111
  cfg.secrets_patterns = merged;
103
112
  }
104
- if (typeof raw.backup_strategy === 'string') cfg.backup_strategy = raw.backup_strategy;
113
+ const warnings = [];
114
+ if (typeof raw.backup_strategy === 'string') {
115
+ if (VALID_STRATEGIES.includes(raw.backup_strategy)) {
116
+ cfg.backup_strategy = raw.backup_strategy;
117
+ } else {
118
+ warnings.push(`Unknown backup_strategy "${raw.backup_strategy}", using default "${cfg.backup_strategy}"`);
119
+ }
120
+ }
105
121
  if (typeof raw.auto_backup_interval_seconds === 'number') cfg.auto_backup_interval_seconds = raw.auto_backup_interval_seconds;
106
- if (typeof raw.pre_restore_backup === 'string') cfg.pre_restore_backup = raw.pre_restore_backup;
122
+ if (typeof raw.pre_restore_backup === 'string') {
123
+ if (VALID_PRE_RESTORE.includes(raw.pre_restore_backup)) {
124
+ cfg.pre_restore_backup = raw.pre_restore_backup;
125
+ } else {
126
+ warnings.push(`Unknown pre_restore_backup "${raw.pre_restore_backup}", using default "${cfg.pre_restore_backup}"`);
127
+ }
128
+ }
107
129
  if (raw.retention) {
108
- if (raw.retention.mode) cfg.retention.mode = raw.retention.mode;
109
- if (raw.retention.days) cfg.retention.days = raw.retention.days;
110
- if (raw.retention.max_count) cfg.retention.max_count = raw.retention.max_count;
111
- if (raw.retention.max_size_mb) cfg.retention.max_size_mb = raw.retention.max_size_mb;
130
+ if (raw.retention.mode) {
131
+ if (VALID_RETENTION_MODES.includes(raw.retention.mode)) {
132
+ cfg.retention.mode = raw.retention.mode;
133
+ } else {
134
+ warnings.push(`Unknown retention.mode "${raw.retention.mode}", using default "${cfg.retention.mode}"`);
135
+ }
136
+ }
137
+ if (typeof raw.retention.days === 'number') cfg.retention.days = raw.retention.days;
138
+ if (typeof raw.retention.max_count === 'number') cfg.retention.max_count = raw.retention.max_count;
139
+ if (typeof raw.retention.max_size_mb === 'number') cfg.retention.max_size_mb = raw.retention.max_size_mb;
112
140
  }
113
141
  if (raw.git_retention) {
114
142
  if (raw.git_retention.enabled === true) cfg.git_retention.enabled = true;
115
- if (raw.git_retention.mode) cfg.git_retention.mode = raw.git_retention.mode;
116
- if (raw.git_retention.days) cfg.git_retention.days = raw.git_retention.days;
117
- if (raw.git_retention.max_count) cfg.git_retention.max_count = raw.git_retention.max_count;
143
+ if (raw.git_retention.mode) {
144
+ if (VALID_GIT_RETENTION_MODES.includes(raw.git_retention.mode)) {
145
+ cfg.git_retention.mode = raw.git_retention.mode;
146
+ } else {
147
+ warnings.push(`Unknown git_retention.mode "${raw.git_retention.mode}", using default "${cfg.git_retention.mode}"`);
148
+ }
149
+ }
150
+ if (typeof raw.git_retention.days === 'number') cfg.git_retention.days = raw.git_retention.days;
151
+ if (typeof raw.git_retention.max_count === 'number') cfg.git_retention.max_count = raw.git_retention.max_count;
118
152
  }
119
- return { cfg, loaded: true, error: null };
153
+ return { cfg, loaded: true, error: null, warnings };
120
154
  } catch (e) {
121
155
  return { cfg, loaded: false, error: e.message };
122
156
  }
@@ -249,11 +283,23 @@ function timestamp() {
249
283
  return new Date().toISOString().replace('T', ' ').substring(0, 19);
250
284
  }
251
285
 
252
- function createLogger(logFilePath) {
286
+ function createLogger(logFilePath, maxSizeMB = 10) {
287
+ let writeCount = 0;
288
+ function rotateIfNeeded() {
289
+ if (++writeCount % 100 !== 0) return;
290
+ try {
291
+ const stat = fs.statSync(logFilePath);
292
+ if (stat.size > maxSizeMB * 1024 * 1024) {
293
+ const old = logFilePath + '.old';
294
+ try { fs.unlinkSync(old); } catch { /* ignore */ }
295
+ fs.renameSync(logFilePath, old);
296
+ }
297
+ } catch { /* ignore */ }
298
+ }
253
299
  return {
254
300
  log(msg, c = 'green') {
255
301
  const line = `${timestamp()} ${msg}`;
256
- try { fs.appendFileSync(logFilePath, line + '\n'); } catch { /* ignore */ }
302
+ try { fs.appendFileSync(logFilePath, line + '\n'); rotateIfNeeded(); } catch { /* ignore */ }
257
303
  console.log(color[c] ? color[c](`[guard] ${line}`) : `[guard] ${line}`);
258
304
  },
259
305
  info(msg) { this.log(msg, 'cyan'); },
@@ -1,329 +0,0 @@
1
- 'use strict';
2
-
3
- const assert = require('assert');
4
- const path = require('path');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const {
8
- globMatch, matchesAny, loadConfig, DEFAULT_CONFIG, DEFAULT_SECRETS,
9
- filterFiles, buildManifest, manifestChanged, parseArgs, walkDir,
10
- } = require('./utils');
11
-
12
- let passed = 0;
13
- let failed = 0;
14
-
15
- function test(name, fn) {
16
- try {
17
- fn();
18
- passed++;
19
- console.log(` \x1b[32m✓\x1b[0m ${name}`);
20
- } catch (e) {
21
- failed++;
22
- console.log(` \x1b[31m✗\x1b[0m ${name}`);
23
- console.log(` ${e.message}`);
24
- }
25
- }
26
-
27
- // ── globMatch ────────────────────────────────────────────────────
28
-
29
- console.log('\nglobMatch:');
30
-
31
- test('exact filename match', () => {
32
- assert.strictEqual(globMatch('.env', '.env'), true);
33
- assert.strictEqual(globMatch('.env', '.envx'), false);
34
- });
35
-
36
- test('* matches within a single segment', () => {
37
- assert.strictEqual(globMatch('*.js', 'foo.js'), true);
38
- assert.strictEqual(globMatch('*.js', 'bar.ts'), false);
39
- assert.strictEqual(globMatch('*.js', 'dir/foo.js'), false);
40
- });
41
-
42
- test('** matches across directories', () => {
43
- assert.strictEqual(globMatch('**/*.js', 'src/foo.js'), true);
44
- assert.strictEqual(globMatch('**/*.js', 'a/b/c/foo.js'), true);
45
- // **/*.js requires a slash — root-level 'foo.js' doesn't match (matchesAny checks leaf separately)
46
- assert.strictEqual(globMatch('**/*.js', 'foo.js'), false);
47
- assert.strictEqual(globMatch('**/*.js', 'foo.ts'), false);
48
- });
49
-
50
- test('? matches single character', () => {
51
- assert.strictEqual(globMatch('?.txt', 'a.txt'), true);
52
- assert.strictEqual(globMatch('?.txt', 'ab.txt'), false);
53
- });
54
-
55
- test('.env.* pattern', () => {
56
- assert.strictEqual(globMatch('.env.*', '.env.local'), true);
57
- assert.strictEqual(globMatch('.env.*', '.env.production'), true);
58
- assert.strictEqual(globMatch('.env.*', '.env'), false);
59
- });
60
-
61
- test('credentials* pattern', () => {
62
- assert.strictEqual(globMatch('credentials*', 'credentials'), true);
63
- assert.strictEqual(globMatch('credentials*', 'credentials.json'), true);
64
- assert.strictEqual(globMatch('credentials*', 'my-credentials'), false);
65
- });
66
-
67
- test('directory pattern src/**', () => {
68
- assert.strictEqual(globMatch('src/**', 'src/foo.js'), true);
69
- assert.strictEqual(globMatch('src/**', 'src/a/b.js'), true);
70
- assert.strictEqual(globMatch('src/**', 'lib/foo.js'), false);
71
- });
72
-
73
- test('backslash normalization', () => {
74
- assert.strictEqual(globMatch('src/**/*.ts', 'src\\components\\App.ts'), true);
75
- });
76
-
77
- test('regex special chars in pattern are escaped', () => {
78
- assert.strictEqual(globMatch('file(1).txt', 'file(1).txt'), true);
79
- assert.strictEqual(globMatch('file[0].txt', 'file[0].txt'), true); // [] escaped as literals
80
- });
81
-
82
- // ── matchesAny ───────────────────────────────────────────────────
83
-
84
- console.log('\nmatchesAny:');
85
-
86
- test('matches when any pattern hits', () => {
87
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.js'), true);
88
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.ts'), true);
89
- assert.strictEqual(matchesAny(['*.js', '*.ts'], 'foo.py'), false);
90
- });
91
-
92
- test('checks leaf filename for deep paths', () => {
93
- assert.strictEqual(matchesAny(['*.key'], 'secrets/server.key'), true);
94
- assert.strictEqual(matchesAny(['.env'], 'config/.env'), true);
95
- });
96
-
97
- test('empty patterns matches nothing', () => {
98
- assert.strictEqual(matchesAny([], 'anything'), false);
99
- });
100
-
101
- // ── loadConfig ───────────────────────────────────────────────────
102
-
103
- console.log('\nloadConfig:');
104
-
105
- test('returns defaults when no config file', () => {
106
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
107
- try {
108
- const { cfg, loaded, error } = loadConfig(tmpDir);
109
- assert.strictEqual(loaded, false);
110
- assert.strictEqual(error, null);
111
- assert.deepStrictEqual(cfg.protect, []);
112
- assert.deepStrictEqual(cfg.ignore, []);
113
- assert.deepStrictEqual(cfg.secrets_patterns, DEFAULT_SECRETS);
114
- assert.strictEqual(cfg.backup_strategy, 'git');
115
- assert.strictEqual(cfg.git_retention.enabled, false);
116
- } finally {
117
- fs.rmSync(tmpDir, { recursive: true, force: true });
118
- }
119
- });
120
-
121
- test('loads and merges custom config', () => {
122
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
123
- try {
124
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
125
- protect: ['src/**'],
126
- backup_strategy: 'both',
127
- retention: { mode: 'count', max_count: 50 },
128
- }));
129
- const { cfg, loaded, error } = loadConfig(tmpDir);
130
- assert.strictEqual(loaded, true);
131
- assert.strictEqual(error, null);
132
- assert.deepStrictEqual(cfg.protect, ['src/**']);
133
- assert.strictEqual(cfg.backup_strategy, 'both');
134
- assert.strictEqual(cfg.retention.mode, 'count');
135
- assert.strictEqual(cfg.retention.max_count, 50);
136
- assert.strictEqual(cfg.retention.days, 30); // default preserved
137
- } finally {
138
- fs.rmSync(tmpDir, { recursive: true, force: true });
139
- }
140
- });
141
-
142
- test('handles malformed JSON gracefully', () => {
143
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
144
- try {
145
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), '{ broken }');
146
- const { cfg, loaded, error } = loadConfig(tmpDir);
147
- assert.strictEqual(loaded, false);
148
- assert.ok(error, 'should have an error message');
149
- assert.deepStrictEqual(cfg.protect, []);
150
- } finally {
151
- fs.rmSync(tmpDir, { recursive: true, force: true });
152
- }
153
- });
154
-
155
- test('secrets_patterns override replaces defaults entirely', () => {
156
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
157
- try {
158
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
159
- secrets_patterns: ['my-secret'],
160
- }));
161
- const { cfg } = loadConfig(tmpDir);
162
- assert.deepStrictEqual(cfg.secrets_patterns, ['my-secret']);
163
- } finally {
164
- fs.rmSync(tmpDir, { recursive: true, force: true });
165
- }
166
- });
167
-
168
- test('secrets_patterns_extra appends to defaults', () => {
169
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
170
- try {
171
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
172
- secrets_patterns_extra: ['*.secret', 'tokens.*'],
173
- }));
174
- const { cfg } = loadConfig(tmpDir);
175
- assert.ok(cfg.secrets_patterns.includes('.env'), 'should keep default .env');
176
- assert.ok(cfg.secrets_patterns.includes('*.p12'), 'should keep default *.p12');
177
- assert.ok(cfg.secrets_patterns.includes('*.secret'), 'should include extra *.secret');
178
- assert.ok(cfg.secrets_patterns.includes('tokens.*'), 'should include extra tokens.*');
179
- } finally {
180
- fs.rmSync(tmpDir, { recursive: true, force: true });
181
- }
182
- });
183
-
184
- test('secrets_patterns_extra merges with custom secrets_patterns', () => {
185
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
186
- try {
187
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
188
- secrets_patterns: ['.env'],
189
- secrets_patterns_extra: ['.env', '*.secret'],
190
- }));
191
- const { cfg } = loadConfig(tmpDir);
192
- assert.deepStrictEqual(cfg.secrets_patterns, ['.env', '*.secret']);
193
- } finally {
194
- fs.rmSync(tmpDir, { recursive: true, force: true });
195
- }
196
- });
197
-
198
- test('non-string backup_strategy is ignored', () => {
199
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-test-'));
200
- try {
201
- fs.writeFileSync(path.join(tmpDir, '.cursor-guard.json'), JSON.stringify({
202
- backup_strategy: 123,
203
- auto_backup_interval_seconds: 'bad',
204
- }));
205
- const { cfg } = loadConfig(tmpDir);
206
- assert.strictEqual(cfg.backup_strategy, 'git');
207
- assert.strictEqual(cfg.auto_backup_interval_seconds, 60);
208
- } finally {
209
- fs.rmSync(tmpDir, { recursive: true, force: true });
210
- }
211
- });
212
-
213
- // ── filterFiles ──────────────────────────────────────────────────
214
-
215
- console.log('\nfilterFiles:');
216
-
217
- const makeFiles = names => names.map(n => ({ full: `/fake/${n}`, rel: n, name: path.basename(n) }));
218
-
219
- test('no protect/ignore returns all non-secret files', () => {
220
- const files = makeFiles(['a.js', 'b.ts', '.env', 'credentials.json']);
221
- const cfg = { ...DEFAULT_CONFIG };
222
- const result = filterFiles(files, cfg);
223
- const rels = result.map(f => f.rel);
224
- assert.ok(!rels.includes('.env'));
225
- assert.ok(!rels.includes('credentials.json'));
226
- assert.ok(rels.includes('a.js'));
227
- assert.ok(rels.includes('b.ts'));
228
- });
229
-
230
- test('protect narrows scope', () => {
231
- const files = makeFiles(['src/a.js', 'lib/b.js', 'README.md']);
232
- const cfg = { ...DEFAULT_CONFIG, protect: ['src/**'] };
233
- const result = filterFiles(files, cfg);
234
- assert.strictEqual(result.length, 1);
235
- assert.strictEqual(result[0].rel, 'src/a.js');
236
- });
237
-
238
- test('ignore excludes files', () => {
239
- const files = makeFiles(['src/a.js', 'src/a.test.js', 'src/b.js']);
240
- const cfg = { ...DEFAULT_CONFIG, ignore: ['**/*.test.js'] };
241
- const result = filterFiles(files, cfg);
242
- const rels = result.map(f => f.rel);
243
- assert.ok(!rels.includes('src/a.test.js'));
244
- assert.ok(rels.includes('src/a.js'));
245
- });
246
-
247
- // ── manifestChanged ──────────────────────────────────────────────
248
-
249
- console.log('\nmanifestChanged:');
250
-
251
- test('null old manifest means changed', () => {
252
- assert.strictEqual(manifestChanged(null, { 'a.js': { mtimeMs: 1, size: 100 } }), true);
253
- });
254
-
255
- test('identical manifests are not changed', () => {
256
- const m = { 'a.js': { mtimeMs: 1, size: 100 } };
257
- assert.strictEqual(manifestChanged(m, { ...m }), false);
258
- });
259
-
260
- test('different mtime means changed', () => {
261
- const old = { 'a.js': { mtimeMs: 1, size: 100 } };
262
- const nw = { 'a.js': { mtimeMs: 2, size: 100 } };
263
- assert.strictEqual(manifestChanged(old, nw), true);
264
- });
265
-
266
- test('new file means changed', () => {
267
- const old = { 'a.js': { mtimeMs: 1, size: 100 } };
268
- const nw = { 'a.js': { mtimeMs: 1, size: 100 }, 'b.js': { mtimeMs: 2, size: 50 } };
269
- assert.strictEqual(manifestChanged(old, nw), true);
270
- });
271
-
272
- // ── parseArgs ────────────────────────────────────────────────────
273
-
274
- console.log('\nparseArgs:');
275
-
276
- test('parses --key value pairs', () => {
277
- const args = parseArgs(['node', 'script', '--path', '/tmp', '--interval', '30']);
278
- assert.strictEqual(args.path, '/tmp');
279
- assert.strictEqual(args.interval, '30');
280
- });
281
-
282
- test('parses boolean flags', () => {
283
- const args = parseArgs(['node', 'script', '--verbose']);
284
- assert.strictEqual(args.verbose, true);
285
- });
286
-
287
- test('empty args returns empty object', () => {
288
- const args = parseArgs(['node', 'script']);
289
- assert.deepStrictEqual(args, {});
290
- });
291
-
292
- // ── walkDir ──────────────────────────────────────────────────────
293
-
294
- console.log('\nwalkDir:');
295
-
296
- test('discovers files recursively', () => {
297
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-walk-'));
298
- try {
299
- fs.mkdirSync(path.join(tmpDir, 'sub'), { recursive: true });
300
- fs.writeFileSync(path.join(tmpDir, 'a.txt'), 'a');
301
- fs.writeFileSync(path.join(tmpDir, 'sub', 'b.txt'), 'b');
302
- const files = walkDir(tmpDir, tmpDir);
303
- const rels = files.map(f => f.rel).sort();
304
- assert.deepStrictEqual(rels, ['a.txt', 'sub/b.txt']);
305
- } finally {
306
- fs.rmSync(tmpDir, { recursive: true, force: true });
307
- }
308
- });
309
-
310
- test('skips .git and node_modules', () => {
311
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'guard-walk-'));
312
- try {
313
- fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true });
314
- fs.mkdirSync(path.join(tmpDir, 'node_modules'), { recursive: true });
315
- fs.writeFileSync(path.join(tmpDir, '.git', 'HEAD'), 'ref');
316
- fs.writeFileSync(path.join(tmpDir, 'node_modules', 'x.js'), 'x');
317
- fs.writeFileSync(path.join(tmpDir, 'real.js'), 'y');
318
- const files = walkDir(tmpDir, tmpDir);
319
- assert.strictEqual(files.length, 1);
320
- assert.strictEqual(files[0].rel, 'real.js');
321
- } finally {
322
- fs.rmSync(tmpDir, { recursive: true, force: true });
323
- }
324
- });
325
-
326
- // ── Summary ──────────────────────────────────────────────────────
327
-
328
- console.log(`\n${passed + failed} tests: \x1b[32m${passed} passed\x1b[0m` + (failed ? `, \x1b[31m${failed} failed\x1b[0m` : ''));
329
- process.exit(failed > 0 ? 1 : 0);