@getmikk/watcher 1.2.0 → 1.3.1
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 +265 -0
- package/package.json +31 -27
- package/src/daemon.ts +232 -232
- package/src/file-watcher.ts +93 -93
- package/src/incremental-analyzer.ts +192 -192
- package/src/index.ts +4 -4
- package/src/types.ts +25 -25
- package/tests/smoke.test.ts +5 -5
- package/tsconfig.json +14 -14
package/src/daemon.ts
CHANGED
|
@@ -1,232 +1,232 @@
|
|
|
1
|
-
import * as path from 'node:path'
|
|
2
|
-
import * as fs from 'node:fs/promises'
|
|
3
|
-
import {
|
|
4
|
-
GraphBuilder, LockCompiler, LockReader, ContractReader,
|
|
5
|
-
parseFiles, readFileContent, discoverFiles, logger,
|
|
6
|
-
type DependencyGraph, type MikkLock, type MikkContract
|
|
7
|
-
} from '@getmikk/core'
|
|
8
|
-
import { FileWatcher } from './file-watcher.js'
|
|
9
|
-
import { IncrementalAnalyzer } from './incremental-analyzer.js'
|
|
10
|
-
import type { WatcherConfig, WatcherEvent, FileChangeEvent } from './types.js'
|
|
11
|
-
|
|
12
|
-
/** Sync state persisted to .mikk/sync-state.json */
|
|
13
|
-
interface SyncState {
|
|
14
|
-
status: 'clean' | 'syncing' | 'drifted' | 'conflict'
|
|
15
|
-
lastUpdated: number
|
|
16
|
-
filesInFlight?: number
|
|
17
|
-
rootHash?: string
|
|
18
|
-
error?: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* WatcherDaemon — long-running background process.
|
|
23
|
-
* Starts the FileWatcher, handles the IncrementalAnalyzer,
|
|
24
|
-
* writes updates to the lock file, and manages sync state.
|
|
25
|
-
*
|
|
26
|
-
* Features:
|
|
27
|
-
* - Debounces file changes (100ms window)
|
|
28
|
-
* - Batch threshold: if > 15 files in a batch, runs full analysis
|
|
29
|
-
* - PID file for single-instance enforcement
|
|
30
|
-
* - Atomic sync state writes
|
|
31
|
-
*/
|
|
32
|
-
export class WatcherDaemon {
|
|
33
|
-
private watcher: FileWatcher
|
|
34
|
-
private analyzer: IncrementalAnalyzer | null = null
|
|
35
|
-
private lock: MikkLock | null = null
|
|
36
|
-
private contract: MikkContract | null = null
|
|
37
|
-
private handlers: ((event: WatcherEvent) => void)[] = []
|
|
38
|
-
private pendingEvents: FileChangeEvent[] = []
|
|
39
|
-
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
|
-
private processing = false
|
|
41
|
-
|
|
42
|
-
constructor(private config: WatcherConfig) {
|
|
43
|
-
this.watcher = new FileWatcher(config)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
async start(): Promise<void> {
|
|
47
|
-
// Write PID file for single-instance enforcement
|
|
48
|
-
await this.writePidFile()
|
|
49
|
-
|
|
50
|
-
// Load existing contract and lock
|
|
51
|
-
const contractReader = new ContractReader()
|
|
52
|
-
const lockReader = new LockReader()
|
|
53
|
-
const contractPath = path.join(this.config.projectRoot, 'mikk.json')
|
|
54
|
-
const lockPath = path.join(this.config.projectRoot, 'mikk.lock.json')
|
|
55
|
-
|
|
56
|
-
this.contract = await contractReader.read(contractPath)
|
|
57
|
-
this.lock = await lockReader.read(lockPath)
|
|
58
|
-
|
|
59
|
-
// Parse all files to populate the analyzer
|
|
60
|
-
const filePaths = await discoverFiles(this.config.projectRoot)
|
|
61
|
-
const parsedFiles = await parseFiles(filePaths, this.config.projectRoot, (fp) =>
|
|
62
|
-
readFileContent(fp)
|
|
63
|
-
)
|
|
64
|
-
const graph = new GraphBuilder().build(parsedFiles)
|
|
65
|
-
|
|
66
|
-
this.analyzer = new IncrementalAnalyzer(graph, this.lock, this.contract, this.config.projectRoot)
|
|
67
|
-
|
|
68
|
-
// Add all parsed files to the analyzer
|
|
69
|
-
for (const file of parsedFiles) {
|
|
70
|
-
this.analyzer.addParsedFile(file)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Subscribe to file changes with debouncing
|
|
74
|
-
this.watcher.on(async (event: WatcherEvent) => {
|
|
75
|
-
if (event.type === 'file:changed') {
|
|
76
|
-
this.enqueueChange(event.data)
|
|
77
|
-
}
|
|
78
|
-
// Forward events to external handlers
|
|
79
|
-
for (const handler of this.handlers) {
|
|
80
|
-
handler(event)
|
|
81
|
-
}
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
this.watcher.start()
|
|
85
|
-
await this.writeSyncState({ status: 'clean', lastUpdated: Date.now() })
|
|
86
|
-
logger.info('Mikk watcher started', { watching: this.config.include })
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async stop(): Promise<void> {
|
|
90
|
-
await this.watcher.stop()
|
|
91
|
-
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
92
|
-
await this.removePidFile()
|
|
93
|
-
logger.info('Mikk watcher stopped')
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
on(handler: (event: WatcherEvent) => void): void {
|
|
97
|
-
this.handlers.push(handler)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// ─── Debounce & Batch Processing ──────────────────────────────
|
|
101
|
-
|
|
102
|
-
private enqueueChange(event: FileChangeEvent): void {
|
|
103
|
-
this.pendingEvents.push(event)
|
|
104
|
-
|
|
105
|
-
// Reset the debounce timer
|
|
106
|
-
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
107
|
-
this.debounceTimer = setTimeout(() => {
|
|
108
|
-
this.flushPendingEvents()
|
|
109
|
-
}, this.config.debounceMs || 100)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
private async flushPendingEvents(): Promise<void> {
|
|
113
|
-
if (this.processing || this.pendingEvents.length === 0) return
|
|
114
|
-
this.processing = true
|
|
115
|
-
|
|
116
|
-
const events = [...this.pendingEvents]
|
|
117
|
-
this.pendingEvents = []
|
|
118
|
-
|
|
119
|
-
// Deduplicate by path (keep latest event per file)
|
|
120
|
-
const byPath = new Map<string, FileChangeEvent>()
|
|
121
|
-
for (const event of events) {
|
|
122
|
-
byPath.set(event.path, event)
|
|
123
|
-
}
|
|
124
|
-
const dedupedEvents = [...byPath.values()]
|
|
125
|
-
|
|
126
|
-
await this.writeSyncState({
|
|
127
|
-
status: 'syncing',
|
|
128
|
-
lastUpdated: Date.now(),
|
|
129
|
-
filesInFlight: dedupedEvents.length,
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
await this.processBatch(dedupedEvents)
|
|
134
|
-
await this.writeSyncState({
|
|
135
|
-
status: 'clean',
|
|
136
|
-
lastUpdated: Date.now(),
|
|
137
|
-
})
|
|
138
|
-
} catch (err: any) {
|
|
139
|
-
await this.writeSyncState({
|
|
140
|
-
status: 'drifted',
|
|
141
|
-
lastUpdated: Date.now(),
|
|
142
|
-
error: err.message,
|
|
143
|
-
})
|
|
144
|
-
} finally {
|
|
145
|
-
this.processing = false
|
|
146
|
-
|
|
147
|
-
// If more events arrived during processing, flush again
|
|
148
|
-
if (this.pendingEvents.length > 0) {
|
|
149
|
-
this.flushPendingEvents()
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
private async processBatch(events: FileChangeEvent[]): Promise<void> {
|
|
155
|
-
if (!this.analyzer || !this.lock) return
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const result = await this.analyzer.analyzeBatch(events)
|
|
159
|
-
this.lock = result.lock
|
|
160
|
-
|
|
161
|
-
// Write updated lock
|
|
162
|
-
const lockPath = path.join(this.config.projectRoot, 'mikk.lock.json')
|
|
163
|
-
await fs.writeFile(lockPath, JSON.stringify(this.lock, null, 2), 'utf-8')
|
|
164
|
-
|
|
165
|
-
// Log batch info
|
|
166
|
-
if (result.mode === 'full') {
|
|
167
|
-
logger.info('Full re-analysis completed', {
|
|
168
|
-
filesChanged: events.length,
|
|
169
|
-
reason: 'Large batch detected (> 15 files)',
|
|
170
|
-
})
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Emit graph:updated event
|
|
174
|
-
for (const handler of this.handlers) {
|
|
175
|
-
handler({
|
|
176
|
-
type: 'graph:updated',
|
|
177
|
-
data: {
|
|
178
|
-
changedNodes: result.impactResult.changed,
|
|
179
|
-
impactedNodes: result.impactResult.impacted,
|
|
180
|
-
},
|
|
181
|
-
})
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
logger.info('Lock file updated', {
|
|
185
|
-
filesChanged: events.length,
|
|
186
|
-
mode: result.mode,
|
|
187
|
-
impactedNodes: result.impactResult.impacted.length,
|
|
188
|
-
})
|
|
189
|
-
} catch (err: any) {
|
|
190
|
-
logger.error('Failed to analyze file changes', {
|
|
191
|
-
files: events.map(e => e.path),
|
|
192
|
-
error: err.message,
|
|
193
|
-
})
|
|
194
|
-
for (const handler of this.handlers) {
|
|
195
|
-
handler({
|
|
196
|
-
type: 'sync:drifted',
|
|
197
|
-
data: {
|
|
198
|
-
reason: err.message,
|
|
199
|
-
affectedModules: events.flatMap(e => e.affectedModuleIds),
|
|
200
|
-
},
|
|
201
|
-
})
|
|
202
|
-
}
|
|
203
|
-
throw err
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ─── Sync State ───────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
/** Write sync state atomically (write to temp, then rename) */
|
|
210
|
-
private async writeSyncState(state: SyncState): Promise<void> {
|
|
211
|
-
const mikkDir = path.join(this.config.projectRoot, '.mikk')
|
|
212
|
-
await fs.mkdir(mikkDir, { recursive: true })
|
|
213
|
-
const statePath = path.join(mikkDir, 'sync-state.json')
|
|
214
|
-
const tmpPath = statePath + '.tmp'
|
|
215
|
-
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf-8')
|
|
216
|
-
await fs.rename(tmpPath, statePath)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ─── PID File ─────────────────────────────────────────────────
|
|
220
|
-
|
|
221
|
-
private async writePidFile(): Promise<void> {
|
|
222
|
-
const mikkDir = path.join(this.config.projectRoot, '.mikk')
|
|
223
|
-
await fs.mkdir(mikkDir, { recursive: true })
|
|
224
|
-
const pidPath = path.join(mikkDir, 'watcher.pid')
|
|
225
|
-
await fs.writeFile(pidPath, String(process.pid), 'utf-8')
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
private async removePidFile(): Promise<void> {
|
|
229
|
-
const pidPath = path.join(this.config.projectRoot, '.mikk', 'watcher.pid')
|
|
230
|
-
try { await fs.unlink(pidPath) } catch { /* ignore if missing */ }
|
|
231
|
-
}
|
|
232
|
-
}
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs/promises'
|
|
3
|
+
import {
|
|
4
|
+
GraphBuilder, LockCompiler, LockReader, ContractReader,
|
|
5
|
+
parseFiles, readFileContent, discoverFiles, logger,
|
|
6
|
+
type DependencyGraph, type MikkLock, type MikkContract
|
|
7
|
+
} from '@getmikk/core'
|
|
8
|
+
import { FileWatcher } from './file-watcher.js'
|
|
9
|
+
import { IncrementalAnalyzer } from './incremental-analyzer.js'
|
|
10
|
+
import type { WatcherConfig, WatcherEvent, FileChangeEvent } from './types.js'
|
|
11
|
+
|
|
12
|
+
/** Sync state persisted to .mikk/sync-state.json */
|
|
13
|
+
interface SyncState {
|
|
14
|
+
status: 'clean' | 'syncing' | 'drifted' | 'conflict'
|
|
15
|
+
lastUpdated: number
|
|
16
|
+
filesInFlight?: number
|
|
17
|
+
rootHash?: string
|
|
18
|
+
error?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* WatcherDaemon — long-running background process.
|
|
23
|
+
* Starts the FileWatcher, handles the IncrementalAnalyzer,
|
|
24
|
+
* writes updates to the lock file, and manages sync state.
|
|
25
|
+
*
|
|
26
|
+
* Features:
|
|
27
|
+
* - Debounces file changes (100ms window)
|
|
28
|
+
* - Batch threshold: if > 15 files in a batch, runs full analysis
|
|
29
|
+
* - PID file for single-instance enforcement
|
|
30
|
+
* - Atomic sync state writes
|
|
31
|
+
*/
|
|
32
|
+
export class WatcherDaemon {
|
|
33
|
+
private watcher: FileWatcher
|
|
34
|
+
private analyzer: IncrementalAnalyzer | null = null
|
|
35
|
+
private lock: MikkLock | null = null
|
|
36
|
+
private contract: MikkContract | null = null
|
|
37
|
+
private handlers: ((event: WatcherEvent) => void)[] = []
|
|
38
|
+
private pendingEvents: FileChangeEvent[] = []
|
|
39
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
|
+
private processing = false
|
|
41
|
+
|
|
42
|
+
constructor(private config: WatcherConfig) {
|
|
43
|
+
this.watcher = new FileWatcher(config)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async start(): Promise<void> {
|
|
47
|
+
// Write PID file for single-instance enforcement
|
|
48
|
+
await this.writePidFile()
|
|
49
|
+
|
|
50
|
+
// Load existing contract and lock
|
|
51
|
+
const contractReader = new ContractReader()
|
|
52
|
+
const lockReader = new LockReader()
|
|
53
|
+
const contractPath = path.join(this.config.projectRoot, 'mikk.json')
|
|
54
|
+
const lockPath = path.join(this.config.projectRoot, 'mikk.lock.json')
|
|
55
|
+
|
|
56
|
+
this.contract = await contractReader.read(contractPath)
|
|
57
|
+
this.lock = await lockReader.read(lockPath)
|
|
58
|
+
|
|
59
|
+
// Parse all files to populate the analyzer
|
|
60
|
+
const filePaths = await discoverFiles(this.config.projectRoot)
|
|
61
|
+
const parsedFiles = await parseFiles(filePaths, this.config.projectRoot, (fp) =>
|
|
62
|
+
readFileContent(fp)
|
|
63
|
+
)
|
|
64
|
+
const graph = new GraphBuilder().build(parsedFiles)
|
|
65
|
+
|
|
66
|
+
this.analyzer = new IncrementalAnalyzer(graph, this.lock, this.contract, this.config.projectRoot)
|
|
67
|
+
|
|
68
|
+
// Add all parsed files to the analyzer
|
|
69
|
+
for (const file of parsedFiles) {
|
|
70
|
+
this.analyzer.addParsedFile(file)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Subscribe to file changes with debouncing
|
|
74
|
+
this.watcher.on(async (event: WatcherEvent) => {
|
|
75
|
+
if (event.type === 'file:changed') {
|
|
76
|
+
this.enqueueChange(event.data)
|
|
77
|
+
}
|
|
78
|
+
// Forward events to external handlers
|
|
79
|
+
for (const handler of this.handlers) {
|
|
80
|
+
handler(event)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
this.watcher.start()
|
|
85
|
+
await this.writeSyncState({ status: 'clean', lastUpdated: Date.now() })
|
|
86
|
+
logger.info('Mikk watcher started', { watching: this.config.include })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async stop(): Promise<void> {
|
|
90
|
+
await this.watcher.stop()
|
|
91
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
92
|
+
await this.removePidFile()
|
|
93
|
+
logger.info('Mikk watcher stopped')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
on(handler: (event: WatcherEvent) => void): void {
|
|
97
|
+
this.handlers.push(handler)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Debounce & Batch Processing ──────────────────────────────
|
|
101
|
+
|
|
102
|
+
private enqueueChange(event: FileChangeEvent): void {
|
|
103
|
+
this.pendingEvents.push(event)
|
|
104
|
+
|
|
105
|
+
// Reset the debounce timer
|
|
106
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
|
107
|
+
this.debounceTimer = setTimeout(() => {
|
|
108
|
+
this.flushPendingEvents()
|
|
109
|
+
}, this.config.debounceMs || 100)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private async flushPendingEvents(): Promise<void> {
|
|
113
|
+
if (this.processing || this.pendingEvents.length === 0) return
|
|
114
|
+
this.processing = true
|
|
115
|
+
|
|
116
|
+
const events = [...this.pendingEvents]
|
|
117
|
+
this.pendingEvents = []
|
|
118
|
+
|
|
119
|
+
// Deduplicate by path (keep latest event per file)
|
|
120
|
+
const byPath = new Map<string, FileChangeEvent>()
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
byPath.set(event.path, event)
|
|
123
|
+
}
|
|
124
|
+
const dedupedEvents = [...byPath.values()]
|
|
125
|
+
|
|
126
|
+
await this.writeSyncState({
|
|
127
|
+
status: 'syncing',
|
|
128
|
+
lastUpdated: Date.now(),
|
|
129
|
+
filesInFlight: dedupedEvents.length,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await this.processBatch(dedupedEvents)
|
|
134
|
+
await this.writeSyncState({
|
|
135
|
+
status: 'clean',
|
|
136
|
+
lastUpdated: Date.now(),
|
|
137
|
+
})
|
|
138
|
+
} catch (err: any) {
|
|
139
|
+
await this.writeSyncState({
|
|
140
|
+
status: 'drifted',
|
|
141
|
+
lastUpdated: Date.now(),
|
|
142
|
+
error: err.message,
|
|
143
|
+
})
|
|
144
|
+
} finally {
|
|
145
|
+
this.processing = false
|
|
146
|
+
|
|
147
|
+
// If more events arrived during processing, flush again
|
|
148
|
+
if (this.pendingEvents.length > 0) {
|
|
149
|
+
this.flushPendingEvents()
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async processBatch(events: FileChangeEvent[]): Promise<void> {
|
|
155
|
+
if (!this.analyzer || !this.lock) return
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = await this.analyzer.analyzeBatch(events)
|
|
159
|
+
this.lock = result.lock
|
|
160
|
+
|
|
161
|
+
// Write updated lock
|
|
162
|
+
const lockPath = path.join(this.config.projectRoot, 'mikk.lock.json')
|
|
163
|
+
await fs.writeFile(lockPath, JSON.stringify(this.lock, null, 2), 'utf-8')
|
|
164
|
+
|
|
165
|
+
// Log batch info
|
|
166
|
+
if (result.mode === 'full') {
|
|
167
|
+
logger.info('Full re-analysis completed', {
|
|
168
|
+
filesChanged: events.length,
|
|
169
|
+
reason: 'Large batch detected (> 15 files)',
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Emit graph:updated event
|
|
174
|
+
for (const handler of this.handlers) {
|
|
175
|
+
handler({
|
|
176
|
+
type: 'graph:updated',
|
|
177
|
+
data: {
|
|
178
|
+
changedNodes: result.impactResult.changed,
|
|
179
|
+
impactedNodes: result.impactResult.impacted,
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
logger.info('Lock file updated', {
|
|
185
|
+
filesChanged: events.length,
|
|
186
|
+
mode: result.mode,
|
|
187
|
+
impactedNodes: result.impactResult.impacted.length,
|
|
188
|
+
})
|
|
189
|
+
} catch (err: any) {
|
|
190
|
+
logger.error('Failed to analyze file changes', {
|
|
191
|
+
files: events.map(e => e.path),
|
|
192
|
+
error: err.message,
|
|
193
|
+
})
|
|
194
|
+
for (const handler of this.handlers) {
|
|
195
|
+
handler({
|
|
196
|
+
type: 'sync:drifted',
|
|
197
|
+
data: {
|
|
198
|
+
reason: err.message,
|
|
199
|
+
affectedModules: events.flatMap(e => e.affectedModuleIds),
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
throw err
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── Sync State ───────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/** Write sync state atomically (write to temp, then rename) */
|
|
210
|
+
private async writeSyncState(state: SyncState): Promise<void> {
|
|
211
|
+
const mikkDir = path.join(this.config.projectRoot, '.mikk')
|
|
212
|
+
await fs.mkdir(mikkDir, { recursive: true })
|
|
213
|
+
const statePath = path.join(mikkDir, 'sync-state.json')
|
|
214
|
+
const tmpPath = statePath + '.tmp'
|
|
215
|
+
await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), 'utf-8')
|
|
216
|
+
await fs.rename(tmpPath, statePath)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── PID File ─────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
private async writePidFile(): Promise<void> {
|
|
222
|
+
const mikkDir = path.join(this.config.projectRoot, '.mikk')
|
|
223
|
+
await fs.mkdir(mikkDir, { recursive: true })
|
|
224
|
+
const pidPath = path.join(mikkDir, 'watcher.pid')
|
|
225
|
+
await fs.writeFile(pidPath, String(process.pid), 'utf-8')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private async removePidFile(): Promise<void> {
|
|
229
|
+
const pidPath = path.join(this.config.projectRoot, '.mikk', 'watcher.pid')
|
|
230
|
+
try { await fs.unlink(pidPath) } catch { /* ignore if missing */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/file-watcher.ts
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
1
|
-
import * as path from 'node:path'
|
|
2
|
-
import { watch } from 'chokidar'
|
|
3
|
-
import { hashFile } from '@getmikk/core'
|
|
4
|
-
import type { WatcherConfig, WatcherEvent, FileChangeEvent } from './types.js'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* FileWatcher — wraps Chokidar to watch filesystem for changes.
|
|
8
|
-
* Computes hash of changed files and emits typed events.
|
|
9
|
-
*/
|
|
10
|
-
export class FileWatcher {
|
|
11
|
-
private watcher: ReturnType<typeof watch> | null = null
|
|
12
|
-
private handlers: ((event: WatcherEvent) => void)[] = []
|
|
13
|
-
private hashStore = new Map<string, string>()
|
|
14
|
-
|
|
15
|
-
constructor(private config: WatcherConfig) { }
|
|
16
|
-
|
|
17
|
-
/** Start watching — non-blocking */
|
|
18
|
-
start(): void {
|
|
19
|
-
this.watcher = watch(this.config.include, {
|
|
20
|
-
ignored: this.config.exclude,
|
|
21
|
-
cwd: this.config.projectRoot,
|
|
22
|
-
ignoreInitial: true,
|
|
23
|
-
persistent: true,
|
|
24
|
-
awaitWriteFinish: {
|
|
25
|
-
stabilityThreshold: 50,
|
|
26
|
-
pollInterval: 10,
|
|
27
|
-
},
|
|
28
|
-
})
|
|
29
|
-
|
|
30
|
-
this.watcher.on('change', (relativePath: string) => {
|
|
31
|
-
this.handleChange(relativePath, 'changed')
|
|
32
|
-
})
|
|
33
|
-
this.watcher.on('add', (relativePath: string) => {
|
|
34
|
-
this.handleChange(relativePath, 'added')
|
|
35
|
-
})
|
|
36
|
-
this.watcher.on('unlink', (relativePath: string) => {
|
|
37
|
-
this.handleChange(relativePath, 'deleted')
|
|
38
|
-
})
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Stop watching */
|
|
42
|
-
async stop(): Promise<void> {
|
|
43
|
-
await this.watcher?.close()
|
|
44
|
-
this.watcher = null
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Register an event handler */
|
|
48
|
-
on(handler: (event: WatcherEvent) => void): void {
|
|
49
|
-
this.handlers.push(handler)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Set initial hash for a file */
|
|
53
|
-
setHash(filePath: string, hash: string): void {
|
|
54
|
-
this.hashStore.set(filePath, hash)
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
private async handleChange(relativePath: string, type: FileChangeEvent['type']): Promise<void> {
|
|
58
|
-
const fullPath = path.join(this.config.projectRoot, relativePath)
|
|
59
|
-
const normalizedPath = relativePath.replace(/\\/g, '/')
|
|
60
|
-
const oldHash = this.hashStore.get(normalizedPath) || null
|
|
61
|
-
|
|
62
|
-
let newHash: string | null = null
|
|
63
|
-
if (type !== 'deleted') {
|
|
64
|
-
try {
|
|
65
|
-
newHash = await hashFile(fullPath)
|
|
66
|
-
} catch {
|
|
67
|
-
return // File might have been deleted before we could read it
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (oldHash === newHash) return // Content unchanged
|
|
72
|
-
|
|
73
|
-
if (newHash) this.hashStore.set(normalizedPath, newHash)
|
|
74
|
-
if (type === 'deleted') this.hashStore.delete(normalizedPath)
|
|
75
|
-
|
|
76
|
-
const event: FileChangeEvent = {
|
|
77
|
-
type,
|
|
78
|
-
path: normalizedPath,
|
|
79
|
-
oldHash,
|
|
80
|
-
newHash,
|
|
81
|
-
timestamp: Date.now(),
|
|
82
|
-
affectedModuleIds: [], // filled by IncrementalAnalyzer
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
this.emit({ type: 'file:changed', data: event })
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
private emit(event: WatcherEvent): void {
|
|
89
|
-
for (const handler of this.handlers) {
|
|
90
|
-
handler(event)
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
}
|
|
1
|
+
import * as path from 'node:path'
|
|
2
|
+
import { watch } from 'chokidar'
|
|
3
|
+
import { hashFile } from '@getmikk/core'
|
|
4
|
+
import type { WatcherConfig, WatcherEvent, FileChangeEvent } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* FileWatcher — wraps Chokidar to watch filesystem for changes.
|
|
8
|
+
* Computes hash of changed files and emits typed events.
|
|
9
|
+
*/
|
|
10
|
+
export class FileWatcher {
|
|
11
|
+
private watcher: ReturnType<typeof watch> | null = null
|
|
12
|
+
private handlers: ((event: WatcherEvent) => void)[] = []
|
|
13
|
+
private hashStore = new Map<string, string>()
|
|
14
|
+
|
|
15
|
+
constructor(private config: WatcherConfig) { }
|
|
16
|
+
|
|
17
|
+
/** Start watching — non-blocking */
|
|
18
|
+
start(): void {
|
|
19
|
+
this.watcher = watch(this.config.include, {
|
|
20
|
+
ignored: this.config.exclude,
|
|
21
|
+
cwd: this.config.projectRoot,
|
|
22
|
+
ignoreInitial: true,
|
|
23
|
+
persistent: true,
|
|
24
|
+
awaitWriteFinish: {
|
|
25
|
+
stabilityThreshold: 50,
|
|
26
|
+
pollInterval: 10,
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
this.watcher.on('change', (relativePath: string) => {
|
|
31
|
+
this.handleChange(relativePath, 'changed')
|
|
32
|
+
})
|
|
33
|
+
this.watcher.on('add', (relativePath: string) => {
|
|
34
|
+
this.handleChange(relativePath, 'added')
|
|
35
|
+
})
|
|
36
|
+
this.watcher.on('unlink', (relativePath: string) => {
|
|
37
|
+
this.handleChange(relativePath, 'deleted')
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Stop watching */
|
|
42
|
+
async stop(): Promise<void> {
|
|
43
|
+
await this.watcher?.close()
|
|
44
|
+
this.watcher = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Register an event handler */
|
|
48
|
+
on(handler: (event: WatcherEvent) => void): void {
|
|
49
|
+
this.handlers.push(handler)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Set initial hash for a file */
|
|
53
|
+
setHash(filePath: string, hash: string): void {
|
|
54
|
+
this.hashStore.set(filePath, hash)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private async handleChange(relativePath: string, type: FileChangeEvent['type']): Promise<void> {
|
|
58
|
+
const fullPath = path.join(this.config.projectRoot, relativePath)
|
|
59
|
+
const normalizedPath = relativePath.replace(/\\/g, '/')
|
|
60
|
+
const oldHash = this.hashStore.get(normalizedPath) || null
|
|
61
|
+
|
|
62
|
+
let newHash: string | null = null
|
|
63
|
+
if (type !== 'deleted') {
|
|
64
|
+
try {
|
|
65
|
+
newHash = await hashFile(fullPath)
|
|
66
|
+
} catch {
|
|
67
|
+
return // File might have been deleted before we could read it
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (oldHash === newHash) return // Content unchanged
|
|
72
|
+
|
|
73
|
+
if (newHash) this.hashStore.set(normalizedPath, newHash)
|
|
74
|
+
if (type === 'deleted') this.hashStore.delete(normalizedPath)
|
|
75
|
+
|
|
76
|
+
const event: FileChangeEvent = {
|
|
77
|
+
type,
|
|
78
|
+
path: normalizedPath,
|
|
79
|
+
oldHash,
|
|
80
|
+
newHash,
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
affectedModuleIds: [], // filled by IncrementalAnalyzer
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
this.emit({ type: 'file:changed', data: event })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private emit(event: WatcherEvent): void {
|
|
89
|
+
for (const handler of this.handlers) {
|
|
90
|
+
handler(event)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|