@kendroger/io-snapshot 1.0.0

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.
@@ -0,0 +1,5 @@
1
+ {
2
+ "port": 9444,
3
+ "timeout": 15,
4
+ "exclude": ["node_modules", "dist", "*.snap.bak"]
5
+ }
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # 📸 io-snapshot
2
+
3
+ `io-snapshot` is a powerful behavior-preservation tool designed for zero-regression refactoring. It captures the exact inputs and outputs of your functions during real-world execution and allows you to "replay" them later to ensure that your structural changes haven't introduced behavioral drift.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/io-snapshot.svg)](https://www.npmjs.com/package/io-snapshot)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ## 🚀 Why io-snapshot?
9
+
10
+ Unit tests verify what you *expect* to happen. `io-snapshot` verifies what *actually happens* in your application. It acts as a safety net for:
11
+
12
+ - **Major Refactors:** Switching from Promises to `async/await` or restructuring complex logic.
13
+ - **Dependency Swaps:** Replacing one library with another while maintaining the same interface.
14
+ - **Performance Tuning:** Ensuring that optimizations don't break edge cases or return values.
15
+ - **Legacy Code:** Safely refactoring codebases that lack traditional test coverage.
16
+
17
+ ## 📦 Installation
18
+
19
+ Install globally or as a dev dependency:
20
+
21
+ ```bash
22
+ # Global installation
23
+ npm install -g io-snapshot
24
+
25
+ # Local installation
26
+ npm install --save-dev io-snapshot
27
+ ```
28
+
29
+ ## 🛠 Workflow
30
+
31
+ `io-snapshot` follows a simple 4-step workflow:
32
+
33
+ ### 1. Record
34
+ Inject the recorder into your target files and start capturing snapshots while you use your application.
35
+
36
+ ```bash
37
+ io-snapshot record ./src/services/*.ts
38
+ ```
39
+ *Wait for the "Recording started" message, then start and interact with your app.*
40
+
41
+ ### 2. Stop & Restore
42
+ Once you've captured enough data, stop the recording. This restores your original source code but preserves the snapshots in `.snaps.jsonl`.
43
+
44
+ ```bash
45
+ io-snapshot stop
46
+ ```
47
+
48
+ ### 3. Refactor
49
+ Modify your code, optimize your functions, or swap dependencies. As long as the function name and exported interface remain the same, you're good to go.
50
+
51
+ ### 4. Verify
52
+ Run the test command to replay the captured inputs against your new code and compare the outputs.
53
+
54
+ ```bash
55
+ io-snapshot test ./src/services/*.ts
56
+ ```
57
+
58
+ ## ⌨️ Command Reference
59
+
60
+ | Command | Description |
61
+ | :--- | :--- |
62
+ | `record [target]` | Injects recorder, starts daemon, and begins capturing snapshots. |
63
+ | `stop` | Stops the daemon and restores original source code. |
64
+ | `test [target]` | Replays snapshots against current code and reports any drift. |
65
+ | `clean [target]` | Restores original files and deletes the snapshot data. |
66
+ | `inject [target]` | Explicitly injects the recorder without starting the daemon. |
67
+
68
+ ## ⚙️ Configuration
69
+
70
+ You can configure `io-snapshot` via a `.iosnapshotrc.json` file in your project root.
71
+
72
+ **File Locations:**
73
+ - **`.snaps.jsonl`**: Stores your recorded snapshots directly in your project's root directory.
74
+ - **Temporary Session Files**: Files like the daemon's PID (`.io-snapshot.pid`) and the injected recorder code (`.io-snapshot/recorder.js`) are stored in a unique, project-specific directory within your **operating system's temporary folder**. This ensures a clean project root and prevents conflicts.
75
+ - **Backup Files**:
76
+ - **Primary Backups**: Created in the OS temporary folder alongside other temporary session files.
77
+ - **Fallback Backups**: Created in a `.io-snapshot-local-backups/` directory in your project's root for persistence against OS temp folder cleanup.
78
+
79
+ ```json
80
+ {
81
+ "port": 9444,
82
+ "timeout": 30,
83
+ "exclude": ["node_modules/**", "test/**"]
84
+ }
85
+ ```
86
+
87
+ ## 🤔 Troubleshooting
88
+
89
+ Here are some common issues and how to solve them in simple terms.
90
+
91
+ | Problem | Solution |
92
+ | :--- | :--- |
93
+ | **`"io-snapshot: command not found"`** | This usually means the tool wasn't installed correctly. Try running `npm install -g io-snapshot` again. If you installed it locally (`--save-dev`), you'll need to run it through an npm script. |
94
+ | **`"io-snapshot is already running!"`** | You have a previous session that wasn't stopped. Run `io-snapshot stop` to end it, and then you can start a new recording. |
95
+ | **No snapshots are being recorded.** | 1. Make sure you are running your application *after* `io-snapshot record` says "Recording started." <br> 2. Check that the functions you want to record are **exported** from their files. <br> 3. Make sure you are interacting with the parts of your app that use those functions. |
96
+ | **`"EADDRINUSE: address already in use"`** | The port `io-snapshot` wants to use (default: 9444) is occupied. You can either stop the other program or tell `io-snapshot` to use a different port with the `-p` flag: `io-snapshot record -p 9445` |
97
+ | **Tests are passing, but I know the logic is different.**| `io-snapshot` checks if the final *output* is the same for a given *input*. If your refactor produces the same result (e.g., changing a `for` loop to a `.map()`), `io-snapshot` will correctly report no change in behavior. It only cares about the "what," not the "how." |
98
+
99
+ ## 🔍 How it Works
100
+
101
+ 1. **Instrumentation:** It uses `ts-morph` and `Babel` to wrap your exported functions with a Proxy.
102
+ 2. **Backup:** Before injecting, `io-snapshot` creates two backups of your original files:
103
+ - A **primary backup** in a temporary directory managed by your operating system.
104
+ - A **fallback backup** in a `.io-snapshot-local-backups/` directory within your project's root.
105
+ This ensures that even if OS temporary files are cleared, your original code can still be restored.
106
+ 3. **Capture:** When the wrapped functions are called, the inputs and outputs are sent to a local background daemon.
107
+ 4. **Storage:** Snapshots are serialized using `SuperJSON` (to preserve complex types like Dates and RegEx) and stored in a newline-delimited JSON file (`.snaps.jsonl`) in your project root.
108
+ 5. **Verification:** The test runner imports your modified functions and feeds them the exact arguments from the snapshots, then performs a deep-diff on the results. When restoring, it first attempts to use the primary backup, falling back to the local backup if necessary.
109
+
110
+ ## ⚠️ Requirements & Limitations
111
+
112
+ - Functions must be **exported** to be captured.
113
+ - Data must be **serializable** (SuperJSON handles many complex types, but extremely complex circular references or native handles might be tricky).
114
+ - Currently supports **ES Modules (ESM)** projects.
115
+
116
+ ## 📄 License
117
+
118
+ MIT © [kendroger](https://github.com/kendroger)
package/bin/cli.js ADDED
@@ -0,0 +1,254 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import { spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import { fileURLToPath } from 'url';
7
+ import http from 'http';
8
+ import { SNAPSHOT_FILE } from '../lib/constants.js';
9
+ import { loadConfig, validatePort, validateTimeout, validateFilePattern } from '../lib/config.js';
10
+ import { log } from '../lib/logger.js';
11
+ import { getPidFilePath, ensureTempSessionDir } from '../lib/paths.js';
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+
15
+ function savePid(pid) {
16
+ const pidPath = getPidFilePath();
17
+ ensureTempSessionDir(); // Ensure the temp directory exists before saving PID
18
+ fs.writeFileSync(pidPath, String(pid));
19
+ }
20
+
21
+ function getSavedPid() {
22
+ const pidPath = getPidFilePath();
23
+ if (fs.existsSync(pidPath)) {
24
+ return parseInt(fs.readFileSync(pidPath, 'utf8'), 10);
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function removePid() {
30
+ const pidPath = getPidFilePath();
31
+ if (fs.existsSync(pidPath)) {
32
+ fs.unlinkSync(pidPath);
33
+ }
34
+ }
35
+
36
+ async function daemonRequest(endpoint, port, method = 'POST') {
37
+ return new Promise((resolve, reject) => {
38
+ const req = http.request(`http://localhost:${port}${endpoint}`, { method }, (res) => {
39
+ let data = '';
40
+ res.on('data', chunk => data += chunk);
41
+ res.on('end', () => {
42
+ try {
43
+ resolve(JSON.parse(data));
44
+ } catch {
45
+ resolve(data);
46
+ }
47
+ });
48
+ });
49
+ req.on('error', reject);
50
+ req.end();
51
+ });
52
+ }
53
+
54
+ program
55
+ .name('io-snapshot')
56
+ .description('Capture and compare function behavior snapshots for zero-regression refactoring');
57
+
58
+ program
59
+ .command('inject [target]')
60
+ .description('Inject recorder into target files (for explicit use)')
61
+ .option('-f, --force', 'Force re-inject even if already injected')
62
+ .action(async (target, opts) => {
63
+ try {
64
+ if (target) validateFilePattern(target);
65
+ } catch (error) {
66
+ log.error(error.message);
67
+ process.exit(1);
68
+ }
69
+
70
+ const { injectRecorder } = await import('../lib/transformer.js');
71
+ await injectRecorder(target, opts.force);
72
+ });
73
+
74
+ program
75
+ .command('record [target]')
76
+ .description('Inject, start daemon, and begin recording snapshots')
77
+ .option('-p, --port <port>', 'Port to run the daemon on')
78
+ .option('-t, --timeout <minutes>', 'Auto-shutdown after N minutes of inactivity')
79
+ .option('-f, --force', 'Force re-inject even if already injected')
80
+ .action(async (target, opts) => {
81
+ try {
82
+ if (opts.port) validatePort(opts.port);
83
+ if (opts.timeout) validateTimeout(opts.timeout);
84
+ if (target) validateFilePattern(target);
85
+ } catch (error) {
86
+ log.error(error.message);
87
+ process.exit(1);
88
+ }
89
+
90
+ const config = loadConfig();
91
+ const port = opts.port || config.port || 9444;
92
+ const timeout = config.timeout || 30; // Use config.timeout directly, as opts.timeout is used above
93
+
94
+ log.divider('IMPORTANT');
95
+ log.info('Run this command FIRST,');
96
+ log.info(' THEN start your app!');
97
+ log.divider();
98
+
99
+ log.workflow([
100
+ 'io-snapshot record → Inject code + start daemon + begin recording',
101
+ 'npm run dev → Start your app (AFTER recording has started)',
102
+ 'Interact with app → Use your app to capture snapshots',
103
+ 'io-snapshot stop → Stop recording, restore original code',
104
+ 'Modify your code → Refactor/change your functions',
105
+ 'io-snapshot test → Verify changes against recorded snapshots'
106
+ ]);
107
+
108
+ const snapshotPath = path.resolve(process.cwd(), SNAPSHOT_FILE);
109
+ if (fs.existsSync(snapshotPath)) {
110
+ fs.unlinkSync(snapshotPath);
111
+ log.info('Cleared previous snapshot file.');
112
+ }
113
+
114
+ log.info('Checking for existing io-snapshot session...');
115
+ const savedPid = getSavedPid();
116
+ if (savedPid) {
117
+ try {
118
+ process.kill(savedPid, 0);
119
+ log.divider();
120
+ log.warn('io-snapshot is already running!');
121
+ log.warn(`A daemon is active (PID: ${savedPid})`);
122
+ log.warn('Please run "io-snapshot stop" first before starting a new session.');
123
+ log.divider();
124
+ process.exit(1);
125
+ } catch {
126
+ // Process not running, clean up stale PID
127
+ removePid();
128
+ }
129
+ }
130
+
131
+ log.step(1, 'Injecting recorder into files...');
132
+ const { injectRecorder } = await import('../lib/transformer.js');
133
+ await injectRecorder(target, opts.force);
134
+
135
+ log.step(2, 'Starting daemon...');
136
+ const env = {
137
+ ...process.env,
138
+ IOSNAP_DAEMON_PORT: String(port),
139
+ IOSNAP_DAEMON_CORS: '*'
140
+ };
141
+
142
+ const child = spawn(process.execPath, [path.join(__dirname, '../lib/daemon.js')], {
143
+ cwd: process.cwd(),
144
+ stdio: 'ignore',
145
+ env,
146
+ detached: true
147
+ });
148
+
149
+ child.unref();
150
+ savePid(child.pid);
151
+ log.success(`Daemon started on port ${port} (PID: ${child.pid})`);
152
+
153
+ await new Promise(resolve => setTimeout(resolve, 1000));
154
+
155
+ log.step(3, 'Starting recording...');
156
+
157
+ try {
158
+ await daemonRequest('/record', port);
159
+ log.success('Recording started.');
160
+ log.info(`Snapshots will be saved to: ${snapshotPath}`);
161
+ log.divider();
162
+ log.success('NOW START YOUR APP (npm run dev)');
163
+ log.info('Interact with your app to capture snapshots.');
164
+ log.info('Run "io-snapshot stop" when done to stop recording and restore original code.');
165
+ log.divider();
166
+ } catch (error) {
167
+ log.error(`Failed to start recording: ${error.message}`);
168
+ process.exit(1);
169
+ }
170
+ });
171
+
172
+ program
173
+ .command('stop')
174
+ .description('Stop recording, stop daemon, and restore original code (snapshots preserved)')
175
+ .option('-p, --port <port>', 'Port where the daemon is running')
176
+ .action(async (opts) => {
177
+ try {
178
+ if (opts.port) validatePort(opts.port);
179
+ } catch (error) {
180
+ log.error(error.message);
181
+ process.exit(1);
182
+ }
183
+
184
+ const config = loadConfig();
185
+ const port = opts.port || config.port || 9444;
186
+
187
+ log.step(1, 'Stopping recording...');
188
+ try {
189
+ await daemonRequest('/stop', port);
190
+ log.success('Recording stopped.');
191
+ } catch (error) {
192
+ log.warn(`Daemon not responding: ${error.message}. May already be stopped.`);
193
+ }
194
+
195
+ log.step(2, 'Stopping daemon...');
196
+ const savedPid = getSavedPid();
197
+ if (savedPid) {
198
+ try {
199
+ process.kill(savedPid, 'SIGTERM');
200
+ log.success(`Daemon (PID: ${savedPid}) stopped.`);
201
+ removePid();
202
+ } catch (error) {
203
+ log.warn('Daemon not running, cleaning up stale PID file.');
204
+ removePid();
205
+ }
206
+ }
207
+
208
+ log.step(3, 'Restoring original code...');
209
+ const { restore } = await import('../lib/transformer.js');
210
+ await restore(null, true);
211
+
212
+ const snapshotPath = path.resolve(process.cwd(), SNAPSHOT_FILE);
213
+ if (fs.existsSync(snapshotPath)) {
214
+ const stats = fs.statSync(snapshotPath);
215
+ log.success(`Snapshots preserved at: ${snapshotPath} (${stats.size} bytes)`);
216
+ }
217
+
218
+ log.divider();
219
+ log.success('Original code restored. Snapshots preserved for testing.');
220
+ log.info('Run "io-snapshot test" to verify your code changes.');
221
+ log.divider();
222
+ });
223
+
224
+ program
225
+ .command('test [target]')
226
+ .description('Replay snapshots against current code to verify behavior')
227
+ .action(async (target) => {
228
+ try {
229
+ if (target) validateFilePattern(target);
230
+ } catch (error) {
231
+ log.error(error.message);
232
+ process.exit(1);
233
+ }
234
+
235
+ const { verifyDir } = await import('../lib/verifier.js');
236
+ await verifyDir(target);
237
+ });
238
+
239
+ program
240
+ .command('clean [target]')
241
+ .description('Restore original files and delete snapshots')
242
+ .action(async (target) => {
243
+ try {
244
+ if (target) validateFilePattern(target);
245
+ } catch (error) {
246
+ log.error(error.message);
247
+ process.exit(1);
248
+ }
249
+
250
+ const { restore } = await import('../lib/transformer.js');
251
+ await restore(target);
252
+ });
253
+
254
+ program.parse();
package/lib/config.js ADDED
@@ -0,0 +1,42 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { CONFIG_FILE } from './constants.js';
4
+
5
+ export function loadConfig() {
6
+ const configPath = path.resolve(process.cwd(), CONFIG_FILE);
7
+
8
+ if (!fs.existsSync(configPath)) {
9
+ return {};
10
+ }
11
+
12
+ try {
13
+ const content = fs.readFileSync(configPath, 'utf8');
14
+ return JSON.parse(content);
15
+ } catch (error) {
16
+ console.warn(`[io-snapshot] Warning: Failed to parse config file: ${error.message}`);
17
+ return {};
18
+ }
19
+ }
20
+
21
+ export function validatePort(port) {
22
+ const portNum = parseInt(port, 10);
23
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
24
+ throw new Error(`Invalid port: ${port}. Must be between 1 and 65535.`);
25
+ }
26
+ return portNum;
27
+ }
28
+
29
+ export function validateTimeout(timeout) {
30
+ const timeoutNum = parseInt(timeout, 10);
31
+ if (isNaN(timeoutNum) || timeoutNum < 1) {
32
+ throw new Error(`Invalid timeout: ${timeout}. Must be a positive number.`);
33
+ }
34
+ return timeoutNum;
35
+ }
36
+
37
+ export function validateFilePattern(pattern) {
38
+ if (!pattern || typeof pattern !== 'string') {
39
+ throw new Error('File pattern must be a non-empty string.');
40
+ }
41
+ return pattern;
42
+ }
@@ -0,0 +1,10 @@
1
+ export const DEFAULT_PORT = 9444;
2
+ export const DEFAULT_TIMEOUT = 30;
3
+ export const SNAPSHOT_FILE = '.snaps.jsonl'; // In project root
4
+ export const TEMP_SESSION_DIR_PREFIX = 'io-snapshot-session-';
5
+ export const TEMP_SUBDIR_NAME = '.io-snapshot'; // Subdirectory within the session temp dir
6
+ export const PID_FILE_NAME = '.io-snapshot.pid'; // In the session temp dir
7
+ export const RECORDER_FILE_NAME = 'recorder.js'; // In the session temp dir
8
+ export const LOCAL_BACKUP_DIR = '.io-snapshot-local-backups';
9
+ export const CONFIG_FILE = '.iosnapshotrc.json';
10
+ export const BACKUP_EXT = '.snap.bak';
package/lib/daemon.js ADDED
@@ -0,0 +1,193 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import { DEFAULT_PORT, DEFAULT_TIMEOUT, SNAPSHOT_FILE } from './constants.js';
6
+ import { loadConfig } from './config.js';
7
+ import { log } from './logger.js';
8
+ import { getPidFilePath } from './paths.js';
9
+
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+
12
+ let isRecording = false;
13
+ let lastTelemetryTime = Date.now();
14
+ let server = null;
15
+ let timeoutInterval = null;
16
+ let corsOrigin = '*';
17
+
18
+ function setCorsHeaders(res) {
19
+ if (corsOrigin === '*') {
20
+ res.setHeader('Access-Control-Allow-Origin', '*');
21
+ } else if (corsOrigin) {
22
+ res.setHeader('Access-Control-Allow-Origin', corsOrigin);
23
+ }
24
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
25
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
26
+ }
27
+
28
+ function sendResponse(res, statusCode, data) {
29
+ setCorsHeaders(res);
30
+ res.writeHead(statusCode, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify(data));
32
+ }
33
+
34
+ function savePid(pid) {
35
+ fs.writeFileSync(getPidFilePath(), String(pid));
36
+ }
37
+
38
+ function removePid() {
39
+ const pidPath = getPidFilePath();
40
+ if (fs.existsSync(pidPath)) {
41
+ fs.unlinkSync(pidPath);
42
+ }
43
+ }
44
+
45
+ function resetTimeout() {
46
+ lastTelemetryTime = Date.now();
47
+ }
48
+
49
+ function startTimeoutWatcher(timeoutMinutes) {
50
+ if (timeoutInterval) clearInterval(timeoutInterval);
51
+
52
+ timeoutInterval = setInterval(() => {
53
+ const elapsed = (Date.now() - lastTelemetryTime) / 1000 / 60;
54
+ if (elapsed >= timeoutMinutes && server) {
55
+ log.error(`Daemon auto-shutting down after ${timeoutMinutes} minutes of inactivity.`);
56
+ stopServer();
57
+ }
58
+ }, 30000);
59
+ }
60
+
61
+ function stopServer() {
62
+ if (server) {
63
+ server.close();
64
+ server = null;
65
+ }
66
+ if (timeoutInterval) {
67
+ clearInterval(timeoutInterval);
68
+ timeoutInterval = null;
69
+ }
70
+ removePid();
71
+ process.exit(0);
72
+ }
73
+
74
+ async function handleTelemetry(req, res) {
75
+ let body = '';
76
+ for await (const chunk of req) {
77
+ body += chunk;
78
+ }
79
+
80
+ if (!isRecording) {
81
+ sendResponse(res, 200, { status: 'ignored', reason: 'not_recording' });
82
+ return;
83
+ }
84
+
85
+ resetTimeout();
86
+
87
+ try {
88
+ fs.appendFileSync(path.resolve(process.cwd(), SNAPSHOT_FILE), body + '\n');
89
+ sendResponse(res, 200, { status: 'captured' });
90
+ } catch (error) {
91
+ sendResponse(res, 500, { error: error.message });
92
+ }
93
+ }
94
+
95
+ function startDaemon(port, timeoutMinutes, corsOptions) {
96
+ if (corsOptions?.origin) {
97
+ corsOrigin = corsOptions.origin;
98
+ }
99
+
100
+ server = http.createServer(async (req, res) => {
101
+ const url = new URL(req.url, `http://localhost:${port}`);
102
+
103
+ if (req.method === 'OPTIONS') {
104
+ setCorsHeaders(res);
105
+ res.writeHead(204);
106
+ res.end();
107
+ return;
108
+ }
109
+
110
+ if (req.method === 'GET' && url.pathname === '/status') {
111
+ sendResponse(res, 200, {
112
+ isRecording,
113
+ timeout: timeoutMinutes,
114
+ uptime: process.uptime(),
115
+ corsOrigin
116
+ });
117
+ return;
118
+ }
119
+
120
+ if (req.method === 'POST' && url.pathname === '/record') {
121
+ isRecording = true;
122
+ resetTimeout();
123
+ const snapshotPath = path.resolve(process.cwd(), SNAPSHOT_FILE);
124
+ log.success('Recording started.');
125
+ log.info(`Saving snapshots to: ${snapshotPath}`);
126
+ sendResponse(res, 200, { isRecording: true });
127
+ return;
128
+ }
129
+
130
+ if (req.method === 'POST' && url.pathname === '/stop') {
131
+ isRecording = false;
132
+ log.success('Recording stopped.');
133
+ sendResponse(res, 200, { isRecording: false });
134
+ return;
135
+ }
136
+
137
+ if (req.method === 'POST' && url.pathname === '/telemetry') {
138
+ await handleTelemetry(req, res);
139
+ return;
140
+ }
141
+
142
+ sendResponse(res, 404, { error: 'Not found' });
143
+ });
144
+
145
+ server.on('error', (error) => {
146
+ if (error.code === 'EADDRINUSE') {
147
+ log.error(`Port ${port} is already in use.`);
148
+ process.exit(1);
149
+ }
150
+ throw error;
151
+ });
152
+
153
+ server.listen(port, () => {
154
+ const snapshotPath = path.resolve(process.cwd(), SNAPSHOT_FILE);
155
+ log.info(`Daemon running on http://localhost:${port}`);
156
+ log.info(`Snapshots will be saved to: ${snapshotPath}`);
157
+ log.info(`Auto-shutdown after ${timeoutMinutes} minutes of inactivity.`);
158
+ savePid(process.pid);
159
+ startTimeoutWatcher(timeoutMinutes);
160
+ });
161
+ }
162
+
163
+ export function runDaemon(port, timeout, corsOptions) {
164
+ const config = loadConfig();
165
+ const actualPort = port || config.port || DEFAULT_PORT;
166
+ const actualTimeout = timeout || config.timeout || DEFAULT_TIMEOUT;
167
+ const corsEnv = process.env.IOSNAP_DAEMON_CORS || config.cors?.origin || '*';
168
+ startDaemon(actualPort, actualTimeout, { origin: corsEnv });
169
+ }
170
+
171
+ export function stopDaemon() {
172
+ const config = loadConfig();
173
+ const port = config.port || DEFAULT_PORT;
174
+
175
+ http.get(`http://localhost:${port}/status`, (res) => {
176
+ res.resume();
177
+ if (res.statusCode === 200) {
178
+ const req = http.request(`http://localhost:${port}/stop`, { method: 'POST' }, () => {
179
+ log.success('Daemon stopped.');
180
+ process.exit(0);
181
+ });
182
+ req.end();
183
+ }
184
+ }).on('error', () => {
185
+ log.error('Daemon is not running.');
186
+ process.exit(1);
187
+ });
188
+ }
189
+
190
+ if (import.meta.url === `file://${process.argv[1]}`) {
191
+ const config = loadConfig();
192
+ runDaemon(config.port || DEFAULT_PORT, config.timeout || DEFAULT_TIMEOUT);
193
+ }
package/lib/logger.js ADDED
@@ -0,0 +1,42 @@
1
+ const PREFIX = '[io-snapshot]';
2
+
3
+ export const log = {
4
+ info(...args) {
5
+ console.log(PREFIX, ...args);
6
+ },
7
+
8
+ warn(...args) {
9
+ console.warn(PREFIX, 'Warning:', ...args);
10
+ },
11
+
12
+ error(...args) {
13
+ console.error(PREFIX, 'Error:', ...args);
14
+ },
15
+
16
+ success(...args) {
17
+ console.log(PREFIX, '✓', ...args);
18
+ },
19
+
20
+ step(stepNumber, ...args) {
21
+ console.log(PREFIX, `Step ${stepNumber}:`, ...args);
22
+ },
23
+
24
+ divider(text = '') {
25
+ if (text) {
26
+ console.log(`========================================`);
27
+ console.log(PREFIX, text);
28
+ console.log(`========================================`);
29
+ } else {
30
+ console.log(`========================================`);
31
+ }
32
+ },
33
+
34
+ workflow(steps) {
35
+ console.log('');
36
+ console.log('Workflow:');
37
+ steps.forEach((step, index) => {
38
+ console.log(` ${index + 1}. ${step}`);
39
+ });
40
+ console.log('');
41
+ }
42
+ };
package/lib/paths.js ADDED
@@ -0,0 +1,67 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+ import {
6
+ TEMP_SESSION_DIR_PREFIX,
7
+ TEMP_SUBDIR_NAME,
8
+ PID_FILE_NAME,
9
+ RECORDER_FILE_NAME,
10
+ LOCAL_BACKUP_DIR,
11
+ BACKUP_EXT
12
+ } from './constants.js';
13
+
14
+ let _tempSessionDirPath = null;
15
+
16
+ function getProjectHash() {
17
+ return crypto.createHash('md5').update(process.cwd()).digest('hex');
18
+ }
19
+
20
+ export function getTempSessionDirPath() {
21
+ if (_tempSessionDirPath) {
22
+ return _tempSessionDirPath;
23
+ }
24
+ _tempSessionDirPath = path.join(os.tmpdir(), TEMP_SESSION_DIR_PREFIX + getProjectHash());
25
+ return _tempSessionDirPath;
26
+ }
27
+
28
+ export function getRecorderPath() {
29
+ return path.join(getTempSessionDirPath(), TEMP_SUBDIR_NAME, RECORDER_FILE_NAME);
30
+ }
31
+
32
+ export function getPidFilePath() {
33
+ return path.join(getTempSessionDirPath(), PID_FILE_NAME);
34
+ }
35
+
36
+ export function getRecorderDir() {
37
+ return path.join(getTempSessionDirPath(), TEMP_SUBDIR_NAME);
38
+ }
39
+
40
+ export function ensureTempSessionDir() {
41
+ const dirPath = getTempSessionDirPath();
42
+ if (!fs.existsSync(dirPath)) {
43
+ fs.mkdirSync(dirPath, { recursive: true });
44
+ }
45
+ const recorderDirPath = getRecorderDir();
46
+ if (!fs.existsSync(recorderDirPath)) {
47
+ fs.mkdirSync(recorderDirPath, { recursive: true });
48
+ }
49
+ }
50
+
51
+ // Gets the path for the primary backup in the OS temp directory
52
+ export function getPrimaryBackupPath(filePath) {
53
+ const relativePath = path.relative(process.cwd(), filePath);
54
+ const backupPath = path.join(getTempSessionDirPath(), 'backup', relativePath) + BACKUP_EXT;
55
+ // Ensure the subdirectory structure exists
56
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
57
+ return backupPath;
58
+ }
59
+
60
+ // Gets the path for the fallback backup in the project's root directory
61
+ export function getLocalBackupPath(filePath) {
62
+ const relativePath = path.relative(process.cwd(), filePath);
63
+ const backupPath = path.join(process.cwd(), LOCAL_BACKUP_DIR, relativePath) + BACKUP_EXT;
64
+ // Ensure the subdirectory structure exists
65
+ fs.mkdirSync(path.dirname(backupPath), { recursive: true });
66
+ return backupPath;
67
+ }
@@ -0,0 +1,38 @@
1
+ import SuperJSON from 'superjson';
2
+
3
+ function getPort() {
4
+ if (typeof process !== 'undefined' && process.env.IOSNAP_DAEMON_PORT) {
5
+ return parseInt(process.env.IOSNAP_DAEMON_PORT, 10);
6
+ }
7
+ if (typeof window !== 'undefined') {
8
+ return window.IOSNAP_DAEMON_PORT || 9444;
9
+ }
10
+ return 9444;
11
+ }
12
+
13
+ export function record(fn, fnName) {
14
+ return new Proxy(fn, {
15
+ async apply(target, thisArg, args) {
16
+ const result = await Reflect.apply(target, thisArg, args);
17
+
18
+ const snapshot = {
19
+ fnName,
20
+ args,
21
+ result,
22
+ at: new Date().toISOString()
23
+ };
24
+
25
+ const port = getPort();
26
+
27
+ fetch(`http://localhost:${port}/telemetry`, {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: SuperJSON.stringify(snapshot)
31
+ }).catch(() => {
32
+ console.warn('[io-snapshot] Daemon not running. Run io-snapshot daemon first.');
33
+ });
34
+
35
+ return result;
36
+ }
37
+ });
38
+ }
@@ -0,0 +1,363 @@
1
+ import { Project } from 'ts-morph';
2
+ import * as babelParser from '@babel/parser';
3
+ import * as babelTraverse from '@babel/traverse';
4
+ import * as babelTypes from '@babel/types';
5
+ import * as babelGenerator from '@babel/generator';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { glob } from 'glob';
9
+ import { BACKUP_EXT, SNAPSHOT_FILE, RECORDER_FILE_NAME, TEMP_SUBDIR_NAME, LOCAL_BACKUP_DIR } from './constants.js';
10
+ import { getRecorderPath, getRecorderDir, getTempSessionDirPath, getPrimaryBackupPath, getLocalBackupPath } from './paths.js';
11
+ import { log } from './logger.js';
12
+
13
+ const parse = babelParser.parse;
14
+ const traverse = babelTraverse.default?.default || babelTraverse.default;
15
+ const t = babelTypes;
16
+ const generate = babelGenerator.default?.default || babelGenerator.default || babelGenerator;
17
+
18
+ const RECORDER_CODE = `export function _snap_record(fn, fnName) {
19
+ return new Proxy(fn, {
20
+ async apply(target, thisArg, args) {
21
+ const result = Reflect.apply(target, thisArg, args);
22
+ const port = typeof process !== 'undefined' && process.env.IOSNAP_DAEMON_PORT
23
+ ? parseInt(process.env.IOSNAP_DAEMON_PORT, 10)
24
+ : typeof window !== 'undefined' ? (window.IOSNAP_DAEMON_PORT || 9444) : 9444;
25
+
26
+ const snapshot = {
27
+ fnName,
28
+ args: args,
29
+ result: result,
30
+ at: new Date().toISOString()
31
+ };
32
+
33
+ fetch('http://localhost:' + port + '/telemetry', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify(snapshot)
37
+ }).catch(() => {});
38
+
39
+ return result;
40
+ }
41
+ });
42
+ }
43
+ `;
44
+
45
+ function ensureRecorderFile() {
46
+ const recorderPath = getRecorderPath();
47
+ const dirPath = getRecorderDir();
48
+
49
+ if (!fs.existsSync(dirPath)) {
50
+ fs.mkdirSync(dirPath, { recursive: true });
51
+ }
52
+
53
+ if (!fs.existsSync(recorderPath)) {
54
+ fs.writeFileSync(recorderPath, RECORDER_CODE);
55
+ log.success(`Created ${path.join(TEMP_SUBDIR_NAME, RECORDER_FILE_NAME)} in temp directory.`);
56
+ }
57
+ }
58
+
59
+ function getProject() {
60
+ return new Project({
61
+ useInMemoryFileSystem: false,
62
+ });
63
+ }
64
+
65
+ function injectTSFile(filePath) {
66
+ const project = getProject();
67
+ const sourceFile = project.addSourceFileAtPath(filePath);
68
+
69
+ log.info('Injecting recorder import');
70
+
71
+ // Calculate relative path from the source file to the recorder file in the temp directory
72
+ const relativeRecorderPath = path.relative(path.dirname(filePath), getRecorderPath().replace(/\.js$/, ''));
73
+
74
+ const imports = sourceFile.getImportDeclarations();
75
+ const hasRecorderImport = imports.some(
76
+ imp => imp.getModuleSpecifierValue?.().includes(relativeRecorderPath)
77
+ );
78
+
79
+ if (!hasRecorderImport) {
80
+ sourceFile.addImportDeclaration({
81
+ moduleSpecifier: `./${relativeRecorderPath}`, // Use relative path for import
82
+ namedImports: ['_snap_record']
83
+ });
84
+ }
85
+
86
+ const exports = sourceFile.getExportedDeclarations();
87
+ log.info(`Found ${exports.size} exported declarations`);
88
+
89
+ exports.forEach((declarations, name) => {
90
+ log.info(`Processing export: ${name}`);
91
+ declarations.forEach(decl => {
92
+ const kind = decl.getKindName();
93
+ log.info(` Kind: ${kind}`);
94
+
95
+ if (kind === 'FunctionDeclaration') {
96
+ const originalName = decl.getName();
97
+ log.info(` Renaming function: ${originalName} -> _snap_${originalName}`);
98
+ decl.rename(`_snap_${originalName}`);
99
+ decl.setIsExported(false);
100
+
101
+ sourceFile.addVariableStatement({
102
+ isExported: true,
103
+ declarationKind: 'const',
104
+ declarations: [{
105
+ name: originalName,
106
+ initializer: `_snap_record(_snap_${originalName}, '${originalName}')`
107
+ }]
108
+ });
109
+ log.info(` Added wrapper for: ${originalName}`);
110
+ }
111
+
112
+ if (kind === 'ArrowFunction' || kind === 'FunctionExpression') {
113
+ const variableStmt = decl.getFirstAncestor(a => a.getKindName() === 'VariableStatement');
114
+ if (variableStmt) {
115
+ const declarations = variableStmt.getDeclarations();
116
+
117
+ declarations.forEach(varDecl2 => {
118
+ const name = varDecl2.getName();
119
+ const init = varDecl2.getInitializer();
120
+ if (init && (init.getKindName() === 'ArrowFunction' || init.getKindName() === 'FunctionExpression')) {
121
+ const originalName = `_snap_${name}`;
122
+ log.info(` Wrapping arrow/function: ${name}`);
123
+ init.replaceWithText(`_snap_record(${originalName}, '${name}')`);
124
+ varDecl2.setName(originalName);
125
+ }
126
+ });
127
+ }
128
+ }
129
+ });
130
+ });
131
+
132
+ sourceFile.saveSync();
133
+ log.info(`Saved changes to ${filePath}`);
134
+ }
135
+
136
+ function injectJSFile(filePath) {
137
+ const code = fs.readFileSync(filePath, 'utf8');
138
+
139
+ log.info('Injecting recorder import');
140
+
141
+ let ast;
142
+ try {
143
+ ast = parse(code, {
144
+ sourceType: 'module',
145
+ plugins: ['jsx']
146
+ });
147
+ } catch (error) {
148
+ log.error(`Parser error: ${error.message}`);
149
+ return;
150
+ }
151
+
152
+ // Calculate relative path from the source file to the recorder file in the temp directory
153
+ const relativeRecorderPath = path.relative(path.dirname(filePath), getRecorderPath().replace(/\.js$/, ''));
154
+
155
+ // Check if already has recorder import
156
+ const hasRecorderImport = ast.program.body.some(
157
+ node => t.isImportDeclaration(node) &&
158
+ node.source.value.includes(relativeRecorderPath)
159
+ );
160
+
161
+ // Add import if not present
162
+ if (!hasRecorderImport) {
163
+ const importDecl = t.importDeclaration(
164
+ [t.importSpecifier(t.identifier('_snap_record'), t.identifier('_snap_record'))],
165
+ t.stringLiteral(`./${relativeRecorderPath}`) // Use relative path for import
166
+ );
167
+ ast.program.body.unshift(importDecl);
168
+ }
169
+
170
+ const wrapperStatements = [];
171
+ const exportsToRemove = [];
172
+
173
+ traverse(ast, {
174
+ ExportNamedDeclaration(path) {
175
+ const decl = path.node.declaration;
176
+
177
+ if (t.isFunctionDeclaration(decl)) {
178
+ const name = decl.id.name;
179
+ const newName = `_snap_${name}`;
180
+ log.info(`Wrapping function: ${name} -> ${newName}`);
181
+ decl.id.name = newName;
182
+
183
+ wrapperStatements.push(t.exportNamedDeclaration(t.variableDeclaration('const', [
184
+ t.variableDeclarator(
185
+ t.identifier(name),
186
+ t.callExpression(t.identifier('_snap_record'), [
187
+ t.identifier(newName),
188
+ t.stringLiteral(name)
189
+ ])
190
+ )
191
+ ])));
192
+
193
+ exportsToRemove.push(path);
194
+ }
195
+
196
+ if (t.isVariableDeclaration(decl)) {
197
+ decl.declarations.forEach(varDecl => {
198
+ if (t.isIdentifier(varDecl.id) &&
199
+ (t.isArrowFunctionExpression(varDecl.init) ||
200
+ t.isFunctionExpression(varDecl.init))) {
201
+ const name = varDecl.id.name;
202
+ const newName = `_snap_${name}`;
203
+ log.info(`Wrapping variable: ${name} -> ${newName}`);
204
+ varDecl.id.name = newName;
205
+
206
+ wrapperStatements.push(t.exportNamedDeclaration(t.variableDeclaration('const', [
207
+ t.variableDeclarator(
208
+ t.identifier(name),
209
+ t.callExpression(t.identifier('_snap_record'), [
210
+ t.identifier(newName),
211
+ t.stringLiteral(name)
212
+ ])
213
+ )
214
+ ])));
215
+
216
+ exportsToRemove.push(path);
217
+ }
218
+ });
219
+ }
220
+ }
221
+ });
222
+
223
+ if (wrapperStatements.length === 0) {
224
+ log.warn('No exported functions found to wrap');
225
+ return;
226
+ }
227
+
228
+ for (const expPath of exportsToRemove) {
229
+ const decl = expPath.node.declaration;
230
+ expPath.replaceWith(decl);
231
+ }
232
+
233
+ for (const stmt of wrapperStatements) {
234
+ ast.program.body.push(stmt);
235
+ }
236
+
237
+ const output = generate(ast, {}, code);
238
+ fs.writeFileSync(filePath, output.code);
239
+ log.info(`Saved changes to ${filePath}`);
240
+ }
241
+
242
+ function restoreFile(filePath) {
243
+ const primaryBackupPath = getPrimaryBackupPath(filePath);
244
+ const localBackupPath = getLocalBackupPath(filePath);
245
+
246
+ if (fs.existsSync(primaryBackupPath)) {
247
+ fs.copyFileSync(primaryBackupPath, filePath);
248
+ } else if (fs.existsSync(localBackupPath)) {
249
+ log.warn(`Primary backup not found. Restoring from local fallback: ${localBackupPath}`);
250
+ fs.copyFileSync(localBackupPath, filePath);
251
+ } else {
252
+ log.error(`No backup found for ${filePath}. File cannot be restored.`);
253
+ return false; // Indicate failure
254
+ }
255
+ return true; // Indicate success
256
+ }
257
+
258
+ export async function injectRecorder(targetPattern, force = false) {
259
+ ensureRecorderFile();
260
+
261
+ const defaultPattern = '**/*.{ts,tsx,js,jsx}';
262
+ const pattern = targetPattern || defaultPattern;
263
+
264
+ const files = await glob(pattern, {
265
+ ignore: ['node_modules/**', '**/node_modules/**', `${LOCAL_BACKUP_DIR}/**`]
266
+ });
267
+
268
+ if (files.length === 0) {
269
+ log.warn(`No files found matching: ${pattern}`);
270
+ return;
271
+ }
272
+
273
+ const tsFiles = files.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes(BACKUP_EXT));
274
+ const jsFiles = files.filter(f => (f.endsWith('.js') || f.endsWith('.jsx')) && !f.includes(BACKUP_EXT));
275
+
276
+ log.info(`Found ${tsFiles.length} TypeScript and ${jsFiles.length} JavaScript files to inject.`);
277
+
278
+ for (const file of [...tsFiles, ...jsFiles]) {
279
+ const primaryBackupPath = getPrimaryBackupPath(file);
280
+
281
+ if (fs.existsSync(primaryBackupPath) && !force) {
282
+ log.warn(`Skipping ${file} - already injected (backup exists). Use --force to re-inject.`);
283
+ continue;
284
+ }
285
+
286
+ if (fs.existsSync(primaryBackupPath) && force) {
287
+ log.info(`Re-injecting ${file}`);
288
+ // Restore from primary to ensure we are injecting a clean file
289
+ fs.copyFileSync(primaryBackupPath, file);
290
+ } else {
291
+ log.info(`Injecting ${file}`);
292
+ }
293
+
294
+ // Create both backups
295
+ const localBackupPath = getLocalBackupPath(file);
296
+ fs.copyFileSync(file, primaryBackupPath);
297
+ fs.copyFileSync(file, localBackupPath);
298
+
299
+ try {
300
+ if (file.endsWith('.ts') || file.endsWith('.tsx')) {
301
+ injectTSFile(file);
302
+ } else {
303
+ injectJSFile(file);
304
+ }
305
+ log.success(`Injected recorder into ${file}`);
306
+ } catch (error) {
307
+ log.error(`Failed to inject ${file}: ${error.message}`);
308
+ // If injection fails, try to restore from one of the backups
309
+ restoreFile(file);
310
+ }
311
+ }
312
+ }
313
+
314
+ export async function restore(targetPattern, keepSnapshots = false) {
315
+ let filesToRestore = [];
316
+
317
+ if (targetPattern) {
318
+ const files = await glob(targetPattern);
319
+ filesToRestore = files.filter(f => !f.includes(BACKUP_EXT));
320
+ } else {
321
+ // Search for all backup files in the local backup dir to determine what to restore
322
+ const backupDir = path.join(process.cwd(), LOCAL_BACKUP_DIR);
323
+ if (fs.existsSync(backupDir)) {
324
+ const bakFiles = await glob(`${backupDir}/**/*${BACKUP_EXT}`);
325
+ filesToRestore = bakFiles.map(f => {
326
+ const relativePath = path.relative(backupDir, f);
327
+ return relativePath.replace(BACKUP_EXT, '');
328
+ });
329
+ }
330
+ }
331
+
332
+ if (filesToRestore.length === 0) {
333
+ log.warn('No files to restore.');
334
+ }
335
+
336
+ for (const file of filesToRestore) {
337
+ if (restoreFile(file)) {
338
+ log.success(`Restored ${file}`);
339
+ }
340
+ }
341
+
342
+ // --- Final Cleanup ---
343
+
344
+ const snapshotPath = path.resolve(process.cwd(), SNAPSHOT_FILE);
345
+ if (!keepSnapshots && fs.existsSync(snapshotPath)) {
346
+ fs.unlinkSync(snapshotPath);
347
+ log.success(`Removed ${SNAPSHOT_FILE}`);
348
+ }
349
+
350
+ // Remove the entire temp session directory, which includes primary backups and PID file
351
+ const tempSessionDirPath = getTempSessionDirPath();
352
+ if (fs.existsSync(tempSessionDirPath)) {
353
+ fs.rmSync(tempSessionDirPath, { recursive: true, force: true });
354
+ log.success(`Removed temp session directory: ${tempSessionDirPath}`);
355
+ }
356
+
357
+ // Remove the local backup directory from the project root
358
+ const localBackupDirPath = path.join(process.cwd(), LOCAL_BACKUP_DIR);
359
+ if (fs.existsSync(localBackupDirPath)) {
360
+ fs.rmSync(localBackupDirPath, { recursive: true, force: true });
361
+ log.success(`Removed local backup directory: ${LOCAL_BACKUP_DIR}`);
362
+ }
363
+ }
@@ -0,0 +1,166 @@
1
+ import SuperJSON from 'superjson';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { pathToFileURL } from 'url';
5
+ import pkg from 'deep-diff';
6
+ const { diff } = pkg;
7
+ import { glob } from 'glob';
8
+ import { SNAPSHOT_FILE } from './constants.js';
9
+ import { loadConfig } from './config.js';
10
+ import { log } from './logger.js';
11
+
12
+ export async function verify(newFn, fnName) {
13
+ const config = loadConfig();
14
+ const port = config.port || 9444;
15
+
16
+ if (!fs.existsSync(path.resolve(process.cwd(), SNAPSHOT_FILE))) {
17
+ log.error(`No snapshots found! Run 'io-snapshot record' first.`);
18
+ return { passed: false, error: 'no_snapshots' };
19
+ }
20
+
21
+ const fileContent = fs.readFileSync(path.resolve(process.cwd(), SNAPSHOT_FILE), 'utf8');
22
+ const snapshots = fileContent.split('\n')
23
+ .filter(line => line.trim())
24
+ .map(line => {
25
+ try {
26
+ const parsed = SuperJSON.parse(line);
27
+ if (parsed?.fnName) return parsed;
28
+ } catch (error) {
29
+ log.warn(`Failed to parse snapshot line: ${error.message}`);
30
+ }
31
+ try {
32
+ const parsed = JSON.parse(line);
33
+ if (parsed?.fnName) return parsed;
34
+ } catch (error) {
35
+ log.warn(`Failed to parse snapshot line as JSON: ${error.message}`);
36
+ }
37
+ return null;
38
+ })
39
+ .filter(s => s?.fnName === fnName);
40
+
41
+ if (snapshots.length === 0) {
42
+ log.info(`No snapshots found for function '${fnName}'.`);
43
+ return { passed: true, skipped: true };
44
+ }
45
+
46
+ let allPassed = true;
47
+ let failedCount = 0;
48
+
49
+ for (const snap of snapshots) {
50
+ const newResult = await newFn(...snap.args);
51
+ const changes = diff(snap.result, newResult);
52
+
53
+ if (changes) {
54
+ log.error(`Drift detected in ${fnName}!`);
55
+ console.dir(changes, { depth: null });
56
+ allPassed = false;
57
+ failedCount++;
58
+ } else {
59
+ log.success(`${fnName} passed semantic check.`);
60
+ }
61
+ }
62
+
63
+ if (allPassed && snapshots.length > 0) {
64
+ log.success(`All ${snapshots.length} snapshots passed for ${fnName}.`);
65
+ }
66
+
67
+ return { passed: allPassed, failed: failedCount, total: snapshots.length };
68
+ }
69
+
70
+ export async function verifyDir(targetPattern) {
71
+ if (!fs.existsSync(path.resolve(process.cwd(), SNAPSHOT_FILE))) {
72
+ log.error(`No snapshots found! Run 'io-snapshot record' first.`);
73
+ process.exit(1);
74
+ }
75
+
76
+ const fileContent = fs.readFileSync(path.resolve(process.cwd(), SNAPSHOT_FILE), 'utf8');
77
+ const allSnapshots = fileContent.split('\n')
78
+ .filter(line => line.trim())
79
+ .map(line => {
80
+ try {
81
+ const parsed = SuperJSON.parse(line);
82
+ if (parsed?.fnName) return parsed;
83
+ } catch (error) {
84
+ log.warn(`Failed to parse snapshot line: ${error.message}`);
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(line);
88
+ if (parsed?.fnName) return parsed;
89
+ } catch (error) {
90
+ log.warn(`Failed to parse snapshot line as JSON: ${error.message}`);
91
+ }
92
+ return null;
93
+ })
94
+ .filter(s => s?.fnName);
95
+
96
+ const fnNames = [...new Set(allSnapshots.map(s => s.fnName))];
97
+
98
+ if (fnNames.length === 0) {
99
+ log.warn('No snapshots to verify.');
100
+ return;
101
+ }
102
+
103
+ let allFiles = [];
104
+
105
+ if (targetPattern) {
106
+ const files = await glob(targetPattern);
107
+ const tsFiles = files.filter(f => (f.endsWith('.ts') || f.endsWith('.tsx')) && !f.includes('.snap.bak'));
108
+ const jsFiles = files.filter(f => (f.endsWith('.js') || f.endsWith('.jsx')) && !f.includes('.snap.bak'));
109
+ allFiles = [...tsFiles, ...jsFiles];
110
+ } else {
111
+ const allSourceFiles = await glob('**/*.{ts,tsx,js,jsx}', {
112
+ ignore: ['node_modules/**', '**/node_modules/**']
113
+ });
114
+ allFiles = allSourceFiles.filter(f => !f.includes('.snap.bak'));
115
+ }
116
+
117
+ if (allFiles.length === 0) {
118
+ log.warn('No source files found.');
119
+ return;
120
+ }
121
+
122
+ log.info(`Verifying ${fnNames.length} functions against ${allSnapshots.length} snapshots...`);
123
+
124
+ let totalPassed = true;
125
+
126
+ for (const fnName of fnNames) {
127
+ let found = false;
128
+
129
+ for (const file of allFiles) {
130
+ try {
131
+ const absolutePath = path.resolve(process.cwd(), file);
132
+ const fileUrl = pathToFileURL(absolutePath).href;
133
+ const mod = await import(fileUrl);
134
+ const fn = mod[fnName];
135
+
136
+ if (!fn) {
137
+ continue;
138
+ }
139
+
140
+ found = true;
141
+ log.info(`Checking function '${fnName}' in ${file}`);
142
+ const result = await verify(fn, fnName);
143
+ if (result && !result.passed) {
144
+ totalPassed = false;
145
+ }
146
+ break;
147
+ } catch (error) {
148
+ log.warn(`Could not load function from ${file}: ${error.message}`);
149
+ }
150
+ }
151
+
152
+ if (!found) {
153
+ log.warn(`Function '${fnName}' not found in any source file.`);
154
+ }
155
+ }
156
+
157
+ if (totalPassed) {
158
+ log.divider('SUCCESS');
159
+ log.success('All verifications passed!');
160
+ process.exit(0);
161
+ } else {
162
+ log.divider('FAILURE');
163
+ log.error('Some verifications failed.');
164
+ process.exit(1);
165
+ }
166
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@kendroger/io-snapshot",
3
+ "version": "1.0.0",
4
+ "description": "Capture and compare function behavior snapshots for zero-regression refactoring.",
5
+ "keywords": [
6
+ "refactor",
7
+ "testing",
8
+ "semantic",
9
+ "automation",
10
+ "ai",
11
+ "diff",
12
+ "test"
13
+ ],
14
+ "homepage": "https://github.com/kenDRoger/io-snapshot#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/kenDRoger/io-snapshot/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/kenDRoger/io-snapshot.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "kendroger",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./lib/recorder.js",
27
+ "./recorder": "./lib/recorder.js",
28
+ "./verifier": "./lib/verifier.js",
29
+ "./transformer": "./lib/transformer.js",
30
+ "./daemon": "./lib/daemon.js",
31
+ "./constants": "./lib/constants.js",
32
+ "./config": "./lib/config.js",
33
+ "./logger": "./lib/logger.js"
34
+ },
35
+ "main": "./lib/recorder.js",
36
+ "bin": {
37
+ "io-snapshot": "bin/cli.js"
38
+ },
39
+ "directories": {
40
+ "lib": "lib"
41
+ },
42
+ "scripts": {
43
+ "test": "vitest"
44
+ },
45
+ "dependencies": {
46
+ "commander": "^12.0.0",
47
+ "deep-diff": "^1.0.2",
48
+ "glob": "^10.3.10",
49
+ "superjson": "^2.2.1"
50
+ },
51
+ "devDependencies": {
52
+ "@babel/generator": "^7.23.0",
53
+ "@babel/parser": "^7.23.0",
54
+ "@babel/traverse": "^7.23.0",
55
+ "@babel/types": "^7.23.0",
56
+ "ts-morph": "^21.0.0"
57
+ }
58
+ }