@getmikk/intent-engine 1.8.0 → 1.9.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 +4 -11
- package/src/decision-engine.ts +69 -0
- package/src/explanation-engine.ts +80 -0
- package/src/preflight.ts +41 -11
- package/src/types.ts +21 -0
- package/src/xeno-transformers.d.ts +3 -3
- package/tests/semantic-searcher.test.ts +16 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getmikk/intent-engine",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,16 +21,9 @@
|
|
|
21
21
|
"dev": "tsc --watch"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@getmikk/core": "^1.
|
|
25
|
-
"zod": "^3.22.0"
|
|
26
|
-
|
|
27
|
-
"peerDependencies": {
|
|
28
|
-
"@xenova/transformers": ""
|
|
29
|
-
},
|
|
30
|
-
"peerDependenciesMeta": {
|
|
31
|
-
"@xenova/transformers": {
|
|
32
|
-
"optional": true
|
|
33
|
-
}
|
|
24
|
+
"@getmikk/core": "^1.9.0",
|
|
25
|
+
"zod": "^3.22.0",
|
|
26
|
+
"@xenova/transformers": "^2.17.2"
|
|
34
27
|
},
|
|
35
28
|
"devDependencies": {
|
|
36
29
|
"@types/bun": "^1.3.10",
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ImpactResult, MikkContract } from '@getmikk/core'
|
|
2
|
+
import type { DecisionResult, DecisionStatus } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DecisionEngine — the "Brain" of Mikk 2.0.
|
|
6
|
+
* Evaluates quantitative impact analysis against project policies.
|
|
7
|
+
*/
|
|
8
|
+
export class DecisionEngine {
|
|
9
|
+
constructor(private contract: MikkContract) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Evaluate an impact result against the defined policies.
|
|
13
|
+
*/
|
|
14
|
+
evaluate(impact: ImpactResult): DecisionResult {
|
|
15
|
+
const policy = {
|
|
16
|
+
maxRiskScore: this.contract.policies?.maxRiskScore ?? 70,
|
|
17
|
+
maxImpactNodes: this.contract.policies?.maxImpactNodes ?? 10,
|
|
18
|
+
protectedModules: this.contract.policies?.protectedModules ?? [],
|
|
19
|
+
enforceStrictBoundaries: this.contract.policies?.enforceStrictBoundaries ?? false,
|
|
20
|
+
};
|
|
21
|
+
const reasons: string[] = [];
|
|
22
|
+
let status: DecisionStatus = 'APPROVED';
|
|
23
|
+
|
|
24
|
+
const maxRisk = impact.riskScore;
|
|
25
|
+
const impactCount = impact.impacted.length;
|
|
26
|
+
|
|
27
|
+
// 1. Check absolute risk threshold
|
|
28
|
+
if (maxRisk >= 90) {
|
|
29
|
+
status = 'BLOCKED';
|
|
30
|
+
reasons.push(`Critical risk detected (${maxRisk}/100). Policy strictly blocks changes exceeding 90 risk.`);
|
|
31
|
+
} else if (maxRisk > policy.maxRiskScore) {
|
|
32
|
+
status = 'WARNING';
|
|
33
|
+
reasons.push(`High risk (${maxRisk}) exceeds policy threshold of ${policy.maxRiskScore}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Check impact scale
|
|
37
|
+
if (impactCount > policy.maxImpactNodes) {
|
|
38
|
+
status = status === 'BLOCKED' ? 'BLOCKED' : 'WARNING';
|
|
39
|
+
reasons.push(`Impact spread (${impactCount} symbols) exceeds propagation limit of ${policy.maxImpactNodes}.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 3. Check protected modules
|
|
43
|
+
const touchedProtectedModules = impact.allImpacted
|
|
44
|
+
.filter(node => node.risk === 'CRITICAL' || node.risk === 'HIGH')
|
|
45
|
+
.map(node => {
|
|
46
|
+
return policy.protectedModules.find(pm => node.file.toLowerCase().includes(pm.toLowerCase()));
|
|
47
|
+
})
|
|
48
|
+
.filter((m): m is string => !!m);
|
|
49
|
+
|
|
50
|
+
const uniqueProtected = [...new Set(touchedProtectedModules)];
|
|
51
|
+
if (uniqueProtected.length > 0) {
|
|
52
|
+
status = status === 'BLOCKED' ? 'BLOCKED' : 'WARNING';
|
|
53
|
+
reasons.push(`Change affects protected modules: ${uniqueProtected.join(', ')}.`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 4. Force BLOCKED for critical cross-boundary impacts if enforcement is on
|
|
57
|
+
if (policy.enforceStrictBoundaries && impact.classified.critical.length > 0) {
|
|
58
|
+
status = 'BLOCKED';
|
|
59
|
+
reasons.push(`Strict boundary enforcement: ${impact.classified.critical.length} critical cross-module impact(s) detected.`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
status,
|
|
64
|
+
reasons,
|
|
65
|
+
riskScore: maxRisk,
|
|
66
|
+
impactNodes: impactCount
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ImpactResult } from '@getmikk/core'
|
|
2
|
+
import type { DecisionResult, Explanation } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ExplanationEngine — generates human-readable reasoning for code modification decisions.
|
|
6
|
+
* Explains WHY a change is safe or risky based on the quantitative graph analysis.
|
|
7
|
+
*/
|
|
8
|
+
export class ExplanationEngine {
|
|
9
|
+
/**
|
|
10
|
+
* Generate an explanation for the decision and impact.
|
|
11
|
+
*/
|
|
12
|
+
explain(impact: ImpactResult, decision: DecisionResult): Explanation {
|
|
13
|
+
const summary = this.getSummary(decision);
|
|
14
|
+
const details = this.getDetails(impact, decision);
|
|
15
|
+
const riskBreakdown = this.getRiskBreakdown(impact);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
summary,
|
|
19
|
+
details,
|
|
20
|
+
riskBreakdown
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private getSummary(decision: DecisionResult): string {
|
|
25
|
+
switch (decision.status) {
|
|
26
|
+
case 'APPROVED': return 'This change is safe and conforms to project policies.';
|
|
27
|
+
case 'WARNING': return 'Change detected with non-trivial impact — proceed with caution.';
|
|
28
|
+
case 'BLOCKED': return 'MODIFICATION BLOCKED: This change violates safety or architectural policies.';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private getDetails(impact: ImpactResult, decision: DecisionResult): string[] {
|
|
33
|
+
const details: string[] = [];
|
|
34
|
+
|
|
35
|
+
if (impact.allImpacted.length === 0) {
|
|
36
|
+
details.push('No downstream symbols are affected by this change.');
|
|
37
|
+
} else {
|
|
38
|
+
details.push(`Affects ${impact.allImpacted.length} symbols across ${this.countModules(impact)} modules.`);
|
|
39
|
+
details.push(`Maximum risk score is ${impact.riskScore}/100.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (impact.classified.critical.length > 0) {
|
|
43
|
+
details.push(`⚠ Critical: affects ${impact.classified.critical.length} symbols in separate modules.`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (decision.reasons.length > 0) {
|
|
47
|
+
decision.reasons.forEach(r => details.push(`Policy: ${r}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return details;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private getRiskBreakdown(impact: ImpactResult): { symbol: string; reason: string; score: number }[] {
|
|
54
|
+
// Return top 3 riskiest affected symbols
|
|
55
|
+
return impact.allImpacted
|
|
56
|
+
.sort((a, b) => b.riskScore - a.riskScore)
|
|
57
|
+
.slice(0, 3)
|
|
58
|
+
.map(node => ({
|
|
59
|
+
symbol: node.label,
|
|
60
|
+
score: node.riskScore,
|
|
61
|
+
reason: this.getRiskReason(node.riskScore)
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private getRiskReason(score: number): string {
|
|
66
|
+
if (score >= 90) return 'Direct critical dependency in protected module';
|
|
67
|
+
if (score >= 80) return 'Large downstream reach (potential side effects)';
|
|
68
|
+
if (score >= 60) return 'Cross-module propagation';
|
|
69
|
+
return 'Standard functional dependency';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private countModules(impact: ImpactResult): number {
|
|
73
|
+
const modules = new Set(impact.allImpacted.map(n => {
|
|
74
|
+
// Heuristic to extract module from file path if moduleId not present
|
|
75
|
+
const parts = n.file.split('/');
|
|
76
|
+
return parts.length > 1 ? parts[0] : 'root';
|
|
77
|
+
}));
|
|
78
|
+
return modules.size;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/preflight.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
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'
|
|
5
|
-
import
|
|
7
|
+
import { DecisionEngine } from './decision-engine.js'
|
|
8
|
+
import { ExplanationEngine } from './explanation-engine.js'
|
|
9
|
+
import type { PreflightResult, DecisionResult, Explanation } 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 → analyze (impact/risk) → decide → explain → conflict-detect → suggest.
|
|
11
14
|
*/
|
|
12
15
|
export class PreflightPipeline {
|
|
13
16
|
private interpreter: IntentInterpreter
|
|
14
17
|
private conflictDetector: ConflictDetector
|
|
15
18
|
private suggester: Suggester
|
|
19
|
+
private decisionEngine: DecisionEngine
|
|
20
|
+
private explanationEngine: ExplanationEngine
|
|
16
21
|
|
|
17
22
|
constructor(
|
|
18
23
|
private contract: MikkContract,
|
|
@@ -21,6 +26,8 @@ export class PreflightPipeline {
|
|
|
21
26
|
this.interpreter = new IntentInterpreter(contract, lock)
|
|
22
27
|
this.conflictDetector = new ConflictDetector(contract, lock)
|
|
23
28
|
this.suggester = new Suggester(contract, lock)
|
|
29
|
+
this.decisionEngine = new DecisionEngine(contract)
|
|
30
|
+
this.explanationEngine = new ExplanationEngine()
|
|
24
31
|
}
|
|
25
32
|
|
|
26
33
|
/** Run the full preflight pipeline */
|
|
@@ -28,11 +35,32 @@ export class PreflightPipeline {
|
|
|
28
35
|
// 1. Interpret prompt into structured intents
|
|
29
36
|
const intents = await this.interpreter.interpret(prompt)
|
|
30
37
|
|
|
31
|
-
// 2.
|
|
38
|
+
// 2. Perform Quantitative Impact Analysis
|
|
39
|
+
const graph = new GraphBuilder().buildFromLock(this.lock)
|
|
40
|
+
const analyzer = new ImpactAnalyzer(graph)
|
|
41
|
+
|
|
42
|
+
// Find node IDs for the intents
|
|
43
|
+
const targetIds: string[] = []
|
|
44
|
+
for (const intent of intents) {
|
|
45
|
+
// Find better match by checking both name and type
|
|
46
|
+
// In Mikk 2.0, we have IDs like fn:src/file.ts:name
|
|
47
|
+
const matchedNode = [...graph.nodes.values()].find(n =>
|
|
48
|
+
n.name.toLowerCase() === intent.target.name.toLowerCase() &&
|
|
49
|
+
(intent.target.type === 'function' ? n.type === 'function' : true)
|
|
50
|
+
)
|
|
51
|
+
if (matchedNode) targetIds.push(matchedNode.id)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const impact = analyzer.analyze(targetIds)
|
|
55
|
+
|
|
56
|
+
// 3. Decide and Explain
|
|
57
|
+
const decision = this.decisionEngine.evaluate(impact)
|
|
58
|
+
const explanation = this.explanationEngine.explain(impact, decision)
|
|
59
|
+
|
|
60
|
+
// 4. Check for Static Conflicts
|
|
32
61
|
const conflicts = this.conflictDetector.detect(intents)
|
|
33
62
|
|
|
34
|
-
//
|
|
35
|
-
// add a warning so the AI doesn't blindly proceed
|
|
63
|
+
// 5. Low-confidence rejection / Supplemental warnings
|
|
36
64
|
const maxConfidence = intents.length > 0
|
|
37
65
|
? Math.max(...intents.map(i => i.confidence))
|
|
38
66
|
: 0
|
|
@@ -40,20 +68,22 @@ export class PreflightPipeline {
|
|
|
40
68
|
conflicts.conflicts.push({
|
|
41
69
|
type: 'low-confidence',
|
|
42
70
|
severity: 'warning',
|
|
43
|
-
message: `Low confidence (${(maxConfidence * 100).toFixed(0)}%) —
|
|
71
|
+
message: `Low confidence (${(maxConfidence * 100).toFixed(0)}%) — matching to existing code was ambiguous.`,
|
|
44
72
|
relatedIntent: intents[0],
|
|
45
|
-
suggestedFix: 'Be more specific about the function or module name
|
|
73
|
+
suggestedFix: 'Be more specific about the function or module name.',
|
|
46
74
|
})
|
|
47
75
|
}
|
|
48
76
|
|
|
49
|
-
//
|
|
77
|
+
// 6. Generate implementation suggestions
|
|
50
78
|
const suggestions = this.suggester.suggest(intents)
|
|
51
79
|
|
|
52
80
|
return {
|
|
53
81
|
intents,
|
|
54
82
|
conflicts,
|
|
55
83
|
suggestions,
|
|
56
|
-
|
|
84
|
+
decision,
|
|
85
|
+
explanation,
|
|
86
|
+
approved: !conflicts.hasConflicts && decision.status !== 'BLOCKED' && maxConfidence >= 0.4,
|
|
57
87
|
}
|
|
58
88
|
}
|
|
59
89
|
}
|
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
|
})
|