@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.
- package/README.md +158 -69
- package/package.json +4 -11
- package/src/auto-correction.ts +284 -0
- package/src/decision-engine.ts +74 -0
- package/src/enforced-safety.ts +305 -0
- package/src/explanation-engine.ts +100 -0
- package/src/index.ts +10 -0
- package/src/intent-understanding.ts +227 -0
- package/src/pre-edit-validation.ts +295 -0
- package/src/preflight.ts +51 -22
- package/src/types.ts +21 -0
- package/src/xeno-transformers.d.ts +3 -3
- package/tests/semantic-searcher.test.ts +16 -7
|
@@ -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
|
|
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:
|
|
16
|
+
private interpreter: IntentInterpreter
|
|
14
17
|
private conflictDetector: ConflictDetector
|
|
15
|
-
private 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
|
|
26
|
+
this.interpreter = new IntentInterpreter(contract, lock)
|
|
22
27
|
this.conflictDetector = new ConflictDetector(contract, lock)
|
|
23
|
-
this.suggester
|
|
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
|
|
36
|
+
// 1. Interpret prompt → structured intents
|
|
29
37
|
const intents = await this.interpreter.interpret(prompt)
|
|
30
38
|
|
|
31
|
-
// 2.
|
|
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
|
-
//
|
|
35
|
-
|
|
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
|
-
|
|
65
|
+
|
|
66
|
+
if (maxConf < 0.4 && intents.length > 0) {
|
|
40
67
|
conflicts.conflicts.push({
|
|
41
|
-
type:
|
|
42
|
-
severity:
|
|
43
|
-
message:
|
|
44
|
-
relatedIntent:
|
|
45
|
-
suggestedFix:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
})
|