@getmikk/intent-engine 1.8.0 → 1.9.1
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
package/README.md
CHANGED
|
@@ -1,84 +1,190 @@
|
|
|
1
|
-
|
|
1
|
+
# @getmikk/intent-engine
|
|
2
2
|
|
|
3
|
-
> Parse developer intent,
|
|
3
|
+
> Parse developer intent, enforce safety gates, run the Decision Engine, auto-correct issues, and find functions by meaning.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@getmikk/intent-engine)
|
|
6
6
|
[](../../LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The Intent Engine is the safety layer that validates every edit before it lands. It combines:
|
|
9
|
+
|
|
10
|
+
1. **PreflightPipeline** — pre-flight check for plain-English plans
|
|
11
|
+
2. **PreEditValidation** — full pre-edit safety validation (used by `mikk_before_edit`)
|
|
12
|
+
3. **IntentUnderstanding** — analyzes commit/branch context for intentional breaking changes
|
|
13
|
+
4. **EnforcedSafetyGates** — six gates that block or warn on risky edits
|
|
14
|
+
5. **DecisionEngine** — aggregates all signals into `APPROVED` / `WARNING` / `BLOCKED`
|
|
15
|
+
6. **AutoCorrectionEngine** — detects and auto-fixes broken references, imports, boundary violations
|
|
16
|
+
7. **SemanticSearcher** — local vector search for functions by natural-language description
|
|
9
17
|
|
|
10
18
|
> Part of [Mikk](../../README.md) — live architectural context for your AI agent.
|
|
11
19
|
|
|
12
20
|
---
|
|
13
21
|
|
|
14
|
-
## Pre-
|
|
22
|
+
## Pre-Edit Validation (`mikk_before_edit`)
|
|
15
23
|
|
|
16
|
-
|
|
24
|
+
The main entry point for the MCP `mikk_before_edit` tool. Runs the full pipeline against a set of files the AI intends to edit.
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
```typescript
|
|
27
|
+
import { PreEditValidation } from '@getmikk/intent-engine'
|
|
28
|
+
|
|
29
|
+
const validator = new PreEditValidation(contract, lock, graph, projectRoot, {
|
|
30
|
+
maxRiskScore: 70,
|
|
31
|
+
maxImpactNodes: 10,
|
|
32
|
+
protectedModules: ['auth', 'billing'],
|
|
33
|
+
requireTestsForChangedFiles: true,
|
|
34
|
+
requireDocumentationForApiChanges: false,
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const result = await validator.validate({
|
|
38
|
+
files: ['src/auth/login.ts', 'src/auth/session.ts'],
|
|
39
|
+
description: 'Refactor JWT validation to use new token format',
|
|
40
|
+
author: 'dev@example.com',
|
|
41
|
+
intent: {
|
|
42
|
+
commitMessage: 'REFACTOR: update JWT validation',
|
|
43
|
+
branchName: 'refactor/jwt-v2',
|
|
44
|
+
},
|
|
45
|
+
})
|
|
19
46
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
console.log(result.allowed) // true | false
|
|
48
|
+
console.log(result.intent) // { isIntentionalBreakingChange, confidence, reasoning }
|
|
49
|
+
console.log(result.gates) // per-gate pass/fail with reason and bypassable flag
|
|
50
|
+
console.log(result.corrections) // auto-fixed issues and suggestions
|
|
51
|
+
console.log(result.recommendations) // contextual next steps
|
|
23
52
|
```
|
|
24
53
|
|
|
25
|
-
|
|
54
|
+
### Response shape
|
|
26
55
|
|
|
27
56
|
```typescript
|
|
28
|
-
|
|
57
|
+
{
|
|
58
|
+
allowed: boolean,
|
|
59
|
+
confidence: number, // 0–1 intent confidence
|
|
60
|
+
|
|
61
|
+
intent: {
|
|
62
|
+
isIntentionalBreakingChange: boolean,
|
|
63
|
+
confidence: number,
|
|
64
|
+
reasoning: string[],
|
|
65
|
+
riskAcceptance: 'none' | 'low' | 'medium' | 'high'
|
|
66
|
+
},
|
|
29
67
|
|
|
30
|
-
|
|
31
|
-
|
|
68
|
+
impact: {
|
|
69
|
+
totalFiles: number,
|
|
70
|
+
totalFunctions: number,
|
|
71
|
+
riskScore: number, // 0–100
|
|
72
|
+
criticalPaths: string[], // high-calledBy functions
|
|
73
|
+
blastRadius: string[] // functions in calledBy chain
|
|
74
|
+
},
|
|
32
75
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
76
|
+
gates: Array<{
|
|
77
|
+
name: string, // RISK_SCORE | IMPACT_SCALE | PROTECTED_MODULE | ...
|
|
78
|
+
passed: boolean,
|
|
79
|
+
severity: 'BLOCKING' | 'WARNING',
|
|
80
|
+
message: string,
|
|
81
|
+
bypassable: boolean
|
|
82
|
+
}>,
|
|
83
|
+
|
|
84
|
+
corrections: {
|
|
85
|
+
available: boolean,
|
|
86
|
+
issuesFound: number,
|
|
87
|
+
autoFixable: number,
|
|
88
|
+
applied: string[],
|
|
89
|
+
suggested: string[]
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
recommendations: string[],
|
|
93
|
+
nextSteps: string[],
|
|
94
|
+
tokenSavings: number
|
|
95
|
+
}
|
|
36
96
|
```
|
|
37
97
|
|
|
38
|
-
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Safety Gates
|
|
101
|
+
|
|
102
|
+
Six gates enforced by `EnforcedSafetyGates`. Use standalone or via `PreEditValidation`:
|
|
39
103
|
|
|
40
104
|
```typescript
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
105
|
+
import { EnforcedSafetyGates } from '@getmikk/intent-engine'
|
|
106
|
+
|
|
107
|
+
const gates = new EnforcedSafetyGates(contract, lock, graph, {
|
|
108
|
+
maxRiskScore: 70,
|
|
109
|
+
maxImpactNodes: 10,
|
|
110
|
+
protectedModules: ['auth'],
|
|
111
|
+
enforceOnSave: true,
|
|
112
|
+
enforceOnCommit: true,
|
|
113
|
+
enforceInCI: true,
|
|
114
|
+
requireTestsForChangedFiles: true,
|
|
115
|
+
requireDocumentationForApiChanges: false,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const results = await gates.validateEdits(['src/auth/login.ts'])
|
|
119
|
+
const { allowed, blockingGates } = gates.canProceed(results)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| Gate | Blocks when | Bypassable |
|
|
123
|
+
|------|------------|-----------|
|
|
124
|
+
| `RISK_SCORE` | Risk ≥ 90, or > `maxRiskScore` | Yes (except ≥ 90) |
|
|
125
|
+
| `IMPACT_SCALE` | Impact > `maxImpactNodes × 2` | Yes |
|
|
126
|
+
| `PROTECTED_MODULE` | Protected module touched | **Never** |
|
|
127
|
+
| `BREAKING_CHANGE` | Exported API changed without `BREAKING:` marker | Yes |
|
|
128
|
+
| `TEST_COVERAGE` | High-risk changes with no test file edits | Yes |
|
|
129
|
+
| `DOCUMENTATION` | Significant API changes with no doc updates | Yes |
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Decision Engine
|
|
134
|
+
|
|
135
|
+
Evaluates an `ImpactResult` against your policies:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { DecisionEngine } from '@getmikk/intent-engine'
|
|
139
|
+
|
|
140
|
+
const engine = new DecisionEngine(contract)
|
|
141
|
+
const decision = engine.evaluate(impactResult)
|
|
142
|
+
|
|
143
|
+
// { status: 'APPROVED' | 'WARNING' | 'BLOCKED', reasons: string[], riskScore: number, impactNodes: number }
|
|
71
144
|
```
|
|
72
145
|
|
|
73
|
-
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Auto-Correction
|
|
74
149
|
|
|
75
|
-
|
|
150
|
+
Detects and fixes common issues in source files:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { AutoCorrectionEngine } from '@getmikk/intent-engine'
|
|
154
|
+
|
|
155
|
+
const corrector = new AutoCorrectionEngine(contract, lock, graph, projectRoot)
|
|
156
|
+
const result = await corrector.analyzeAndFix(['src/auth/login.ts'])
|
|
157
|
+
|
|
158
|
+
console.log(result.issues) // all detected issues
|
|
159
|
+
console.log(result.appliedFixes) // auto-applied fixes
|
|
160
|
+
console.log(result.failedFixes) // fixes that failed to apply
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Issues detected: `broken_reference` · `missing_import` · `boundary_violation`
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Pre-flight Pipeline
|
|
168
|
+
|
|
169
|
+
For plain-English intent validation before writing any code:
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
import { PreflightPipeline } from '@getmikk/intent-engine'
|
|
173
|
+
|
|
174
|
+
const pipeline = new PreflightPipeline(contract, lock)
|
|
175
|
+
const result = await pipeline.run("Add rate limiting to all API routes")
|
|
176
|
+
|
|
177
|
+
console.log(result.approved) // true | false
|
|
178
|
+
console.log(result.conflicts) // constraint violations
|
|
179
|
+
console.log(result.decision) // DecisionResult from DecisionEngine
|
|
180
|
+
console.log(result.explanation) // human-readable summary
|
|
181
|
+
```
|
|
76
182
|
|
|
77
183
|
---
|
|
78
184
|
|
|
79
185
|
## Semantic Search
|
|
80
186
|
|
|
81
|
-
Find functions by natural-language description using local vector embeddings.
|
|
187
|
+
Find functions by natural-language description using local vector embeddings. Runs entirely on-device — no external API.
|
|
82
188
|
|
|
83
189
|
### Setup
|
|
84
190
|
|
|
@@ -90,36 +196,19 @@ The model (`Xenova/all-MiniLM-L6-v2`, ~22MB) downloads once to `~/.cache/hugging
|
|
|
90
196
|
|
|
91
197
|
### Usage
|
|
92
198
|
|
|
93
|
-
Exposed via the MCP tool `mikk_semantic_search`, or directly:
|
|
94
|
-
|
|
95
199
|
```typescript
|
|
96
200
|
import { SemanticSearcher } from '@getmikk/intent-engine'
|
|
97
201
|
|
|
98
202
|
const searcher = new SemanticSearcher(projectRoot)
|
|
99
|
-
|
|
100
|
-
// Build (or load from cache) embeddings for the lock
|
|
101
203
|
await searcher.index(lock)
|
|
102
204
|
|
|
103
|
-
// Find the 10 most semantically similar functions
|
|
104
205
|
const results = await searcher.search('validate a JWT token', lock, 10)
|
|
105
206
|
// Returns: [{ name, file, moduleId, purpose, lines, score }]
|
|
106
207
|
```
|
|
107
208
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
All vectors are unit-normalized at generation time so similarity is a simple dot product.
|
|
117
|
-
|
|
118
|
-
### Check availability
|
|
209
|
+
Embeddings are cached to `.mikk/embeddings.json` and only recomputed when the lock changes.
|
|
119
210
|
|
|
120
211
|
```typescript
|
|
121
212
|
const available = await SemanticSearcher.isAvailable()
|
|
122
|
-
// true if @xenova/transformers is installed
|
|
213
|
+
// true if @xenova/transformers is installed
|
|
123
214
|
```
|
|
124
|
-
|
|
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.1",
|
|
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,284 @@
|
|
|
1
|
+
import * as nodePath from 'node:path'
|
|
2
|
+
import * as fs from 'node:fs/promises'
|
|
3
|
+
import type { MikkContract, MikkLock, DependencyGraph } from '@getmikk/core'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* AutoCorrectionEngine — detects and auto-fixes common code issues.
|
|
7
|
+
*
|
|
8
|
+
* Capabilities:
|
|
9
|
+
* 1. Broken call references (pointing to IDs that no longer exist in the lock)
|
|
10
|
+
* 2. Missing imports (resolved path absent from lock)
|
|
11
|
+
* 3. Boundary violations (cross-module calls that break declared constraints)
|
|
12
|
+
*/
|
|
13
|
+
export interface CorrectionIssue {
|
|
14
|
+
type: 'missing_import' | 'broken_reference' | 'boundary_violation' | 'missing_type' | 'null_safety'
|
|
15
|
+
severity: 'error' | 'warning'
|
|
16
|
+
file: string
|
|
17
|
+
line: number
|
|
18
|
+
column: number
|
|
19
|
+
message: string
|
|
20
|
+
autoFixable: boolean
|
|
21
|
+
suggestedFix: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CorrectionResult {
|
|
25
|
+
issues: CorrectionIssue[]
|
|
26
|
+
appliedFixes: string[]
|
|
27
|
+
failedFixes: string[]
|
|
28
|
+
filesModified: string[]
|
|
29
|
+
tokenCost: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AutoCorrectionEngine {
|
|
33
|
+
constructor(
|
|
34
|
+
private contract: MikkContract,
|
|
35
|
+
private lock: MikkLock,
|
|
36
|
+
private _graph: DependencyGraph,
|
|
37
|
+
private projectRoot: string,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
/** Analyze files and auto-apply safe fixes. */
|
|
41
|
+
async analyzeAndFix(files: string[]): Promise<CorrectionResult> {
|
|
42
|
+
const issues: CorrectionIssue[] = []
|
|
43
|
+
const appliedFixes: string[] = []
|
|
44
|
+
const failedFixes: string[] = []
|
|
45
|
+
const filesModified = new Set<string>()
|
|
46
|
+
let tokenCost = 0
|
|
47
|
+
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
const fileIssues = await this.analyzeFile(file)
|
|
50
|
+
issues.push(...fileIssues)
|
|
51
|
+
|
|
52
|
+
for (const issue of fileIssues) {
|
|
53
|
+
if (!issue.autoFixable) continue
|
|
54
|
+
try {
|
|
55
|
+
const ok = await this.applyFix(issue)
|
|
56
|
+
if (ok) {
|
|
57
|
+
appliedFixes.push(`${file}:${issue.line} — ${issue.message}`)
|
|
58
|
+
filesModified.add(file)
|
|
59
|
+
tokenCost += 100 // ~100 tokens per fix
|
|
60
|
+
} else {
|
|
61
|
+
failedFixes.push(`${file}:${issue.line} — ${issue.message}`)
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
failedFixes.push(`${file}:${issue.line} — ${issue.message}`)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { issues, appliedFixes, failedFixes, filesModified: [...filesModified], tokenCost }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Private: analysis ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
private async analyzeFile(file: string): Promise<CorrectionIssue[]> {
|
|
75
|
+
const issues: CorrectionIssue[] = []
|
|
76
|
+
const norm = file.replace(/\\/g, '/')
|
|
77
|
+
|
|
78
|
+
const fileFunctions = Object.values(this.lock.functions).filter(
|
|
79
|
+
f => f.file === norm || f.file.endsWith('/' + norm),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for (const fn of fileFunctions) {
|
|
83
|
+
// Broken call references
|
|
84
|
+
for (const callId of fn.calls) {
|
|
85
|
+
if (!this.lock.functions[callId]) {
|
|
86
|
+
// Extract the plain name from the ID (fn:path:Name → Name)
|
|
87
|
+
const calleeName = callId.split(':').pop() ?? callId
|
|
88
|
+
const similar = this.findSimilarFunction(calleeName)
|
|
89
|
+
issues.push({
|
|
90
|
+
type: 'broken_reference',
|
|
91
|
+
severity: 'error',
|
|
92
|
+
file: norm,
|
|
93
|
+
line: fn.startLine,
|
|
94
|
+
column: 1,
|
|
95
|
+
message: `Function "${calleeName}" not found in lock.${similar ? ` Did you mean "${similar}"?` : ''}`,
|
|
96
|
+
autoFixable: !!similar,
|
|
97
|
+
suggestedFix: similar ?? '',
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Boundary violations
|
|
103
|
+
for (const v of this.checkBoundaryViolations(fn)) {
|
|
104
|
+
issues.push({
|
|
105
|
+
type: 'boundary_violation',
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
file: norm,
|
|
108
|
+
line: fn.startLine,
|
|
109
|
+
column: 1,
|
|
110
|
+
message: v.message,
|
|
111
|
+
autoFixable: true,
|
|
112
|
+
suggestedFix: v.suggestedAdapter,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Missing imports
|
|
118
|
+
const fileEntry = this.lock.files[norm]
|
|
119
|
+
if (fileEntry) {
|
|
120
|
+
for (const imp of fileEntry.imports ?? []) {
|
|
121
|
+
if (imp.resolvedPath && !this.lock.files[imp.resolvedPath]) {
|
|
122
|
+
const corrected = this.findCorrectedImportPath(imp.resolvedPath)
|
|
123
|
+
if (corrected && corrected !== imp.resolvedPath) {
|
|
124
|
+
issues.push({
|
|
125
|
+
type: 'missing_import',
|
|
126
|
+
severity: 'error',
|
|
127
|
+
file: norm,
|
|
128
|
+
line: 1,
|
|
129
|
+
column: 1,
|
|
130
|
+
message: `Import "${imp.source}" resolves to missing file "${imp.resolvedPath}"`,
|
|
131
|
+
autoFixable: true,
|
|
132
|
+
suggestedFix: corrected,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return issues
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Private: fix application ───────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
private async applyFix(issue: CorrectionIssue): Promise<boolean> {
|
|
145
|
+
// Only operate on files inside the project root
|
|
146
|
+
const abs = nodePath.resolve(this.projectRoot, issue.file)
|
|
147
|
+
const rootResolved = nodePath.resolve(this.projectRoot)
|
|
148
|
+
if (!abs.startsWith(rootResolved + nodePath.sep) && abs !== rootResolved) return false
|
|
149
|
+
|
|
150
|
+
let content: string
|
|
151
|
+
try {
|
|
152
|
+
content = await fs.readFile(abs, 'utf-8')
|
|
153
|
+
} catch {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let newContent: string
|
|
158
|
+
switch (issue.type) {
|
|
159
|
+
case 'broken_reference':
|
|
160
|
+
newContent = this.fixBrokenReference(content, issue)
|
|
161
|
+
break
|
|
162
|
+
case 'missing_import':
|
|
163
|
+
newContent = this.fixMissingImport(content, issue)
|
|
164
|
+
break
|
|
165
|
+
case 'boundary_violation':
|
|
166
|
+
newContent = this.addBoundaryWarningComment(content, issue)
|
|
167
|
+
break
|
|
168
|
+
default:
|
|
169
|
+
return false
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (newContent === content) return false
|
|
173
|
+
await fs.writeFile(abs, newContent, 'utf-8')
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Replace the old function name with the suggested name.
|
|
179
|
+
*
|
|
180
|
+
* The issue message is: `Function "<calleeName>" not found...`
|
|
181
|
+
* We extract calleeName from the quotes and do a whole-word replace.
|
|
182
|
+
* We use a simple string-literal escaping so no regex injection occurs.
|
|
183
|
+
*/
|
|
184
|
+
private fixBrokenReference(content: string, issue: CorrectionIssue): string {
|
|
185
|
+
const oldName = issue.message.match(/^Function "([^"]+)"/)?.[1]
|
|
186
|
+
const newName = issue.suggestedFix
|
|
187
|
+
|
|
188
|
+
if (!oldName || !newName || oldName === newName) return content
|
|
189
|
+
|
|
190
|
+
// Escape any regex metacharacters in the name (names can contain `.` for methods)
|
|
191
|
+
const escaped = oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
192
|
+
return content.replace(new RegExp(`\\b${escaped}\\b`, 'g'), newName)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Replace the old resolved path with the corrected one in import statements.
|
|
197
|
+
*/
|
|
198
|
+
private fixMissingImport(content: string, issue: CorrectionIssue): string {
|
|
199
|
+
// Extract the old path from the message: `...missing file "<path>"`
|
|
200
|
+
const oldPath = issue.message.match(/missing file "([^"]+)"/)?.[1]
|
|
201
|
+
const newPath = issue.suggestedFix
|
|
202
|
+
if (!oldPath || !newPath) return content
|
|
203
|
+
// Only replace inside string literals (quoted) to avoid broad substitution
|
|
204
|
+
return content.split(oldPath).join(newPath)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Prepend a TODO comment flagging the boundary violation.
|
|
209
|
+
* Real adapter generation would go here in a future iteration.
|
|
210
|
+
*/
|
|
211
|
+
private addBoundaryWarningComment(content: string, issue: CorrectionIssue): string {
|
|
212
|
+
const comment = `// TODO [mikk]: Boundary violation — ${issue.message}\n// Suggested: ${issue.suggestedFix}\n`
|
|
213
|
+
return comment + content
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Private: helpers ───────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
private findSimilarFunction(missingName: string): string | null {
|
|
219
|
+
// Strip class prefix (Class.method → method) for matching
|
|
220
|
+
const simpleName = missingName.includes('.') ? missingName.split('.').pop()! : missingName
|
|
221
|
+
|
|
222
|
+
const candidates = Object.values(this.lock.functions)
|
|
223
|
+
.map(f => {
|
|
224
|
+
const name = f.name.includes('.') ? f.name.split('.').pop()! : f.name
|
|
225
|
+
return { fn: f, dist: this.levenshtein(simpleName, name) }
|
|
226
|
+
})
|
|
227
|
+
.filter(x => x.dist <= 3)
|
|
228
|
+
.sort((a, b) => a.dist - b.dist)
|
|
229
|
+
|
|
230
|
+
return candidates[0]?.fn.name ?? null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private checkBoundaryViolations(fn: MikkLock['functions'][string]): Array<{ message: string; suggestedAdapter: string }> {
|
|
234
|
+
const violations: Array<{ message: string; suggestedAdapter: string }> = []
|
|
235
|
+
const constraints = this.contract.declared?.constraints ?? []
|
|
236
|
+
|
|
237
|
+
for (const callId of fn.calls) {
|
|
238
|
+
const target = this.lock.functions[callId]
|
|
239
|
+
if (!target || fn.moduleId === target.moduleId) continue
|
|
240
|
+
|
|
241
|
+
// Check if any constraint text mentions both modules with "no-import"
|
|
242
|
+
const violated = constraints.some(c => {
|
|
243
|
+
const lower = typeof c === 'string' ? c.toLowerCase() : ''
|
|
244
|
+
return (
|
|
245
|
+
lower.includes('no-import') &&
|
|
246
|
+
lower.includes(fn.moduleId.toLowerCase()) &&
|
|
247
|
+
lower.includes(target.moduleId.toLowerCase())
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
if (violated) {
|
|
252
|
+
violations.push({
|
|
253
|
+
message: `Boundary violation: ${fn.moduleId} → ${target.moduleId} breaks a declared constraint`,
|
|
254
|
+
suggestedAdapter: `Create adapter in ${fn.moduleId} that wraps ${target.moduleId}::${target.name}`,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return violations
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private findCorrectedImportPath(oldPath: string): string | null {
|
|
263
|
+
const fileName = oldPath.split('/').pop()
|
|
264
|
+
if (!fileName) return null
|
|
265
|
+
const matches = Object.keys(this.lock.files).filter(p => p.endsWith('/' + fileName))
|
|
266
|
+
return matches.length === 1 ? matches[0] : null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** O(n·m) Levenshtein with early exit when distance exceeds threshold. */
|
|
270
|
+
private levenshtein(a: string, b: string, threshold = 4): number {
|
|
271
|
+
if (Math.abs(a.length - b.length) > threshold) return threshold + 1
|
|
272
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
273
|
+
for (let i = 1; i <= a.length; i++) {
|
|
274
|
+
let prev = i
|
|
275
|
+
for (let j = 1; j <= b.length; j++) {
|
|
276
|
+
const val = a[i - 1] === b[j - 1] ? row[j - 1] : Math.min(row[j - 1], row[j], prev) + 1
|
|
277
|
+
row[j - 1] = prev
|
|
278
|
+
prev = val
|
|
279
|
+
}
|
|
280
|
+
row[b.length] = prev
|
|
281
|
+
}
|
|
282
|
+
return row[b.length]
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ImpactResult, MikkContract } from '@getmikk/core'
|
|
2
|
+
import type { DecisionResult, DecisionStatus } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DecisionEngine — evaluates quantitative impact analysis against project policies.
|
|
6
|
+
*
|
|
7
|
+
* Policies live in contract.policies (all optional with safe defaults):
|
|
8
|
+
* - maxRiskScore (default 70) — WARNING above, BLOCKED at ≥ 90
|
|
9
|
+
* - maxImpactNodes (default 10) — WARNING/BLOCKED when exceeded
|
|
10
|
+
* - protectedModules (default []) — BLOCKED if a CRITICAL/HIGH-risk node's
|
|
11
|
+
* file path matches a protected module name
|
|
12
|
+
* - enforceStrictBoundaries (false) — BLOCKED on any critical cross-module hit
|
|
13
|
+
*/
|
|
14
|
+
export class DecisionEngine {
|
|
15
|
+
constructor(private contract: MikkContract) {}
|
|
16
|
+
|
|
17
|
+
evaluate(impact: ImpactResult): DecisionResult {
|
|
18
|
+
const policy = {
|
|
19
|
+
maxRiskScore: this.contract.policies?.maxRiskScore ?? 70,
|
|
20
|
+
maxImpactNodes: this.contract.policies?.maxImpactNodes ?? 10,
|
|
21
|
+
protectedModules: this.contract.policies?.protectedModules ?? [] as string[],
|
|
22
|
+
enforceStrictBoundaries: this.contract.policies?.enforceStrictBoundaries ?? false,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const reasons: string[] = []
|
|
26
|
+
let status: DecisionStatus = 'APPROVED'
|
|
27
|
+
const promote = (next: DecisionStatus) => {
|
|
28
|
+
if (next === 'BLOCKED' || (next === 'WARNING' && status === 'APPROVED')) status = next
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 1. Absolute risk threshold
|
|
32
|
+
if (impact.riskScore >= 90) {
|
|
33
|
+
promote('BLOCKED')
|
|
34
|
+
reasons.push(`Critical risk detected (${impact.riskScore}/100). Changes exceeding 90 are always blocked.`)
|
|
35
|
+
} else if (impact.riskScore > policy.maxRiskScore) {
|
|
36
|
+
promote('WARNING')
|
|
37
|
+
reasons.push(`High risk (${impact.riskScore}) exceeds policy threshold of ${policy.maxRiskScore}.`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 2. Impact scale
|
|
41
|
+
if (impact.impacted.length > policy.maxImpactNodes) {
|
|
42
|
+
promote('WARNING')
|
|
43
|
+
reasons.push(`Impact spread (${impact.impacted.length} symbols) exceeds limit of ${policy.maxImpactNodes}.`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 3. Protected modules
|
|
47
|
+
// allImpacted entries are ClassifiedImpact objects — they have `nodeId`
|
|
48
|
+
// and `riskScore` (numeric), NOT a `.risk` string field.
|
|
49
|
+
const touched = new Set<string>()
|
|
50
|
+
for (const node of impact.allImpacted) {
|
|
51
|
+
// Only act on nodes above the HIGH threshold (riskScore ≥ 60)
|
|
52
|
+
if (node.riskScore < 60) continue
|
|
53
|
+
for (const pm of policy.protectedModules) {
|
|
54
|
+
if (node.file.toLowerCase().includes(pm.toLowerCase())) {
|
|
55
|
+
touched.add(pm)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (touched.size > 0) {
|
|
60
|
+
promote('WARNING')
|
|
61
|
+
reasons.push(`Change affects protected modules: ${[...touched].join(', ')}.`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Strict boundary enforcement
|
|
65
|
+
if (policy.enforceStrictBoundaries && impact.classified.critical.length > 0) {
|
|
66
|
+
promote('BLOCKED')
|
|
67
|
+
reasons.push(
|
|
68
|
+
`Strict boundary enforcement: ${impact.classified.critical.length} critical cross-module impact(s) detected.`,
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { status, reasons, riskScore: impact.riskScore, impactNodes: impact.impacted.length }
|
|
73
|
+
}
|
|
74
|
+
}
|