@getmikk/watcher 1.8.0 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmikk/watcher",
3
- "version": "1.8.0",
3
+ "version": "2.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "dev": "tsc --watch"
22
22
  },
23
23
  "dependencies": {
24
- "@getmikk/core": "^1.8.0",
24
+ "@getmikk/core": "^2.0.0",
25
25
  "chokidar": "^4.0.0"
26
26
  },
27
27
  "devDependencies": {
@@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises'
2
2
  import * as path from 'node:path'
3
3
  import {
4
4
  getParser, GraphBuilder, ImpactAnalyzer, LockCompiler, hashFile,
5
- type ParsedFile, type DependencyGraph, type MikkLock, type MikkContract, type ImpactResult
5
+ type ParsedFile, type DependencyGraph, type MikkLock, type MikkContract, type ImpactResult, type GraphEdge
6
6
  } from '@getmikk/core'
7
7
  import type { FileChangeEvent } from './types.js'
8
8
 
@@ -13,14 +13,9 @@ const FULL_ANALYSIS_THRESHOLD = 15
13
13
  const MAX_RETRIES = 3
14
14
 
15
15
  /**
16
- * IncrementalAnalyzer — re-parses only changed files, updates graph nodes,
17
- * and recomputes affected module hashes. O(changed files) not O(whole repo).
18
- *
19
- * Supports batch analysis: if > 15 files change at once (e.g. git checkout),
20
- * runs a full re-analysis instead of incremental.
21
- *
22
- * Race condition handling: after parsing, re-hashes the file and re-parses
23
- * if the content changed during parsing (up to 3 retries).
16
+ * IncrementalAnalyzer — re-parses only changed files, performs a surgical
17
+ * graph update (removes stale nodes/edges, inserts new ones), then runs
18
+ * impact analysis over the affected subgraph.
24
19
  */
25
20
  export class IncrementalAnalyzer {
26
21
  private parsedFiles: Map<string, ParsedFile> = new Map()
@@ -30,59 +25,96 @@ export class IncrementalAnalyzer {
30
25
  private lock: MikkLock,
31
26
  private contract: MikkContract,
32
27
  private projectRoot: string
33
- ) { }
28
+ ) {
29
+ if (!this.graph.outEdges) this.graph.outEdges = new Map()
30
+ if (!this.graph.inEdges) this.graph.inEdges = new Map()
31
+ }
34
32
 
35
- /** Handle a batch of file change events (debounced by daemon) */
33
+ public get fileCount(): number {
34
+ return this.parsedFiles.size
35
+ }
36
+
37
+ /** Handle a batch of file change events */
36
38
  async analyzeBatch(events: FileChangeEvent[]): Promise<{
37
39
  graph: DependencyGraph
38
40
  lock: MikkLock
39
41
  impactResult: ImpactResult
40
42
  mode: 'incremental' | 'full'
41
43
  }> {
42
- // If too many changes at once, run full analysis
43
44
  if (events.length > FULL_ANALYSIS_THRESHOLD) {
44
45
  return this.runFullAnalysis(events)
45
46
  }
46
47
 
47
- // Incremental: process each event, collecting changed file paths
48
48
  const changedFilePaths: string[] = []
49
49
 
50
50
  for (const event of events) {
51
51
  if (event.type === 'deleted') {
52
52
  this.parsedFiles.delete(event.path)
53
- changedFilePaths.push(event.path)
54
53
  } else {
55
54
  const parsed = await this.parseWithRaceCheck(event.path)
56
- if (parsed) {
57
- this.parsedFiles.set(event.path, parsed)
58
- }
59
- changedFilePaths.push(event.path)
55
+ if (parsed) this.parsedFiles.set(event.path, parsed)
60
56
  }
57
+ changedFilePaths.push(event.path)
61
58
  }
62
59
 
63
- // Rebuild graph from all parsed files BEFORE deriving node IDs,
64
- // so newly-added files are present in the graph when we look them up.
65
- const allParsedFiles = [...this.parsedFiles.values()]
66
- const builder = new GraphBuilder()
67
- this.graph = builder.build(allParsedFiles)
60
+ // --- Surgical graph update ---
61
+ const staleNodeIds = new Set<string>(
62
+ changedFilePaths.flatMap(fp => this.findNodeIdsForFile(fp))
63
+ )
64
+
65
+ for (const nodeId of staleNodeIds) {
66
+ this.graph.nodes.delete(nodeId)
67
+ }
68
+ for (const fp of changedFilePaths) {
69
+ this.graph.nodes.delete(fp)
70
+ }
71
+
72
+ const allStaleIds = new Set([...staleNodeIds, ...changedFilePaths])
73
+ this.graph.edges = this.graph.edges.filter(
74
+ edge => !allStaleIds.has(edge.from) && !allStaleIds.has(edge.to)
75
+ )
76
+
77
+ const changedParsedFiles = changedFilePaths
78
+ .map(fp => this.parsedFiles.get(fp))
79
+ .filter((f): f is ParsedFile => f !== undefined)
80
+
81
+ if (changedParsedFiles.length > 0) {
82
+ const miniBuilder = new GraphBuilder()
83
+ const miniGraph = miniBuilder.build(changedParsedFiles)
84
+
85
+ for (const [id, node] of miniGraph.nodes) {
86
+ this.graph.nodes.set(id, node)
87
+ }
88
+
89
+ for (const edge of miniGraph.edges) {
90
+ this.graph.edges.push(edge)
91
+ }
92
+ }
93
+
94
+ // Rebuild adjacency maps
95
+ this.graph.outEdges = new Map()
96
+ this.graph.inEdges = new Map()
97
+ for (const edge of this.graph.edges) {
98
+ if (!this.graph.outEdges.has(edge.from)) this.graph.outEdges.set(edge.from, [])
99
+ this.graph.outEdges.get(edge.from)!.push(edge)
100
+ if (!this.graph.inEdges.has(edge.to)) this.graph.inEdges.set(edge.to, [])
101
+ this.graph.inEdges.get(edge.to)!.push(edge)
102
+ }
68
103
 
69
- // Map changed file paths → graph node IDs using the updated graph
70
- const changedNodeIds = [...new Set(
71
- changedFilePaths.flatMap(fp => this.findAffectedNodes(fp))
72
- )]
104
+ const changedNodeIds = [
105
+ ...new Set(changedFilePaths.flatMap(fp => this.findNodeIdsForFile(fp)))
106
+ ]
73
107
 
74
- // Run impact analysis on all changed nodes
75
108
  const analyzer = new ImpactAnalyzer(this.graph)
76
109
  const impactResult = analyzer.analyze(changedNodeIds)
77
110
 
78
- // Recompile lock
111
+ const allParsedFiles = [...this.parsedFiles.values()]
79
112
  const compiler = new LockCompiler()
80
- this.lock = compiler.compile(this.graph, this.contract, allParsedFiles)
113
+ this.lock = compiler.compile(this.graph, this.contract, allParsedFiles, undefined, this.projectRoot)
81
114
 
82
115
  return { graph: this.graph, lock: this.lock, impactResult, mode: 'incremental' }
83
116
  }
84
117
 
85
- /** Handle a single file change event */
86
118
  async analyze(event: FileChangeEvent): Promise<{
87
119
  graph: DependencyGraph
88
120
  lock: MikkLock
@@ -92,112 +124,75 @@ export class IncrementalAnalyzer {
92
124
  return { graph: result.graph, lock: result.lock, impactResult: result.impactResult }
93
125
  }
94
126
 
95
- /** Add a parsed file to the tracker */
96
127
  addParsedFile(file: ParsedFile): void {
97
128
  this.parsedFiles.set(file.path, file)
98
129
  }
99
130
 
100
- /** Get the current parsed file count */
101
- get fileCount(): number {
102
- return this.parsedFiles.size
103
- }
104
-
105
- // ─── Private ──────────────────────────────────────────────────
106
-
107
- /**
108
- * Parse a file with race-condition detection.
109
- * After parsing, re-hash the file. If the hash differs from what we started with,
110
- * the file changed during parsing — re-parse (up to MAX_RETRIES).
111
- */
112
131
  private async parseWithRaceCheck(changedFile: string): Promise<ParsedFile | null> {
113
132
  const fullPath = path.join(this.projectRoot, changedFile)
114
-
115
133
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
116
134
  try {
117
135
  const content = await fs.readFile(fullPath, 'utf-8')
118
136
  const parser = getParser(changedFile)
119
137
  const parsedFile = await parser.parse(changedFile, content)
120
138
 
121
- // Race condition check: re-hash after parse
122
139
  try {
123
140
  const postParseHash = await hashFile(fullPath)
124
- if (postParseHash === parsedFile.hash) {
125
- return parsedFile // Content stable
126
- }
127
- // Content changed during parse — retry
141
+ if (postParseHash === parsedFile.hash) return parsedFile
128
142
  } catch {
129
- return parsedFile // File may have been deleted, return what we have
143
+ return parsedFile
130
144
  }
131
145
  } catch {
132
- return null // File unreadable
146
+ return null
133
147
  }
134
148
  }
135
-
136
- // Exhausted retries — parse one final time and accept
137
- try {
138
- const content = await fs.readFile(fullPath, 'utf-8')
139
- const parser = getParser(changedFile)
140
- return await parser.parse(changedFile, content)
141
- } catch {
142
- return null
143
- }
149
+ return null
144
150
  }
145
151
 
146
- /** Run a full re-analysis (for large batches like git checkout) */
147
152
  private async runFullAnalysis(events: FileChangeEvent[]): Promise<{
148
153
  graph: DependencyGraph
149
154
  lock: MikkLock
150
155
  impactResult: ImpactResult
151
156
  mode: 'full'
152
157
  }> {
153
- // Remove deleted files
154
158
  for (const event of events) {
155
- if (event.type === 'deleted') {
156
- this.parsedFiles.delete(event.path)
157
- }
159
+ if (event.type === 'deleted') this.parsedFiles.delete(event.path)
158
160
  }
159
161
 
160
- // Re-parse all non-deleted changed files
161
162
  const nonDeleted = events.filter(e => e.type !== 'deleted')
162
163
  await Promise.all(nonDeleted.map(async (event) => {
163
164
  const parsed = await this.parseWithRaceCheck(event.path)
164
- if (parsed) {
165
- this.parsedFiles.set(event.path, parsed)
166
- }
165
+ if (parsed) this.parsedFiles.set(event.path, parsed)
167
166
  }))
168
167
 
169
- // Full rebuild
170
168
  const allParsedFiles = [...this.parsedFiles.values()]
171
169
  const builder = new GraphBuilder()
172
170
  this.graph = builder.build(allParsedFiles)
173
171
 
174
172
  const compiler = new LockCompiler()
175
- this.lock = compiler.compile(this.graph, this.contract, allParsedFiles)
176
-
177
- const changedPaths = events.map(e => e.path)
178
-
179
- return {
180
- graph: this.graph,
181
- lock: this.lock,
182
- impactResult: {
183
- changed: changedPaths,
184
- impacted: [],
185
- classified: {
186
- critical: [],
187
- high: [],
188
- medium: [],
189
- low: [],
190
- },
191
- depth: 0,
192
- confidence: 'low', // Full rebuild = can't determine precise impact
193
- },
194
- mode: 'full',
173
+ this.lock = compiler.compile(this.graph, this.contract, allParsedFiles, undefined, this.projectRoot)
174
+
175
+ const impactResult: ImpactResult = {
176
+ changed: events.map(e => e.path),
177
+ impacted: [],
178
+ allImpacted: [],
179
+ depth: 0,
180
+ entryPoints: [],
181
+ criticalModules: [],
182
+ paths: [],
183
+ confidence: 1.0,
184
+ riskScore: 0,
185
+ classified: { critical: [], high: [], medium: [], low: [] }
195
186
  }
187
+
188
+ return { graph: this.graph, lock: this.lock, impactResult, mode: 'full' }
196
189
  }
197
190
 
198
- private findAffectedNodes(filePath: string): string[] {
199
- return [...this.graph.nodes.values()]
200
- .filter(n => n.file === filePath)
201
- .map(n => n.id)
191
+ private findNodeIdsForFile(filePath: string): string[] {
192
+ const ids: string[] = []
193
+ for (const [id, node] of this.graph.nodes) {
194
+ if (node.file === filePath) ids.push(id)
195
+ }
196
+ return ids
202
197
  }
203
198
  }
@@ -1,91 +1,158 @@
1
1
  import { describe, it, expect } from 'bun:test'
2
2
  import { IncrementalAnalyzer } from '../src/incremental-analyzer.js'
3
- import type { MikkLock, ParsedFile } from '@getmikk/core'
3
+ import type { MikkLock, DependencyGraph, MikkContract } from '@getmikk/core'
4
+ import type { FileChangeEvent } from '../src/types.js'
4
5
 
5
6
  describe('IncrementalAnalyzer', () => {
6
- it('detects changes correctly without throwing', async () => {
7
- const mockLock: MikkLock = {
8
- version: '1',
9
- lastUpdated: new Date().toISOString(),
10
- files: {
11
- 'src/index.ts': {
12
- hash: 'abc',
13
- lastModified: new Date().toISOString(),
14
- path: 'src/index.ts',
15
- moduleId: 'root',
16
- imports: [],
17
- exports: []
18
- }
19
- },
20
- functions: {},
21
- classes: {},
22
- modules: {}
7
+ const mockGraph = (): DependencyGraph => ({
8
+ nodes: new Map(),
9
+ edges: [],
10
+ outEdges: new Map(),
11
+ inEdges: new Map()
12
+ })
13
+
14
+ const mockLock: MikkLock = {
15
+ version: '2.0.0',
16
+ generatedAt: new Date().toISOString(),
17
+ generatorVersion: '1.0.0',
18
+ projectRoot: '/project',
19
+ syncState: {
20
+ status: 'clean',
21
+ lastSyncAt: new Date().toISOString(),
22
+ lockHash: 'abc',
23
+ contractHash: 'xyz'
24
+ },
25
+ files: {
26
+ 'src/index.ts': {
27
+ path: 'src/index.ts',
28
+ hash: 'abc',
29
+ moduleId: 'root',
30
+ lastModified: new Date().toISOString(),
31
+ imports: []
32
+ }
33
+ },
34
+ functions: {},
35
+ classes: {},
36
+ modules: {},
37
+ graph: {
38
+ nodes: 1,
39
+ edges: 0,
40
+ rootHash: 'abc'
23
41
  }
42
+ }
24
43
 
44
+ const contract: MikkContract = {
45
+ version: '2.0.0',
46
+ project: {
47
+ name: 'test',
48
+ description: 'test project',
49
+ language: 'typescript',
50
+ framework: 'none',
51
+ entryPoints: []
52
+ },
53
+ declared: {
54
+ modules: [],
55
+ constraints: [],
56
+ decisions: []
57
+ },
58
+ overwrite: {
59
+ mode: 'never',
60
+ requireConfirmation: false
61
+ },
62
+ policies: {
63
+ maxRiskScore: 70,
64
+ maxImpactNodes: 10,
65
+ protectedModules: [],
66
+ enforceStrictBoundaries: true,
67
+ requireReasoningForCritical: true
68
+ }
69
+ }
70
+
71
+ it('detects changes correctly without throwing', async () => {
25
72
  const analyzer = new IncrementalAnalyzer(
26
- { nodes: new Map(), edges: [] },
73
+ mockGraph(),
27
74
  mockLock,
28
- {
29
- project: { name: 'test', language: 'typescript', framework: null },
30
- declared: { modules: [], constraints: [], decisions: [] },
31
- overwrite: { mode: 'never', requireConfirmation: false }
32
- },
75
+ contract,
33
76
  '/project'
34
77
  )
35
78
 
36
- // This simulates a file change event
37
- const result = await analyzer.analyze({ path: 'src/index.ts', type: 'modified' })
79
+ const event: FileChangeEvent = {
80
+ path: 'src/index.ts',
81
+ type: 'changed',
82
+ oldHash: 'old',
83
+ newHash: 'abc',
84
+ timestamp: Date.now(),
85
+ affectedModuleIds: []
86
+ }
87
+ const result = await analyzer.analyze(event)
38
88
  expect(result.graph).toBeDefined()
39
89
  expect(result.lock).toBeDefined()
40
90
  expect(result.impactResult).toBeDefined()
41
91
  })
42
92
 
43
93
  describe('Edge Cases and Batch Processing', () => {
44
- const mockLock: MikkLock = {
45
- version: '1',
46
- lastUpdated: new Date().toISOString(),
47
- files: {}, functions: {}, classes: {}, modules: {}
48
- }
49
-
50
- const contract = {
51
- project: { name: 'test', language: 'typescript', framework: null },
52
- declared: { modules: [], constraints: [], decisions: [] },
53
- overwrite: { mode: 'never', requireConfirmation: false }
54
- }
55
-
56
94
  it('handles file deletions by removing nodes from graph and lock', async () => {
57
- const analyzer = new IncrementalAnalyzer({ nodes: new Map(), edges: [] }, mockLock, contract, '/project')
95
+ const analyzer = new IncrementalAnalyzer(mockGraph(), mockLock, contract, '/project')
58
96
  // First add it
59
- analyzer.addParsedFile({ path: 'src/to-delete.ts', language: 'ts', hash: 'foo', parsedAt: Date.now(), functions: [], classes: [], imports: [], exports: [], routes: [] })
97
+ analyzer.addParsedFile({
98
+ path: 'src/to-delete.ts',
99
+ language: 'typescript',
100
+ hash: 'foo',
101
+ parsedAt: Date.now(),
102
+ functions: [],
103
+ classes: [],
104
+ imports: [],
105
+ exports: [],
106
+ routes: [],
107
+ variables: [],
108
+ generics: [],
109
+ calls: []
110
+ })
60
111
  expect(analyzer.fileCount).toBe(1)
61
112
 
62
113
  // Now send a deleted event
63
- await analyzer.analyze({ path: 'src/to-delete.ts', type: 'deleted' })
114
+ const event: FileChangeEvent = {
115
+ path: 'src/to-delete.ts',
116
+ type: 'deleted',
117
+ oldHash: 'foo',
118
+ newHash: '',
119
+ timestamp: Date.now(),
120
+ affectedModuleIds: []
121
+ }
122
+ await analyzer.analyze(event)
64
123
  expect(analyzer.fileCount).toBe(0)
65
124
  })
66
125
 
67
126
  it('survives analyze events on completely non-existent OS files gracefully', async () => {
68
- const analyzer = new IncrementalAnalyzer({ nodes: new Map(), edges: [] }, mockLock, contract, '/project')
69
- // Will fail to fs.readFile inside parseWithRaceCheck
70
- const result = await analyzer.analyze({ path: 'does/not/exist.ts', type: 'modified' })
71
- expect(result.mode).toBeUndefined() // Returns incremental by default
127
+ const analyzer = new IncrementalAnalyzer(mockGraph(), mockLock, contract, '/project')
128
+ const event: FileChangeEvent = {
129
+ path: 'does/not/exist.ts',
130
+ type: 'changed',
131
+ oldHash: '',
132
+ newHash: 'new',
133
+ timestamp: Date.now(),
134
+ affectedModuleIds: []
135
+ }
136
+ const result = await analyzer.analyzeBatch([event])
137
+ expect(result.mode).toBe('incremental')
72
138
  expect(result.impactResult).toBeDefined()
73
- // Should not have crashed the analyzer
74
139
  expect(analyzer.fileCount).toBe(0)
75
140
  })
76
141
 
77
142
  it('triggers a full re-analysis if file batch exceeds FULL_ANALYSIS_THRESHOLD (15)', async () => {
78
- const analyzer = new IncrementalAnalyzer({ nodes: new Map(), edges: [] }, mockLock, contract, '/project')
79
- const events = Array.from({ length: 16 }).map((_, i) => ({
143
+ const analyzer = new IncrementalAnalyzer(mockGraph(), mockLock, contract, '/project')
144
+ const events: FileChangeEvent[] = Array.from({ length: 16 }).map((_, i) => ({
80
145
  path: `src/file_${i}.ts`,
81
- type: 'modified' as const
146
+ type: 'changed',
147
+ oldHash: '',
148
+ newHash: `hash_${i}`,
149
+ timestamp: Date.now(),
150
+ affectedModuleIds: []
82
151
  }))
83
152
 
84
153
  const result = await analyzer.analyzeBatch(events)
85
- // It should have hit runFullAnalysis, which returns mode: 'full'
86
154
  expect(result.mode).toBe('full')
87
- // It will also have gracefully continued despite the 16 files failing to load off disk
88
- expect(result.impactResult.confidence).toBe('low')
155
+ expect(result.impactResult.confidence).toBe(1.0)
89
156
  })
90
157
  })
91
158
  })