@getmikk/watcher 1.2.0 → 1.3.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.
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
+ }
@@ -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
+ }