deepflow 0.1.89 → 0.1.91
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 +1 -7
- package/bin/install.js +17 -17
- package/bin/install.test.js +697 -0
- package/bin/ratchet.js +327 -0
- package/bin/ratchet.test.js +869 -0
- package/hooks/df-snapshot-guard.js +105 -0
- package/hooks/df-snapshot-guard.test.js +506 -0
- package/package.json +1 -1
- package/src/commands/df/auto-cycle.md +1 -143
- package/src/commands/df/auto.md +1 -1
- package/src/commands/df/execute.md +53 -26
- package/src/commands/df/verify.md +38 -8
- package/src/skills/auto-cycle/SKILL.md +148 -0
- package/templates/config-template.yaml +26 -3
- package/hooks/df-consolidation-check.js +0 -67
- package/src/commands/df/consolidate.md +0 -42
- package/src/commands/df/note.md +0 -73
- package/src/commands/df/report.md +0 -75
- package/src/commands/df/resume.md +0 -47
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deepflow snapshot guard
|
|
4
|
+
* PostToolUse hook: blocks Write/Edit to files listed in .deepflow/auto-snapshot.txt.
|
|
5
|
+
*
|
|
6
|
+
* REQ-3 AC-3: exit(1) to block the tool call when all conditions hold:
|
|
7
|
+
* 1. tool_name is Write or Edit
|
|
8
|
+
* 2. file_path matches an entry in .deepflow/auto-snapshot.txt
|
|
9
|
+
*
|
|
10
|
+
* Physical barrier independent of prompt instructions — prevents agents from
|
|
11
|
+
* modifying pre-existing test files that are part of the ratchet baseline.
|
|
12
|
+
*
|
|
13
|
+
* Exits silently (code 0) on parse errors or missing snapshot file — never breaks
|
|
14
|
+
* tool execution in non-deepflow projects.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
function loadSnapshotPaths(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const snapshotPath = path.join(cwd, '.deepflow', 'auto-snapshot.txt');
|
|
25
|
+
if (!fs.existsSync(snapshotPath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const content = fs.readFileSync(snapshotPath, 'utf8');
|
|
29
|
+
const lines = content.split('\n')
|
|
30
|
+
.map(l => l.trim())
|
|
31
|
+
.filter(l => l.length > 0 && !l.startsWith('#'));
|
|
32
|
+
return lines;
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSnapshotFile(filePath, snapshotPaths, cwd) {
|
|
39
|
+
// Normalize filePath: resolve relative to cwd if not absolute
|
|
40
|
+
const absFilePath = path.isAbsolute(filePath)
|
|
41
|
+
? filePath
|
|
42
|
+
: path.resolve(cwd, filePath);
|
|
43
|
+
|
|
44
|
+
for (const entry of snapshotPaths) {
|
|
45
|
+
// Snapshot entries may be absolute or relative to cwd
|
|
46
|
+
const absEntry = path.isAbsolute(entry)
|
|
47
|
+
? entry
|
|
48
|
+
: path.resolve(cwd, entry);
|
|
49
|
+
|
|
50
|
+
if (absFilePath === absEntry) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let raw = '';
|
|
58
|
+
process.stdin.setEncoding('utf8');
|
|
59
|
+
process.stdin.on('data', chunk => { raw += chunk; });
|
|
60
|
+
process.stdin.on('end', () => {
|
|
61
|
+
try {
|
|
62
|
+
const data = JSON.parse(raw);
|
|
63
|
+
const toolName = data.tool_name || '';
|
|
64
|
+
|
|
65
|
+
// Only guard Write and Edit
|
|
66
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const filePath = (data.tool_input && data.tool_input.file_path) || '';
|
|
71
|
+
const cwd = data.cwd || process.cwd();
|
|
72
|
+
|
|
73
|
+
if (!filePath) {
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapshotPaths = loadSnapshotPaths(cwd);
|
|
78
|
+
|
|
79
|
+
// No snapshot file present — not a deepflow project or ratchet not initialized
|
|
80
|
+
if (snapshotPaths === null) {
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Empty snapshot — nothing to protect
|
|
85
|
+
if (snapshotPaths.length === 0) {
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!isSnapshotFile(filePath, snapshotPaths, cwd)) {
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// File is in the snapshot — block the write
|
|
94
|
+
console.error(
|
|
95
|
+
`[df-snapshot-guard] Blocked ${toolName} to "${filePath}" — this file is listed in ` +
|
|
96
|
+
`.deepflow/auto-snapshot.txt (ratchet baseline). ` +
|
|
97
|
+
`Pre-existing test files must not be modified by agents. ` +
|
|
98
|
+
`If you need to update this file, do so manually outside the autonomous loop.`
|
|
99
|
+
);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
} catch (_e) {
|
|
102
|
+
// Parse or unexpected error — fail open so we never break non-deepflow projects
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for hooks/df-snapshot-guard.js
|
|
3
|
+
*
|
|
4
|
+
* Tests the PostToolUse hook that blocks Write/Edit to files listed in
|
|
5
|
+
* .deepflow/auto-snapshot.txt (ratchet baseline protection).
|
|
6
|
+
*
|
|
7
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const { execFileSync } = require('node:child_process');
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
const HOOK_PATH = path.resolve(__dirname, 'df-snapshot-guard.js');
|
|
24
|
+
|
|
25
|
+
function makeTmpDir() {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-snapshot-guard-test-'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rmrf(dir) {
|
|
30
|
+
if (fs.existsSync(dir)) {
|
|
31
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run the snapshot guard hook as a child process with JSON piped to stdin.
|
|
37
|
+
* Returns { stdout, stderr, code }.
|
|
38
|
+
*/
|
|
39
|
+
function runHook(input, { cwd } = {}) {
|
|
40
|
+
const json = JSON.stringify(input);
|
|
41
|
+
try {
|
|
42
|
+
const stdout = execFileSync(
|
|
43
|
+
process.execPath,
|
|
44
|
+
[HOOK_PATH],
|
|
45
|
+
{
|
|
46
|
+
input: json,
|
|
47
|
+
cwd: cwd || os.tmpdir(),
|
|
48
|
+
encoding: 'utf8',
|
|
49
|
+
timeout: 5000,
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
return { stdout, stderr: '', code: 0 };
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return {
|
|
55
|
+
stdout: err.stdout || '',
|
|
56
|
+
stderr: err.stderr || '',
|
|
57
|
+
code: err.status ?? 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create .deepflow/auto-snapshot.txt in the given directory with the specified entries.
|
|
64
|
+
*/
|
|
65
|
+
function writeSnapshot(dir, entries) {
|
|
66
|
+
const deepflowDir = path.join(dir, '.deepflow');
|
|
67
|
+
fs.mkdirSync(deepflowDir, { recursive: true });
|
|
68
|
+
fs.writeFileSync(path.join(deepflowDir, 'auto-snapshot.txt'), entries.join('\n'));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// 1. Pass-through cases (exit 0)
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
describe('df-snapshot-guard — pass-through (exit 0)', () => {
|
|
76
|
+
let tmpDir;
|
|
77
|
+
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
tmpDir = makeTmpDir();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterEach(() => {
|
|
83
|
+
rmrf(tmpDir);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('exits 0 for non-Write/Edit tools (e.g. Read)', () => {
|
|
87
|
+
writeSnapshot(tmpDir, ['src/app.js']);
|
|
88
|
+
const result = runHook({
|
|
89
|
+
tool_name: 'Read',
|
|
90
|
+
tool_input: { file_path: path.join(tmpDir, 'src/app.js') },
|
|
91
|
+
cwd: tmpDir,
|
|
92
|
+
});
|
|
93
|
+
assert.equal(result.code, 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('exits 0 for Bash tool', () => {
|
|
97
|
+
writeSnapshot(tmpDir, ['src/app.js']);
|
|
98
|
+
const result = runHook({
|
|
99
|
+
tool_name: 'Bash',
|
|
100
|
+
tool_input: { command: 'echo hello' },
|
|
101
|
+
cwd: tmpDir,
|
|
102
|
+
});
|
|
103
|
+
assert.equal(result.code, 0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('exits 0 when snapshot file does not exist', () => {
|
|
107
|
+
// No .deepflow/auto-snapshot.txt created
|
|
108
|
+
const result = runHook({
|
|
109
|
+
tool_name: 'Write',
|
|
110
|
+
tool_input: { file_path: path.join(tmpDir, 'test.js') },
|
|
111
|
+
cwd: tmpDir,
|
|
112
|
+
});
|
|
113
|
+
assert.equal(result.code, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('exits 0 when snapshot file is empty', () => {
|
|
117
|
+
writeSnapshot(tmpDir, []);
|
|
118
|
+
const result = runHook({
|
|
119
|
+
tool_name: 'Write',
|
|
120
|
+
tool_input: { file_path: path.join(tmpDir, 'test.js') },
|
|
121
|
+
cwd: tmpDir,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(result.code, 0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('exits 0 when snapshot contains only comments and blank lines', () => {
|
|
127
|
+
const deepflowDir = path.join(tmpDir, '.deepflow');
|
|
128
|
+
fs.mkdirSync(deepflowDir, { recursive: true });
|
|
129
|
+
fs.writeFileSync(
|
|
130
|
+
path.join(deepflowDir, 'auto-snapshot.txt'),
|
|
131
|
+
'# This is a comment\n\n# Another comment\n \n'
|
|
132
|
+
);
|
|
133
|
+
const result = runHook({
|
|
134
|
+
tool_name: 'Write',
|
|
135
|
+
tool_input: { file_path: path.join(tmpDir, 'test.js') },
|
|
136
|
+
cwd: tmpDir,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(result.code, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('exits 0 when file_path does not match any snapshot entry', () => {
|
|
142
|
+
writeSnapshot(tmpDir, ['bin/install.test.js', 'test/integration.test.js']);
|
|
143
|
+
const result = runHook({
|
|
144
|
+
tool_name: 'Write',
|
|
145
|
+
tool_input: { file_path: path.join(tmpDir, 'src/new-file.js') },
|
|
146
|
+
cwd: tmpDir,
|
|
147
|
+
});
|
|
148
|
+
assert.equal(result.code, 0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('exits 0 when file_path is empty string', () => {
|
|
152
|
+
writeSnapshot(tmpDir, ['test.js']);
|
|
153
|
+
const result = runHook({
|
|
154
|
+
tool_name: 'Edit',
|
|
155
|
+
tool_input: { file_path: '' },
|
|
156
|
+
cwd: tmpDir,
|
|
157
|
+
});
|
|
158
|
+
assert.equal(result.code, 0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('exits 0 when tool_input is missing file_path', () => {
|
|
162
|
+
writeSnapshot(tmpDir, ['test.js']);
|
|
163
|
+
const result = runHook({
|
|
164
|
+
tool_name: 'Write',
|
|
165
|
+
tool_input: {},
|
|
166
|
+
cwd: tmpDir,
|
|
167
|
+
});
|
|
168
|
+
assert.equal(result.code, 0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('exits 0 on invalid JSON input (fail open)', () => {
|
|
172
|
+
// Send raw invalid JSON via child process
|
|
173
|
+
try {
|
|
174
|
+
execFileSync(process.execPath, [HOOK_PATH], {
|
|
175
|
+
input: 'not valid json{{{',
|
|
176
|
+
encoding: 'utf8',
|
|
177
|
+
timeout: 5000,
|
|
178
|
+
});
|
|
179
|
+
// exit 0 — pass
|
|
180
|
+
} catch (err) {
|
|
181
|
+
assert.fail(`Hook should exit 0 on parse error but got exit code ${err.status}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('exits 0 when tool_name is missing', () => {
|
|
186
|
+
writeSnapshot(tmpDir, ['test.js']);
|
|
187
|
+
const result = runHook({
|
|
188
|
+
tool_input: { file_path: path.join(tmpDir, 'test.js') },
|
|
189
|
+
cwd: tmpDir,
|
|
190
|
+
});
|
|
191
|
+
assert.equal(result.code, 0);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// 2. Blocking cases (exit 1)
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
describe('df-snapshot-guard — blocks protected files (exit 1)', () => {
|
|
200
|
+
let tmpDir;
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
tmpDir = makeTmpDir();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
afterEach(() => {
|
|
207
|
+
rmrf(tmpDir);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('blocks Write to a file listed in snapshot (absolute path)', () => {
|
|
211
|
+
const protectedFile = path.join(tmpDir, 'bin', 'install.test.js');
|
|
212
|
+
writeSnapshot(tmpDir, [protectedFile]);
|
|
213
|
+
const result = runHook({
|
|
214
|
+
tool_name: 'Write',
|
|
215
|
+
tool_input: { file_path: protectedFile },
|
|
216
|
+
cwd: tmpDir,
|
|
217
|
+
});
|
|
218
|
+
assert.equal(result.code, 1);
|
|
219
|
+
assert.ok(result.stderr.includes('df-snapshot-guard'));
|
|
220
|
+
assert.ok(result.stderr.includes('Blocked'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('blocks Edit to a file listed in snapshot (absolute path)', () => {
|
|
224
|
+
const protectedFile = path.join(tmpDir, 'test', 'integration.test.js');
|
|
225
|
+
writeSnapshot(tmpDir, [protectedFile]);
|
|
226
|
+
const result = runHook({
|
|
227
|
+
tool_name: 'Edit',
|
|
228
|
+
tool_input: { file_path: protectedFile },
|
|
229
|
+
cwd: tmpDir,
|
|
230
|
+
});
|
|
231
|
+
assert.equal(result.code, 1);
|
|
232
|
+
assert.ok(result.stderr.includes('Blocked'));
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('blocks Write to a file listed as relative path in snapshot', () => {
|
|
236
|
+
writeSnapshot(tmpDir, ['bin/install.test.js']);
|
|
237
|
+
const result = runHook({
|
|
238
|
+
tool_name: 'Write',
|
|
239
|
+
tool_input: { file_path: path.join(tmpDir, 'bin', 'install.test.js') },
|
|
240
|
+
cwd: tmpDir,
|
|
241
|
+
});
|
|
242
|
+
assert.equal(result.code, 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('blocks when file_path is relative and snapshot entry is relative', () => {
|
|
246
|
+
writeSnapshot(tmpDir, ['test/foo.test.js']);
|
|
247
|
+
const result = runHook({
|
|
248
|
+
tool_name: 'Edit',
|
|
249
|
+
tool_input: { file_path: 'test/foo.test.js' },
|
|
250
|
+
cwd: tmpDir,
|
|
251
|
+
});
|
|
252
|
+
assert.equal(result.code, 1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('blocks when file_path is absolute and snapshot entry is relative', () => {
|
|
256
|
+
writeSnapshot(tmpDir, ['src/helper.test.js']);
|
|
257
|
+
const absPath = path.join(tmpDir, 'src', 'helper.test.js');
|
|
258
|
+
const result = runHook({
|
|
259
|
+
tool_name: 'Write',
|
|
260
|
+
tool_input: { file_path: absPath },
|
|
261
|
+
cwd: tmpDir,
|
|
262
|
+
});
|
|
263
|
+
assert.equal(result.code, 1);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('blocks when file_path is relative and snapshot entry is absolute', () => {
|
|
267
|
+
const absEntry = path.join(tmpDir, 'lib', 'core.test.js');
|
|
268
|
+
writeSnapshot(tmpDir, [absEntry]);
|
|
269
|
+
const result = runHook({
|
|
270
|
+
tool_name: 'Edit',
|
|
271
|
+
tool_input: { file_path: 'lib/core.test.js' },
|
|
272
|
+
cwd: tmpDir,
|
|
273
|
+
});
|
|
274
|
+
assert.equal(result.code, 1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('stderr message includes file path and ratchet explanation', () => {
|
|
278
|
+
writeSnapshot(tmpDir, ['test/unit.test.js']);
|
|
279
|
+
const result = runHook({
|
|
280
|
+
tool_name: 'Write',
|
|
281
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'unit.test.js') },
|
|
282
|
+
cwd: tmpDir,
|
|
283
|
+
});
|
|
284
|
+
assert.equal(result.code, 1);
|
|
285
|
+
assert.ok(result.stderr.includes('ratchet baseline'));
|
|
286
|
+
assert.ok(result.stderr.includes('auto-snapshot.txt'));
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('blocks only matching file among multiple snapshot entries', () => {
|
|
290
|
+
writeSnapshot(tmpDir, [
|
|
291
|
+
'test/a.test.js',
|
|
292
|
+
'test/b.test.js',
|
|
293
|
+
'test/c.test.js',
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
// Writing to b.test.js should be blocked
|
|
297
|
+
const resultBlocked = runHook({
|
|
298
|
+
tool_name: 'Write',
|
|
299
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'b.test.js') },
|
|
300
|
+
cwd: tmpDir,
|
|
301
|
+
});
|
|
302
|
+
assert.equal(resultBlocked.code, 1);
|
|
303
|
+
|
|
304
|
+
// Writing to d.test.js should pass through
|
|
305
|
+
const resultAllowed = runHook({
|
|
306
|
+
tool_name: 'Write',
|
|
307
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'd.test.js') },
|
|
308
|
+
cwd: tmpDir,
|
|
309
|
+
});
|
|
310
|
+
assert.equal(resultAllowed.code, 0);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// 3. Edge cases
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('df-snapshot-guard — edge cases', () => {
|
|
319
|
+
let tmpDir;
|
|
320
|
+
|
|
321
|
+
beforeEach(() => {
|
|
322
|
+
tmpDir = makeTmpDir();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
afterEach(() => {
|
|
326
|
+
rmrf(tmpDir);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('snapshot with comment lines ignores comments', () => {
|
|
330
|
+
const deepflowDir = path.join(tmpDir, '.deepflow');
|
|
331
|
+
fs.mkdirSync(deepflowDir, { recursive: true });
|
|
332
|
+
fs.writeFileSync(
|
|
333
|
+
path.join(deepflowDir, 'auto-snapshot.txt'),
|
|
334
|
+
'# Header comment\ntest/real.test.js\n# Another comment\n'
|
|
335
|
+
);
|
|
336
|
+
const result = runHook({
|
|
337
|
+
tool_name: 'Write',
|
|
338
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'real.test.js') },
|
|
339
|
+
cwd: tmpDir,
|
|
340
|
+
});
|
|
341
|
+
assert.equal(result.code, 1);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('snapshot entries with leading/trailing whitespace are trimmed', () => {
|
|
345
|
+
const deepflowDir = path.join(tmpDir, '.deepflow');
|
|
346
|
+
fs.mkdirSync(deepflowDir, { recursive: true });
|
|
347
|
+
fs.writeFileSync(
|
|
348
|
+
path.join(deepflowDir, 'auto-snapshot.txt'),
|
|
349
|
+
' test/spaced.test.js \n'
|
|
350
|
+
);
|
|
351
|
+
const result = runHook({
|
|
352
|
+
tool_name: 'Edit',
|
|
353
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'spaced.test.js') },
|
|
354
|
+
cwd: tmpDir,
|
|
355
|
+
});
|
|
356
|
+
assert.equal(result.code, 1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('uses process.cwd() when cwd is not in input', () => {
|
|
360
|
+
// When cwd is not provided in JSON, hook falls back to process.cwd()
|
|
361
|
+
// We can't easily control process.cwd() in the child, but we can verify
|
|
362
|
+
// it doesn't crash — the snapshot won't exist in the child's cwd so exit 0
|
|
363
|
+
const result = runHook({
|
|
364
|
+
tool_name: 'Write',
|
|
365
|
+
tool_input: { file_path: '/some/random/file.js' },
|
|
366
|
+
});
|
|
367
|
+
assert.equal(result.code, 0);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test('handles snapshot file with only one entry', () => {
|
|
371
|
+
writeSnapshot(tmpDir, ['single.js']);
|
|
372
|
+
const blocked = runHook({
|
|
373
|
+
tool_name: 'Write',
|
|
374
|
+
tool_input: { file_path: path.join(tmpDir, 'single.js') },
|
|
375
|
+
cwd: tmpDir,
|
|
376
|
+
});
|
|
377
|
+
assert.equal(blocked.code, 1);
|
|
378
|
+
|
|
379
|
+
const allowed = runHook({
|
|
380
|
+
tool_name: 'Write',
|
|
381
|
+
tool_input: { file_path: path.join(tmpDir, 'other.js') },
|
|
382
|
+
cwd: tmpDir,
|
|
383
|
+
});
|
|
384
|
+
assert.equal(allowed.code, 0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('does not block similarly named but different files', () => {
|
|
388
|
+
writeSnapshot(tmpDir, ['test/foo.test.js']);
|
|
389
|
+
// foo.test.jsx is NOT the same as foo.test.js
|
|
390
|
+
const result = runHook({
|
|
391
|
+
tool_name: 'Write',
|
|
392
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'foo.test.jsx') },
|
|
393
|
+
cwd: tmpDir,
|
|
394
|
+
});
|
|
395
|
+
assert.equal(result.code, 0);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('does not block a parent directory of a snapshot entry', () => {
|
|
399
|
+
writeSnapshot(tmpDir, ['test/sub/deep.test.js']);
|
|
400
|
+
const result = runHook({
|
|
401
|
+
tool_name: 'Write',
|
|
402
|
+
tool_input: { file_path: path.join(tmpDir, 'test', 'sub') },
|
|
403
|
+
cwd: tmpDir,
|
|
404
|
+
});
|
|
405
|
+
assert.equal(result.code, 0);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// 4. install.js integration — snapshot guard hook registration
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
describe('install.js — snapshot guard hook registration', () => {
|
|
414
|
+
const installSrc = fs.readFileSync(
|
|
415
|
+
path.resolve(__dirname, '..', 'bin', 'install.js'),
|
|
416
|
+
'utf8'
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
test('defines snapshotGuardCmd variable', () => {
|
|
420
|
+
assert.ok(
|
|
421
|
+
installSrc.includes('snapshotGuardCmd'),
|
|
422
|
+
'install.js should define snapshotGuardCmd variable'
|
|
423
|
+
);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('snapshotGuardCmd references df-snapshot-guard.js', () => {
|
|
427
|
+
assert.match(
|
|
428
|
+
installSrc,
|
|
429
|
+
/snapshotGuardCmd\s*=\s*`node.*df-snapshot-guard\.js/,
|
|
430
|
+
'snapshotGuardCmd should reference df-snapshot-guard.js'
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test('pushes snapshot guard to PostToolUse hooks', () => {
|
|
435
|
+
// Verify there is a .push() call that includes snapshotGuardCmd
|
|
436
|
+
assert.match(
|
|
437
|
+
installSrc,
|
|
438
|
+
/PostToolUse\.push\(\{[\s\S]*?snapshotGuardCmd[\s\S]*?\}\)/,
|
|
439
|
+
'install.js should push snapshotGuardCmd to PostToolUse'
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test('PostToolUse filter includes df-snapshot-guard removal', () => {
|
|
444
|
+
// The filter should clean up existing snapshot guard hooks before re-adding
|
|
445
|
+
assert.ok(
|
|
446
|
+
installSrc.includes("df-snapshot-guard"),
|
|
447
|
+
'PostToolUse filter should reference df-snapshot-guard for cleanup'
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('uninstall toRemove includes df-snapshot-guard.js', () => {
|
|
452
|
+
assert.ok(
|
|
453
|
+
installSrc.includes("'hooks/df-snapshot-guard.js'"),
|
|
454
|
+
'Uninstall toRemove should include hooks/df-snapshot-guard.js'
|
|
455
|
+
);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('uninstall PostToolUse filter removes df-snapshot-guard', () => {
|
|
459
|
+
// Find the uninstall section's PostToolUse filter
|
|
460
|
+
// It should include df-snapshot-guard in the filter pattern
|
|
461
|
+
const uninstallSection = installSrc.slice(installSrc.indexOf('async function uninstall'));
|
|
462
|
+
assert.ok(
|
|
463
|
+
uninstallSection.includes('df-snapshot-guard'),
|
|
464
|
+
'Uninstall PostToolUse filter should include df-snapshot-guard'
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('PostToolUse cleanup removes snapshot guard and keeps custom hooks', () => {
|
|
469
|
+
const postToolUse = [
|
|
470
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-tool-usage.js' }] },
|
|
471
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-snapshot-guard.js' }] },
|
|
472
|
+
{ hooks: [{ type: 'command', command: 'node /usr/local/my-custom-hook.js' }] },
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
const filtered = postToolUse.filter(hook => {
|
|
476
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
477
|
+
return !cmd.includes('df-tool-usage') &&
|
|
478
|
+
!cmd.includes('df-execution-history') &&
|
|
479
|
+
!cmd.includes('df-worktree-guard') &&
|
|
480
|
+
!cmd.includes('df-snapshot-guard') &&
|
|
481
|
+
!cmd.includes('df-invariant-check');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
assert.equal(filtered.length, 1);
|
|
485
|
+
assert.ok(filtered[0].hooks[0].command.includes('my-custom-hook.js'));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('filterSessionStart does NOT remove snapshot guard (it is PostToolUse only)', () => {
|
|
489
|
+
// Reproduce the SessionStart filter logic — snapshot guard should not appear
|
|
490
|
+
function filterSessionStart(hooks) {
|
|
491
|
+
return hooks.filter(hook => {
|
|
492
|
+
const cmd = hook.hooks?.[0]?.command || '';
|
|
493
|
+
return !cmd.includes('df-check-update') &&
|
|
494
|
+
!cmd.includes('df-consolidation-check') &&
|
|
495
|
+
!cmd.includes('df-quota-logger');
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const hooks = [
|
|
500
|
+
{ hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-snapshot-guard.js' }] },
|
|
501
|
+
];
|
|
502
|
+
|
|
503
|
+
const filtered = filterSessionStart(hooks);
|
|
504
|
+
assert.equal(filtered.length, 1, 'SessionStart filter should NOT remove snapshot guard hooks');
|
|
505
|
+
});
|
|
506
|
+
});
|