@getmikk/intent-engine 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.
@@ -0,0 +1,295 @@
1
+ import type { MikkContract, MikkLock, DependencyGraph, ImpactResult } from '@getmikk/core'
2
+ import { ImpactAnalyzer } from '@getmikk/core'
3
+ import { IntentUnderstanding, type IntentContext } from './intent-understanding.js'
4
+ import { AutoCorrectionEngine } from './auto-correction.js'
5
+ import { EnforcedSafetyGates, type SafetyGateConfig } from './enforced-safety.js'
6
+
7
+ /**
8
+ * PreEditValidation — intercepts edits before they are applied.
9
+ *
10
+ * Combines:
11
+ * 1. Intent understanding (is this intentional?)
12
+ * 2. Real impact analysis (what breaks?)
13
+ * 3. Auto-correction (can we fix issues automatically?)
14
+ * 4. Safety gates (should we block this?)
15
+ *
16
+ * This is the single entry point for mikk_before_edit.
17
+ */
18
+ export interface EditProposal {
19
+ files: string[]
20
+ description: string
21
+ author: string
22
+ intent?: Partial<IntentContext>
23
+ }
24
+
25
+ export interface ValidationResult {
26
+ allowed: boolean
27
+ confidence: number
28
+
29
+ intent: {
30
+ isIntentionalBreakingChange: boolean
31
+ confidence: number
32
+ reasoning: string[]
33
+ riskAcceptance: 'none' | 'low' | 'medium' | 'high'
34
+ }
35
+
36
+ impact: {
37
+ totalFiles: number
38
+ totalFunctions: number
39
+ riskScore: number
40
+ criticalPaths: string[]
41
+ blastRadius: string[]
42
+ }
43
+
44
+ gates: Array<{
45
+ name: string
46
+ passed: boolean
47
+ severity: 'BLOCKING' | 'WARNING'
48
+ message: string
49
+ bypassable: boolean
50
+ }>
51
+
52
+ corrections: {
53
+ available: boolean
54
+ issuesFound: number
55
+ autoFixable: number
56
+ applied: string[]
57
+ suggested: string[]
58
+ }
59
+
60
+ recommendations: string[]
61
+ nextSteps: string[]
62
+ tokenSavings: number
63
+ }
64
+
65
+ export class PreEditValidation {
66
+ private intentEngine: IntentUnderstanding
67
+ private autoCorrection: AutoCorrectionEngine
68
+ private safetyGates: EnforcedSafetyGates
69
+ private impactAnalyzer: ImpactAnalyzer
70
+
71
+ constructor(
72
+ private contract: MikkContract,
73
+ private lock: MikkLock,
74
+ graph: DependencyGraph,
75
+ private projectRoot: string,
76
+ safetyConfig?: Partial<SafetyGateConfig>,
77
+ ) {
78
+ this.intentEngine = new IntentUnderstanding(contract, lock)
79
+ this.impactAnalyzer = new ImpactAnalyzer(graph)
80
+
81
+ // Build protected modules list from constraints that mention "protected".
82
+ // Constraints may be strings OR objects — guard both cases.
83
+ const protectedModules = (contract.declared?.constraints ?? [])
84
+ .filter(c => {
85
+ if (typeof c === 'string') return c.toLowerCase().includes('protected')
86
+ return false // object-style constraints don't auto-map to module names
87
+ })
88
+ .flatMap(c => {
89
+ const parts = (c as string).split('::')
90
+ return parts[0] ? [parts[0]] : []
91
+ })
92
+
93
+ const defaultConfig: SafetyGateConfig = {
94
+ enforceOnSave: true,
95
+ enforceOnCommit: true,
96
+ enforceInCI: true,
97
+ maxRiskScore: 70,
98
+ maxImpactNodes: 10,
99
+ requireTestsForChangedFiles: true,
100
+ requireDocumentationForApiChanges: true,
101
+ protectedModules,
102
+ }
103
+
104
+ this.safetyGates = new EnforcedSafetyGates(contract, lock, graph, { ...defaultConfig, ...safetyConfig })
105
+ this.autoCorrection = new AutoCorrectionEngine(contract, lock, graph, projectRoot)
106
+ }
107
+
108
+ /** Main validation method — call this BEFORE any edit. */
109
+ async validate(proposal: EditProposal): Promise<ValidationResult> {
110
+ const { files } = proposal
111
+
112
+ // 1. Real impact analysis from the graph
113
+ const fileNodeIds = this.collectFileNodeIds(files)
114
+ const impact = fileNodeIds.length > 0
115
+ ? this.impactAnalyzer.analyze(fileNodeIds)
116
+ : this.emptyImpact(files)
117
+
118
+ // 2. Intent analysis — receives the real ImpactResult so field shapes match
119
+ const intentAnalysis = this.intentEngine.analyzeIntent(impact, {
120
+ commitMessage: proposal.intent?.commitMessage,
121
+ branchName: proposal.intent?.branchName,
122
+ author: proposal.author,
123
+ filesChanged: files,
124
+ changeType: this.inferChangeType(proposal),
125
+ confidence: 0.7,
126
+ })
127
+
128
+ // 3. Safety gates
129
+ const gateResults = await this.safetyGates.validateEdits(files, {
130
+ commitMessage: proposal.intent?.commitMessage,
131
+ branchName: proposal.intent?.branchName,
132
+ })
133
+
134
+ // 4. Auto-correction
135
+ const corrections = await this.autoCorrection.analyzeAndFix(files)
136
+
137
+ // 5. Allowed?
138
+ const { allowed, blockingGates } = this.safetyGates.canProceed(gateResults)
139
+
140
+ // 6. Recommendations
141
+ const recommendations = this.buildRecommendations(intentAnalysis, impact, gateResults, corrections)
142
+
143
+ // 7. Token savings estimate
144
+ const tokenSavings = this.calculateTokenSavings(files, impact)
145
+
146
+ // 8. Impact summary for the response
147
+ const impactSummary = this.summariseImpact(files, impact)
148
+
149
+ return {
150
+ allowed,
151
+ confidence: intentAnalysis.confidence,
152
+
153
+ intent: {
154
+ isIntentionalBreakingChange: intentAnalysis.isIntentionalBreakingChange,
155
+ confidence: intentAnalysis.confidence,
156
+ reasoning: intentAnalysis.reasoning,
157
+ riskAcceptance: intentAnalysis.riskAcceptance,
158
+ },
159
+
160
+ impact: impactSummary,
161
+
162
+ gates: gateResults.map(g => ({
163
+ name: g.gate,
164
+ passed: g.canProceed,
165
+ severity: g.severity,
166
+ message: g.reason,
167
+ bypassable: g.bypassable,
168
+ })),
169
+
170
+ corrections: {
171
+ available: corrections.issues.length > 0,
172
+ issuesFound: corrections.issues.length,
173
+ autoFixable: corrections.appliedFixes.length,
174
+ applied: corrections.appliedFixes.slice(0, 5),
175
+ suggested: corrections.issues
176
+ .filter(i => !i.autoFixable)
177
+ .map(i => `${i.file}:${i.line} — ${i.message}`)
178
+ .slice(0, 5),
179
+ },
180
+
181
+ recommendations,
182
+
183
+ nextSteps: allowed
184
+ ? ['Proceed with edit', 'Run tests after changes']
185
+ : [
186
+ `Address blocking gates: ${blockingGates.join(', ')}`,
187
+ ...gateResults
188
+ .filter(g => !g.canProceed && g.suggestedFix)
189
+ .map(g => g.suggestedFix!)
190
+ .slice(0, 3),
191
+ ],
192
+
193
+ tokenSavings,
194
+ }
195
+ }
196
+
197
+ // ─── Private helpers ────────────────────────────────────────────────────
198
+
199
+ /** Collect graph IDs for every tracked function in the given files. */
200
+ private collectFileNodeIds(files: string[]): string[] {
201
+ const ids: string[] = []
202
+ for (const file of files) {
203
+ const norm = file.replace(/\\/g, '/')
204
+ for (const fn of Object.values(this.lock.functions)) {
205
+ if (fn.file === norm || fn.file.endsWith('/' + norm)) ids.push(fn.id)
206
+ }
207
+ }
208
+ return ids
209
+ }
210
+
211
+ /** Build a zero-impact result when no tracked functions exist. */
212
+ private emptyImpact(files: string[]): ImpactResult {
213
+ return {
214
+ changed: [],
215
+ impacted: [],
216
+ allImpacted: [],
217
+ depth: 0,
218
+ entryPoints: [],
219
+ criticalModules: [],
220
+ paths: [],
221
+ confidence: 1.0,
222
+ riskScore: 0,
223
+ classified: { critical: [], high: [], medium: [], low: [] },
224
+ }
225
+ }
226
+
227
+ private summariseImpact(files: string[], impact: ImpactResult) {
228
+ const fileFunctions = this.collectFileNodeIds(files)
229
+ .map(id => this.lock.functions[id])
230
+ .filter(Boolean)
231
+
232
+ const criticalPaths = fileFunctions
233
+ .filter(f => f.calledBy.length > 10)
234
+ .map(f => `${f.moduleId}::${f.name}`)
235
+ .slice(0, 5)
236
+
237
+ const blastRadius = [...new Set(
238
+ fileFunctions
239
+ .flatMap(f => f.calledBy)
240
+ .map(id => this.lock.functions[id])
241
+ .filter(Boolean)
242
+ .map(f => `${f.moduleId}::${f.name}`),
243
+ )].slice(0, 10)
244
+
245
+ return {
246
+ totalFiles: files.length,
247
+ totalFunctions: fileFunctions.length,
248
+ riskScore: impact.riskScore,
249
+ criticalPaths,
250
+ blastRadius,
251
+ }
252
+ }
253
+
254
+ private buildRecommendations(intent: any, impact: ImpactResult, gates: any[], corrections: any): string[] {
255
+ const recs: string[] = []
256
+
257
+ if (intent.isIntentionalBreakingChange && intent.confidence > 0.7) {
258
+ recs.push('✓ Breaking change appears intentional — ensure migration guide exists')
259
+ }
260
+ if (impact.riskScore > 70) {
261
+ recs.push('⚠ High risk — consider breaking into smaller changes')
262
+ }
263
+
264
+ const summaryImpact = this.summariseImpact([], impact)
265
+ if (summaryImpact.criticalPaths.length > 0) {
266
+ recs.push(`Critical paths affected: ${summaryImpact.criticalPaths.join(', ')}`)
267
+ }
268
+
269
+ const warnings = gates.filter(g => g.severity === 'WARNING' && !g.canProceed)
270
+ if (warnings.length > 0) {
271
+ recs.push(`Address warnings: ${warnings.map((w: any) => w.gate).join(', ')}`)
272
+ }
273
+ if (corrections.issues.length > 0) {
274
+ recs.push(`Found ${corrections.issues.length} issue(s) — ${corrections.appliedFixes.length} auto-fixed`)
275
+ }
276
+
277
+ return recs
278
+ }
279
+
280
+ private calculateTokenSavings(files: string[], impact: ImpactResult): number {
281
+ // Naive estimate: without Mikk the AI reads all impacted + changed files
282
+ const fileCount = impact.impacted.length + files.length
283
+ const naiveCost = fileCount * 500
284
+ return Math.max(0, naiveCost - 500) // 500 tokens for the validation result itself
285
+ }
286
+
287
+ private inferChangeType(proposal: EditProposal): IntentContext['changeType'] {
288
+ const desc = `${proposal.description} ${proposal.intent?.commitMessage ?? ''}`.toLowerCase()
289
+ if (desc.includes('refactor') || desc.includes('restructure')) return 'refactor'
290
+ if (desc.includes('feat') || desc.includes('add') || desc.includes('new')) return 'feature'
291
+ if (desc.includes('fix') || desc.includes('bug') || desc.includes('patch')) return 'bugfix'
292
+ if (desc.includes('breaking') || desc.includes('api change')) return 'breaking'
293
+ return 'unknown'
294
+ }
295
+ }
package/src/preflight.ts CHANGED
@@ -1,59 +1,88 @@
1
- import type { MikkContract, MikkLock } from '@getmikk/core'
1
+ import {
2
+ MikkContract, MikkLock, GraphBuilder, ImpactAnalyzer
3
+ } from '@getmikk/core'
2
4
  import { IntentInterpreter } from './interpreter.js'
3
5
  import { ConflictDetector } from './conflict-detector.js'
4
6
  import { Suggester } from './suggester.js'
7
+ import { DecisionEngine } from './decision-engine.js'
8
+ import { ExplanationEngine } from './explanation-engine.js'
5
9
  import type { PreflightResult } from './types.js'
6
10
 
7
11
  /**
8
12
  * PreflightPipeline — orchestrates the full intent pipeline:
9
- * interpret → conflict-detect → suggest.
10
- * Single function call for the CLI.
13
+ * interpret → impact/risk analysis → decide → explain → conflict-detect → suggest.
11
14
  */
12
15
  export class PreflightPipeline {
13
- private interpreter: IntentInterpreter
16
+ private interpreter: IntentInterpreter
14
17
  private conflictDetector: ConflictDetector
15
- private suggester: Suggester
18
+ private suggester: Suggester
19
+ private decisionEngine: DecisionEngine
20
+ private explanationEngine: ExplanationEngine
16
21
 
17
22
  constructor(
18
23
  private contract: MikkContract,
19
- private lock: MikkLock
24
+ private lock: MikkLock,
20
25
  ) {
21
- this.interpreter = new IntentInterpreter(contract, lock)
26
+ this.interpreter = new IntentInterpreter(contract, lock)
22
27
  this.conflictDetector = new ConflictDetector(contract, lock)
23
- this.suggester = new Suggester(contract, lock)
28
+ this.suggester = new Suggester(contract, lock)
29
+ this.decisionEngine = new DecisionEngine(contract)
30
+ // Pass lock so ExplanationEngine can resolve module names accurately
31
+ this.explanationEngine = new ExplanationEngine(lock)
24
32
  }
25
33
 
26
- /** Run the full preflight pipeline */
34
+ /** Run the full preflight pipeline for a natural-language prompt. */
27
35
  async run(prompt: string): Promise<PreflightResult> {
28
- // 1. Interpret prompt into structured intents
36
+ // 1. Interpret prompt structured intents
29
37
  const intents = await this.interpreter.interpret(prompt)
30
38
 
31
- // 2. Check for conflicts
39
+ // 2. Build graph from lock (no file re-parsing) and run impact analysis
40
+ const graph = new GraphBuilder().buildFromLock(this.lock)
41
+ const analyzer = new ImpactAnalyzer(graph)
42
+
43
+ const targetIds: string[] = []
44
+ for (const intent of intents) {
45
+ const match = [...graph.nodes.values()].find(n =>
46
+ n.name.toLowerCase() === intent.target.name.toLowerCase() &&
47
+ (intent.target.type === 'function' ? n.type === 'function' : true),
48
+ )
49
+ if (match) targetIds.push(match.id)
50
+ }
51
+
52
+ const impact = analyzer.analyze(targetIds)
53
+
54
+ // 3. Decide and explain
55
+ const decision = this.decisionEngine.evaluate(impact)
56
+ const explanation = this.explanationEngine.explain(impact, decision)
57
+
58
+ // 4. Static conflict detection
32
59
  const conflicts = this.conflictDetector.detect(intents)
33
60
 
34
- // 3. Low-confidence rejection: if the best intent has very low confidence,
35
- // add a warning so the AI doesn't blindly proceed
36
- const maxConfidence = intents.length > 0
61
+ // 5. Low-confidence rejection
62
+ const maxConf = intents.length > 0
37
63
  ? Math.max(...intents.map(i => i.confidence))
38
64
  : 0
39
- if (maxConfidence < 0.4 && intents.length > 0) {
65
+
66
+ if (maxConf < 0.4 && intents.length > 0) {
40
67
  conflicts.conflicts.push({
41
- type: 'low-confidence',
42
- severity: 'warning',
43
- message: `Low confidence (${(maxConfidence * 100).toFixed(0)}%) — the intent could not be reliably matched to existing code. The suggestion may be inaccurate.`,
44
- relatedIntent: intents[0],
45
- suggestedFix: 'Be more specific about the function or module name in your prompt.',
68
+ type: 'low-confidence',
69
+ severity: 'warning',
70
+ message: `Low confidence (${(maxConf * 100).toFixed(0)}%) — matching to existing code was ambiguous.`,
71
+ relatedIntent: intents[0],
72
+ suggestedFix: 'Be more specific about the function or module name.',
46
73
  })
47
74
  }
48
75
 
49
- // 4. Generate suggestions
76
+ // 6. Implementation suggestions
50
77
  const suggestions = this.suggester.suggest(intents)
51
78
 
52
79
  return {
53
80
  intents,
54
81
  conflicts,
55
82
  suggestions,
56
- approved: !conflicts.hasConflicts && maxConfidence >= 0.4,
83
+ decision,
84
+ explanation,
85
+ approved: !conflicts.hasConflicts && decision.status !== 'BLOCKED' && maxConf >= 0.4,
57
86
  }
58
87
  }
59
88
  }
package/src/types.ts CHANGED
@@ -38,6 +38,25 @@ export interface Suggestion {
38
38
  implementation: string
39
39
  }
40
40
 
41
+ export type DecisionStatus = 'APPROVED' | 'WARNING' | 'BLOCKED';
42
+
43
+ export interface DecisionResult {
44
+ status: DecisionStatus
45
+ reasons: string[]
46
+ riskScore: number
47
+ impactNodes: number
48
+ }
49
+
50
+ export interface Explanation {
51
+ summary: string
52
+ details: string[]
53
+ riskBreakdown: {
54
+ symbol: string
55
+ reason: string
56
+ score: number
57
+ }[]
58
+ }
59
+
41
60
  /** Configuration for the AI provider */
42
61
  export interface AIProviderConfig {
43
62
  provider: 'anthropic' | 'openai' | 'local'
@@ -50,5 +69,7 @@ export interface PreflightResult {
50
69
  intents: Intent[]
51
70
  conflicts: ConflictResult
52
71
  suggestions: Suggestion[]
72
+ decision: DecisionResult
73
+ explanation: Explanation
53
74
  approved: boolean
54
75
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Ambient stub for the optional peer dependency @xenova/transformers.
3
- * The real types are only available when the package is installed.
4
- * We use dynamic import + `any` everywhere so this stub is sufficient.
2
+ * Ambient stub for the direct dependency @xenova/transformers.
3
+ * Provides basic types for the library and documents that SemanticSearcher.isAvailable()
4
+ * is used to test runtime loadability (e.g. WASM support) for graceful error handling.
5
5
  */
6
6
  declare module '@xenova/transformers' {
7
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,6 +1,9 @@
1
1
  import { describe, test, expect, beforeAll } from 'bun:test'
2
2
  import { SemanticSearcher } from '../src/semantic-searcher'
3
3
  import type { MikkLock } from '@getmikk/core'
4
+ import * as path from 'node:path'
5
+ import * as os from 'node:os'
6
+ import * as fs from 'node:fs/promises'
4
7
 
5
8
  // ── Minimal mock lock ─────────────────────────────────────────────────────────
6
9
  const mockLock: MikkLock = {
@@ -72,9 +75,10 @@ describe('SemanticSearcher', () => {
72
75
 
73
76
  describe('with indexed lock', () => {
74
77
  beforeAll(async () => {
75
- searcher = new SemanticSearcher('/tmp/mikk-test-' + Date.now())
78
+ const uniquePath = path.join(os.tmpdir(), 'mikk-test-' + Math.random().toString(36).slice(2))
79
+ searcher = new SemanticSearcher(uniquePath)
76
80
  await searcher.index(mockLock)
77
- }, 60_000) // model download can take time on first run
81
+ }, 120_000) // Increase timeout for CI model download
78
82
 
79
83
  test('returns results for any query', async () => {
80
84
  const results = await searcher.search('authenticate user', mockLock, 4)
@@ -154,25 +158,30 @@ describe('SemanticSearcher', () => {
154
158
  ...mockLock,
155
159
  functions: { ...mockLock.functions, 'fn:new:brandNewFn': newFn },
156
160
  }
157
- const freshSearcher = new SemanticSearcher('/tmp/mikk-reindex-' + Date.now())
161
+ const uniquePath = path.join(os.tmpdir(), 'mikk-reindex-' + Math.random().toString(36).slice(2))
162
+ const freshSearcher = new SemanticSearcher(uniquePath)
158
163
  await freshSearcher.index(changedLock) // fingerprint differs → full recompute
159
164
  const results = await freshSearcher.search('unique purpose', changedLock, 5)
160
165
  expect(results.some(r => r.name === 'brandNewFn')).toBe(true)
161
- }, 30_000)
166
+ await fs.rm(uniquePath, { recursive: true, force: true })
167
+ }, 120_000)
162
168
  })
163
169
 
164
170
  describe('edge cases', () => {
165
171
  test('search() before index() throws', async () => {
166
- const fresh = new SemanticSearcher('/tmp/mikk-never-indexed-' + Date.now())
172
+ const uniquePath = path.join(os.tmpdir(), 'mikk-never-' + Math.random().toString(36).slice(2))
173
+ const fresh = new SemanticSearcher(uniquePath)
167
174
  await expect(fresh.search('query', mockLock)).rejects.toThrow('Call index() before search()')
168
175
  })
169
176
 
170
177
  test('empty lock: index() and search() succeed, return empty array', async () => {
171
178
  const emptyLock: MikkLock = { ...mockLock, functions: {} }
172
- const s = new SemanticSearcher('/tmp/mikk-empty-' + Date.now())
179
+ const uniquePath = path.join(os.tmpdir(), 'mikk-empty-' + Math.random().toString(36).slice(2))
180
+ const s = new SemanticSearcher(uniquePath)
173
181
  await s.index(emptyLock)
174
182
  const results = await s.search('anything', emptyLock, 5)
175
183
  expect(results).toEqual([])
176
- })
184
+ await fs.rm(uniquePath, { recursive: true, force: true })
185
+ }, 120_000)
177
186
  })
178
187
  })