@getmikk/intent-engine 1.7.1 → 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/README.md +71 -258
- package/package.json +4 -11
- package/src/conflict-detector.ts +1 -1
- package/src/decision-engine.ts +69 -0
- package/src/explanation-engine.ts +80 -0
- package/src/preflight.ts +41 -11
- package/src/semantic-searcher.ts +7 -7
- package/src/types.ts +21 -0
- package/src/xeno-transformers.d.ts +3 -3
- package/tests/semantic-searcher.test.ts +16 -7
package/README.md
CHANGED
|
@@ -1,312 +1,125 @@
|
|
|
1
|
-
# @getmikk/intent-engine
|
|
1
|
+
# @getmikk/intent-engine
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Parse developer intent, detect constraint conflicts, and find functions by meaning.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@getmikk/intent-engine)
|
|
6
6
|
[](../../LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Two capabilities in one package: a pre-flight pipeline that catches architectural conflicts before code is written, and a semantic search engine that finds functions by natural-language description using local vector embeddings.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
> Part of [Mikk](../../README.md) — the codebase nervous system for AI-assisted development.
|
|
10
|
+
> Part of [Mikk](../../README.md) — live architectural context for your AI agent.
|
|
13
11
|
|
|
14
12
|
---
|
|
15
13
|
|
|
16
|
-
##
|
|
17
|
-
|
|
18
|
-
```bash
|
|
19
|
-
npm install @getmikk/intent-engine
|
|
20
|
-
# or
|
|
21
|
-
bun add @getmikk/intent-engine
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
**Peer dependency:** `@getmikk/core`
|
|
14
|
+
## Pre-flight Pipeline
|
|
25
15
|
|
|
26
|
-
|
|
16
|
+
Takes a plain-English prompt describing a refactor or new feature, and returns a structured verdict before any code is written.
|
|
27
17
|
|
|
28
|
-
|
|
18
|
+
### Usage
|
|
29
19
|
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const contract = await new ContractReader().read('./mikk.json')
|
|
35
|
-
const lock = await new LockReader().read('./mikk.lock.json')
|
|
36
|
-
|
|
37
|
-
const pipeline = new PreflightPipeline(contract, lock)
|
|
38
|
-
const result = await pipeline.run('Add a Redis caching layer to the auth module')
|
|
39
|
-
|
|
40
|
-
console.log(result.intents) // Parsed intent objects
|
|
41
|
-
console.log(result.conflicts) // Constraint violations found
|
|
42
|
-
console.log(result.suggestions) // File-level implementation plan
|
|
43
|
-
console.log(result.approved) // true if no blocking conflicts
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## Pipeline Architecture
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
Natural Language Prompt
|
|
52
|
-
│
|
|
53
|
-
▼
|
|
54
|
-
┌──────────────────┐
|
|
55
|
-
│ IntentInterpreter │ → Intent[]
|
|
56
|
-
└────────┬─────────┘
|
|
57
|
-
│
|
|
58
|
-
▼
|
|
59
|
-
┌──────────────────┐
|
|
60
|
-
│ ConflictDetector │ → ConflictResult
|
|
61
|
-
└────────┬─────────┘
|
|
62
|
-
│
|
|
63
|
-
▼
|
|
64
|
-
┌──────────────────┐
|
|
65
|
-
│ Suggester │ → Suggestion[]
|
|
66
|
-
└────────┬─────────┘
|
|
67
|
-
│
|
|
68
|
-
▼
|
|
69
|
-
PreflightResult
|
|
20
|
+
```bash
|
|
21
|
+
mikk intent "Move user validation into a shared utils module"
|
|
22
|
+
mikk intent "Extract auth logic into middleware" --json
|
|
70
23
|
```
|
|
71
24
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
## API Reference
|
|
75
|
-
|
|
76
|
-
### PreflightPipeline
|
|
77
|
-
|
|
78
|
-
The main entry point — orchestrates the full interpret → detect → suggest flow.
|
|
25
|
+
Or programmatically:
|
|
79
26
|
|
|
80
27
|
```typescript
|
|
81
28
|
import { PreflightPipeline } from '@getmikk/intent-engine'
|
|
82
29
|
|
|
83
30
|
const pipeline = new PreflightPipeline(contract, lock)
|
|
84
|
-
const result = await pipeline.run(
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
**`PreflightResult`:**
|
|
88
|
-
|
|
89
|
-
| Field | Type | Description |
|
|
90
|
-
|-------|------|-------------|
|
|
91
|
-
| `intents` | `Intent[]` | Structured interpretation of the prompt |
|
|
92
|
-
| `conflicts` | `ConflictResult` | Any constraint violations detected |
|
|
93
|
-
| `suggestions` | `Suggestion[]` | Concrete implementation suggestions |
|
|
94
|
-
| `approved` | `boolean` | `true` if no error-level conflicts |
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
### IntentInterpreter
|
|
99
|
-
|
|
100
|
-
Parses natural-language prompts into structured intent objects using heuristic keyword matching and fuzzy matching against the lock file's function/module inventory.
|
|
101
|
-
|
|
102
|
-
```typescript
|
|
103
|
-
import { IntentInterpreter } from '@getmikk/intent-engine'
|
|
31
|
+
const result = await pipeline.run("Add rate limiting to all API routes")
|
|
104
32
|
|
|
105
|
-
|
|
106
|
-
|
|
33
|
+
console.log(result.approved) // true | false
|
|
34
|
+
console.log(result.conflicts) // constraint violations found
|
|
35
|
+
console.log(result.suggestions) // implementation suggestions with affected files
|
|
107
36
|
```
|
|
108
37
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
1. **Action verb detection** — Scans for keywords like `create`, `add`, `modify`, `update`, `delete`, `remove`, `refactor`, `move`, `rename`
|
|
112
|
-
2. **Target resolution** — Matches mentioned names against lock file functions, classes, modules, and files using fuzzy matching
|
|
113
|
-
3. **Confidence scoring** — Higher confidence for exact matches, lower for fuzzy
|
|
114
|
-
|
|
115
|
-
**`Intent`:**
|
|
116
|
-
|
|
117
|
-
```typescript
|
|
118
|
-
type Intent = {
|
|
119
|
-
action: 'create' | 'modify' | 'delete' | 'refactor' | 'move'
|
|
120
|
-
target: {
|
|
121
|
-
type: 'function' | 'class' | 'module' | 'file'
|
|
122
|
-
name: string
|
|
123
|
-
moduleId?: string // Which module contains the target
|
|
124
|
-
filePath?: string // Resolved file path
|
|
125
|
-
}
|
|
126
|
-
reason: string // Why this intent was derived
|
|
127
|
-
confidence: number // 0-1 confidence score
|
|
128
|
-
}
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
---
|
|
132
|
-
|
|
133
|
-
### ConflictDetector
|
|
134
|
-
|
|
135
|
-
Rule-based constraint checker that validates intents against the architectural rules in `mikk.json`.
|
|
38
|
+
### What it returns
|
|
136
39
|
|
|
137
40
|
```typescript
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
41
|
+
{
|
|
42
|
+
intents: [
|
|
43
|
+
{
|
|
44
|
+
action: 'add' | 'move' | 'extract' | 'refactor' | 'remove' | ...,
|
|
45
|
+
target: { type: 'function' | 'module' | 'file', name: string, moduleId?: string },
|
|
46
|
+
confidence: number // 0-1
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
conflicts: {
|
|
50
|
+
hasConflicts: boolean,
|
|
51
|
+
conflicts: [
|
|
52
|
+
{
|
|
53
|
+
type: string,
|
|
54
|
+
severity: 'error' | 'warning',
|
|
55
|
+
message: string,
|
|
56
|
+
suggestedFix: string
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
suggestions: [
|
|
61
|
+
{
|
|
62
|
+
intent: Intent,
|
|
63
|
+
implementation: string,
|
|
64
|
+
affectedFiles: string[],
|
|
65
|
+
newFiles: string[],
|
|
66
|
+
estimatedImpact: number
|
|
148
67
|
}
|
|
149
|
-
|
|
68
|
+
],
|
|
69
|
+
approved: boolean
|
|
150
70
|
}
|
|
151
71
|
```
|
|
152
72
|
|
|
153
|
-
|
|
73
|
+
### Constraint checks
|
|
154
74
|
|
|
155
|
-
|
|
156
|
-
|-----------|-------------|---------|
|
|
157
|
-
| `no-import` | Module A must not import from Module B | `"no-import": ["payments"]` in the auth module |
|
|
158
|
-
| `must-use` | Module must use specified dependencies | `"must-use": ["@getmikk/core"]` |
|
|
159
|
-
| `no-call` | Functions in module must not call specified targets | `"no-call": ["database.rawQuery"]` |
|
|
160
|
-
| `layer` | Enforces layered architecture ordering | `"layer": 2` — can only import from lower layers |
|
|
161
|
-
| `naming` | Enforces naming patterns for functions/files | `"naming": { "functions": "^handle|^use|^get" }` |
|
|
162
|
-
| `max-files` | Limits the number of files in a module | `"max-files": 20` |
|
|
163
|
-
|
|
164
|
-
**Additional checks:**
|
|
165
|
-
- **Boundary crossing** — Detects when an intent would create a new cross-module dependency
|
|
166
|
-
- **Missing dependencies** — Flags when a target module doesn't exist
|
|
167
|
-
- **Ownership warnings** — Warns when modifying code owned by a different team/module
|
|
168
|
-
|
|
169
|
-
**`Conflict`:**
|
|
170
|
-
|
|
171
|
-
```typescript
|
|
172
|
-
type Conflict = {
|
|
173
|
-
type: 'constraint-violation' | 'ownership-conflict' | 'boundary-crossing' | 'missing-dependency'
|
|
174
|
-
severity: 'error' | 'warning'
|
|
175
|
-
message: string
|
|
176
|
-
relatedIntent: Intent
|
|
177
|
-
suggestedFix?: string
|
|
178
|
-
}
|
|
179
|
-
```
|
|
75
|
+
The pipeline checks against all 6 declared constraint types: `no-import`, `must-use`, `no-call`, `layer`, `naming`, `max-files`. If the proposed change would violate any of them, it surfaces as a conflict with a suggested fix.
|
|
180
76
|
|
|
181
77
|
---
|
|
182
78
|
|
|
183
|
-
|
|
79
|
+
## Semantic Search
|
|
184
80
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
```typescript
|
|
188
|
-
import { Suggester } from '@getmikk/intent-engine'
|
|
81
|
+
Find functions by natural-language description using local vector embeddings. No external API — runs entirely on-device.
|
|
189
82
|
|
|
190
|
-
|
|
191
|
-
const suggestions = suggester.suggest(intents)
|
|
83
|
+
### Setup
|
|
192
84
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
console.log(`Affected files: ${s.affectedFiles.join(', ')}`)
|
|
196
|
-
console.log(`New files: ${s.newFiles.join(', ')}`)
|
|
197
|
-
console.log(`Impact: ${s.estimatedImpact}`)
|
|
198
|
-
}
|
|
85
|
+
```bash
|
|
86
|
+
npm install @xenova/transformers
|
|
199
87
|
```
|
|
200
88
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
| Field | Type | Description |
|
|
204
|
-
|-------|------|-------------|
|
|
205
|
-
| `intent` | `Intent` | The original intent this suggestion addresses |
|
|
206
|
-
| `affectedFiles` | `string[]` | Existing files that would need changes |
|
|
207
|
-
| `newFiles` | `string[]` | Files that would need to be created |
|
|
208
|
-
| `estimatedImpact` | `'low' \| 'medium' \| 'high'` | Blast radius estimate |
|
|
209
|
-
| `implementation` | `string` | Natural-language implementation guidance |
|
|
210
|
-
|
|
211
|
-
---
|
|
212
|
-
|
|
213
|
-
### SemanticSearcher
|
|
89
|
+
The model (`Xenova/all-MiniLM-L6-v2`, ~22MB) downloads once to `~/.cache/huggingface`.
|
|
214
90
|
|
|
215
|
-
|
|
91
|
+
### Usage
|
|
216
92
|
|
|
217
|
-
|
|
218
|
-
**Optional peer dependency:** `@xenova/transformers >= 2`
|
|
219
|
-
|
|
220
|
-
```bash
|
|
221
|
-
bun add @xenova/transformers # only needed if you use SemanticSearcher
|
|
222
|
-
```
|
|
93
|
+
Exposed via the MCP tool `mikk_semantic_search`, or directly:
|
|
223
94
|
|
|
224
95
|
```typescript
|
|
225
96
|
import { SemanticSearcher } from '@getmikk/intent-engine'
|
|
226
97
|
|
|
227
|
-
|
|
228
|
-
if (await SemanticSearcher.isAvailable()) {
|
|
229
|
-
const searcher = new SemanticSearcher(projectRoot)
|
|
98
|
+
const searcher = new SemanticSearcher(projectRoot)
|
|
230
99
|
|
|
231
|
-
|
|
232
|
-
|
|
100
|
+
// Build (or load from cache) embeddings for the lock
|
|
101
|
+
await searcher.index(lock)
|
|
233
102
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
console.log(`${r.name} (${r.file}:${r.lines}) — score: ${r.score}`)
|
|
238
|
-
console.log(` ${r.purpose}`)
|
|
239
|
-
}
|
|
240
|
-
}
|
|
103
|
+
// Find the 10 most semantically similar functions
|
|
104
|
+
const results = await searcher.search('validate a JWT token', lock, 10)
|
|
105
|
+
// Returns: [{ name, file, moduleId, purpose, lines, score }]
|
|
241
106
|
```
|
|
242
107
|
|
|
243
|
-
|
|
108
|
+
### How it works
|
|
244
109
|
|
|
245
|
-
|
|
110
|
+
1. For each function in the lock, concatenates: function name + purpose string (if present) + param names + return type
|
|
111
|
+
2. Generates embeddings in batches of 64 using the pipeline
|
|
112
|
+
3. Caches to `.mikk/embeddings.json` — fingerprinted by function count + first 20 sorted IDs
|
|
113
|
+
4. Cache is valid until the lock changes; recomputes only what changed
|
|
114
|
+
5. At search time, embeds the query and ranks all functions by cosine similarity
|
|
246
115
|
|
|
247
|
-
|
|
248
|
-
|-------|------|-------------|
|
|
249
|
-
| `id` | `string` | Function ID (`fn:module:name`) |
|
|
250
|
-
| `name` | `string` | Function name |
|
|
251
|
-
| `file` | `string` | Source file path |
|
|
252
|
-
| `moduleId` | `string` | Owning module |
|
|
253
|
-
| `purpose` | `string` | One-line purpose from the lock |
|
|
254
|
-
| `lines` | `string` | Line range, e.g. `"12-34"` |
|
|
255
|
-
| `score` | `number` | Cosine similarity `[0, 1]` — higher is more relevant |
|
|
116
|
+
All vectors are unit-normalized at generation time so similarity is a simple dot product.
|
|
256
117
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
| Method | Description |
|
|
260
|
-
|--------|-------------|
|
|
261
|
-
| `SemanticSearcher.isAvailable()` | Returns `true` if `@xenova/transformers` is importable |
|
|
262
|
-
| `new SemanticSearcher(projectRoot)` | Creates an instance scoped to a project root |
|
|
263
|
-
| `.index(lock)` | Builds/loads embeddings for all functions in the lock |
|
|
264
|
-
| `.search(query, lock, topK?)` | Returns top `topK` (default 10) semantically similar functions |
|
|
265
|
-
|
|
266
|
-
> **Note:** Call `index()` before `search()`, otherwise `search()` throws `"Call index() before search()"`. The MCP server keeps a per-project singleton to avoid repeated model loads.
|
|
267
|
-
|
|
268
|
-
---
|
|
269
|
-
|
|
270
|
-
## Usage with AI Agents
|
|
271
|
-
|
|
272
|
-
The intent engine is designed to be called by AI coding agents as a pre-flight check:
|
|
273
|
-
|
|
274
|
-
```typescript
|
|
275
|
-
// In your AI agent's planning phase:
|
|
276
|
-
const pipeline = new PreflightPipeline(contract, lock)
|
|
277
|
-
const preflight = await pipeline.run(userPrompt)
|
|
278
|
-
|
|
279
|
-
if (!preflight.approved) {
|
|
280
|
-
// Show conflicts to user, ask for confirmation
|
|
281
|
-
const errors = preflight.conflicts.conflicts.filter(c => c.severity === 'error')
|
|
282
|
-
throw new Error(`Blocked: ${errors.map(e => e.message).join('; ')}`)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Use suggestions to guide implementation
|
|
286
|
-
for (const suggestion of preflight.suggestions) {
|
|
287
|
-
// suggestion.affectedFiles — files to read/modify
|
|
288
|
-
// suggestion.newFiles — files to create
|
|
289
|
-
// suggestion.implementation — guidance text
|
|
290
|
-
}
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
---
|
|
294
|
-
|
|
295
|
-
## Types
|
|
118
|
+
### Check availability
|
|
296
119
|
|
|
297
120
|
```typescript
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
Conflict,
|
|
301
|
-
ConflictResult,
|
|
302
|
-
Suggestion,
|
|
303
|
-
PreflightResult,
|
|
304
|
-
AIProviderConfig,
|
|
305
|
-
} from '@getmikk/intent-engine'
|
|
121
|
+
const available = await SemanticSearcher.isAvailable()
|
|
122
|
+
// true if @xenova/transformers is installed and importable
|
|
306
123
|
```
|
|
307
124
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
## License
|
|
311
|
-
|
|
312
|
-
[Apache-2.0](../../LICENSE)
|
|
125
|
+
The MCP server calls `isAvailable()` before registering the tool — if the package is missing, the tool is not exposed and the response explains how to install it.
|
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",
|
package/src/conflict-detector.ts
CHANGED
|
@@ -106,7 +106,7 @@ export class ConflictDetector {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
//
|
|
109
|
+
// --- Constraint Classification & Checking ---------------------
|
|
110
110
|
|
|
111
111
|
private classifyConstraint(text: string): ConstraintType {
|
|
112
112
|
const lower = text.toLowerCase()
|
|
@@ -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/semantic-searcher.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { MikkLock } from '@getmikk/core'
|
|
|
5
5
|
interface EmbeddingCache {
|
|
6
6
|
lockFingerprint: string
|
|
7
7
|
model: string
|
|
8
|
-
embeddings: Record<string, number[]> // fnId
|
|
8
|
+
embeddings: Record<string, number[]> // fnId -> unit-normed vector
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface SemanticMatch {
|
|
@@ -19,7 +19,7 @@ export interface SemanticMatch {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* SemanticSearcher
|
|
22
|
+
* SemanticSearcher -- finds functions semantically similar to a natural-language
|
|
23
23
|
* query using local embeddings via @xenova/transformers.
|
|
24
24
|
*
|
|
25
25
|
* Model: Xenova/all-MiniLM-L6-v2 (~22 MB, downloads once to ~/.cache/huggingface).
|
|
@@ -57,12 +57,12 @@ export class SemanticSearcher {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Build (or load from cache) embeddings for every function in the lock.
|
|
60
|
-
* Safe to call on every MCP request
|
|
60
|
+
* Safe to call on every MCP request -- cache hit is O(1) disk read.
|
|
61
61
|
*/
|
|
62
62
|
async index(lock: MikkLock): Promise<void> {
|
|
63
63
|
const fingerprint = lockFingerprint(lock)
|
|
64
64
|
|
|
65
|
-
//
|
|
65
|
+
// -- Cache hit --------------------------------------------------------
|
|
66
66
|
try {
|
|
67
67
|
const raw = await fs.readFile(this.cachePath, 'utf-8')
|
|
68
68
|
const cached: EmbeddingCache = JSON.parse(raw)
|
|
@@ -77,9 +77,9 @@ export class SemanticSearcher {
|
|
|
77
77
|
this.cache = cached
|
|
78
78
|
return
|
|
79
79
|
}
|
|
80
|
-
} catch { /* miss or corrupt
|
|
80
|
+
} catch { /* miss or corrupt -- rebuild */ }
|
|
81
81
|
|
|
82
|
-
//
|
|
82
|
+
// -- Empty lock fast-path -- nothing to embed ------------------------
|
|
83
83
|
const fns = Object.values(lock.functions)
|
|
84
84
|
if (fns.length === 0) {
|
|
85
85
|
this.cache = { lockFingerprint: fingerprint, model: SemanticSearcher.MODEL, embeddings: {} }
|
|
@@ -154,7 +154,7 @@ export class SemanticSearcher {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
//
|
|
157
|
+
// --- Helpers -----------------------------------------------------------------
|
|
158
158
|
|
|
159
159
|
/** Lightweight fingerprint: function count + first 20 sorted IDs */
|
|
160
160
|
function lockFingerprint(lock: MikkLock): string {
|
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
|
})
|