@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 CHANGED
@@ -1,84 +1,190 @@
1
- # @getmikk/intent-engine
1
+ # @getmikk/intent-engine
2
2
 
3
- > Parse developer intent, detect constraint conflicts, and find functions by meaning.
3
+ > Parse developer intent, enforce safety gates, run the Decision Engine, auto-correct issues, and find functions by meaning.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@getmikk/intent-engine)](https://www.npmjs.com/package/@getmikk/intent-engine)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](../../LICENSE)
7
7
 
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.
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-flight Pipeline
22
+ ## Pre-Edit Validation (`mikk_before_edit`)
15
23
 
16
- Takes a plain-English prompt describing a refactor or new feature, and returns a structured verdict before any code is written.
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
- ### Usage
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
- ```bash
21
- mikk intent "Move user validation into a shared utils module"
22
- mikk intent "Extract auth logic into middleware" --json
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
- Or programmatically:
54
+ ### Response shape
26
55
 
27
56
  ```typescript
28
- import { PreflightPipeline } from '@getmikk/intent-engine'
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
- const pipeline = new PreflightPipeline(contract, lock)
31
- const result = await pipeline.run("Add rate limiting to all API routes")
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
- console.log(result.approved) // true | false
34
- console.log(result.conflicts) // constraint violations found
35
- console.log(result.suggestions) // implementation suggestions with affected files
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
- ### What it returns
98
+ ---
99
+
100
+ ## Safety Gates
101
+
102
+ Six gates enforced by `EnforcedSafetyGates`. Use standalone or via `PreEditValidation`:
39
103
 
40
104
  ```typescript
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
67
- }
68
- ],
69
- approved: boolean
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
- ### Constraint checks
146
+ ---
147
+
148
+ ## Auto-Correction
74
149
 
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.
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. No external APIruns entirely on-device.
187
+ Find functions by natural-language description using local vector embeddings. Runs entirely on-deviceno 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
- ### How it works
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 and importable
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.8.0",
3
+ "version": "2.0.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.8.0",
25
- "zod": "^3.22.0"
26
- },
27
- "peerDependencies": {
28
- "@xenova/transformers": ""
29
- },
30
- "peerDependenciesMeta": {
31
- "@xenova/transformers": {
32
- "optional": true
33
- }
24
+ "@getmikk/core": "^2.0.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
+ }