@getmikk/intent-engine 1.9.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 +1 -1
- package/src/auto-correction.ts +284 -0
- package/src/decision-engine.ts +50 -45
- package/src/enforced-safety.ts +305 -0
- package/src/explanation-engine.ts +56 -36
- 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 +34 -35
package/src/decision-engine.ts
CHANGED
|
@@ -2,68 +2,73 @@ import type { ImpactResult, MikkContract } from '@getmikk/core'
|
|
|
2
2
|
import type { DecisionResult, DecisionStatus } from './types.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* DecisionEngine —
|
|
6
|
-
*
|
|
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
|
|
7
13
|
*/
|
|
8
14
|
export class DecisionEngine {
|
|
9
15
|
constructor(private contract: MikkContract) {}
|
|
10
16
|
|
|
11
|
-
/**
|
|
12
|
-
* Evaluate an impact result against the defined policies.
|
|
13
|
-
*/
|
|
14
17
|
evaluate(impact: ImpactResult): DecisionResult {
|
|
15
18
|
const policy = {
|
|
16
|
-
maxRiskScore:
|
|
17
|
-
maxImpactNodes:
|
|
18
|
-
protectedModules:
|
|
19
|
+
maxRiskScore: this.contract.policies?.maxRiskScore ?? 70,
|
|
20
|
+
maxImpactNodes: this.contract.policies?.maxImpactNodes ?? 10,
|
|
21
|
+
protectedModules: this.contract.policies?.protectedModules ?? [] as string[],
|
|
19
22
|
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;
|
|
23
|
+
}
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} else if (maxRisk > policy.maxRiskScore) {
|
|
32
|
-
status = 'WARNING';
|
|
33
|
-
reasons.push(`High risk (${maxRisk}) exceeds policy threshold of ${policy.maxRiskScore}.`);
|
|
25
|
+
const reasons: string[] = []
|
|
26
|
+
let status: DecisionStatus = 'APPROVED'
|
|
27
|
+
const promote = (next: DecisionStatus) => {
|
|
28
|
+
if (next === 'BLOCKED' || (next === 'WARNING' && status === 'APPROVED')) status = next
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
//
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
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}.`)
|
|
40
38
|
}
|
|
41
39
|
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
})
|
|
48
|
-
.filter((m): m is string => !!m);
|
|
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
|
+
}
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(', ')}.`)
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
// 4.
|
|
64
|
+
// 4. Strict boundary enforcement
|
|
57
65
|
if (policy.enforceStrictBoundaries && impact.classified.critical.length > 0) {
|
|
58
|
-
|
|
59
|
-
reasons.push(
|
|
66
|
+
promote('BLOCKED')
|
|
67
|
+
reasons.push(
|
|
68
|
+
`Strict boundary enforcement: ${impact.classified.critical.length} critical cross-module impact(s) detected.`,
|
|
69
|
+
)
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
return {
|
|
63
|
-
status,
|
|
64
|
-
reasons,
|
|
65
|
-
riskScore: maxRisk,
|
|
66
|
-
impactNodes: impactCount
|
|
67
|
-
};
|
|
72
|
+
return { status, reasons, riskScore: impact.riskScore, impactNodes: impact.impacted.length }
|
|
68
73
|
}
|
|
69
74
|
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock, DependencyGraph } from '@getmikk/core'
|
|
2
|
+
import { ImpactAnalyzer } from '@getmikk/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EnforcedSafetyGates — blocks unsafe edits at edit-time.
|
|
6
|
+
*
|
|
7
|
+
* Unlike warnings, blocking gates PREVENT edits from being applied
|
|
8
|
+
* unless explicitly bypassed.
|
|
9
|
+
*
|
|
10
|
+
* Integration points:
|
|
11
|
+
* - Pre-commit hooks
|
|
12
|
+
* - IDE save handlers
|
|
13
|
+
* - CI/CD gates
|
|
14
|
+
* - MCP tool validation (mikk_before_edit)
|
|
15
|
+
*/
|
|
16
|
+
export interface SafetyGateResult {
|
|
17
|
+
canProceed: boolean
|
|
18
|
+
gate: string
|
|
19
|
+
reason: string
|
|
20
|
+
severity: 'BLOCKING' | 'WARNING'
|
|
21
|
+
bypassable: boolean
|
|
22
|
+
bypassCommand?: string
|
|
23
|
+
autoFixable: boolean
|
|
24
|
+
suggestedFix?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SafetyGateConfig {
|
|
28
|
+
enforceOnSave: boolean
|
|
29
|
+
enforceOnCommit: boolean
|
|
30
|
+
enforceInCI: boolean
|
|
31
|
+
maxRiskScore: number
|
|
32
|
+
maxImpactNodes: number
|
|
33
|
+
requireTestsForChangedFiles: boolean
|
|
34
|
+
requireDocumentationForApiChanges: boolean
|
|
35
|
+
protectedModules: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class EnforcedSafetyGates {
|
|
39
|
+
private analyzer: ImpactAnalyzer
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private contract: MikkContract,
|
|
43
|
+
private lock: MikkLock,
|
|
44
|
+
graph: DependencyGraph,
|
|
45
|
+
private config: SafetyGateConfig,
|
|
46
|
+
) {
|
|
47
|
+
this.analyzer = new ImpactAnalyzer(graph)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Run all safety gates before allowing edits. */
|
|
51
|
+
async validateEdits(
|
|
52
|
+
files: string[],
|
|
53
|
+
context?: { commitMessage?: string; branchName?: string; isTestRun?: boolean },
|
|
54
|
+
): Promise<SafetyGateResult[]> {
|
|
55
|
+
return [
|
|
56
|
+
await this.checkRiskGate(files),
|
|
57
|
+
await this.checkScaleGate(files),
|
|
58
|
+
await this.checkProtectedModuleGate(files),
|
|
59
|
+
await this.checkBreakingChangeGate(files, context),
|
|
60
|
+
await this.checkTestCoverageGate(files),
|
|
61
|
+
await this.checkDocumentationGate(files),
|
|
62
|
+
]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Returns whether all blocking gates passed. */
|
|
66
|
+
canProceed(results: SafetyGateResult[]): { allowed: boolean; blockingGates: string[] } {
|
|
67
|
+
const blocking = results.filter(r => !r.canProceed && r.severity === 'BLOCKING')
|
|
68
|
+
return { allowed: blocking.length === 0, blockingGates: blocking.map(r => r.gate) }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Gate implementations ──────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
private async checkRiskGate(files: string[]): Promise<SafetyGateResult> {
|
|
74
|
+
const fileNodes = this.getFileNodes(files)
|
|
75
|
+
|
|
76
|
+
if (fileNodes.length === 0) {
|
|
77
|
+
return this.pass('RISK_SCORE', 'No tracked functions in modified files')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const impact = this.analyzer.analyze(fileNodes.map(n => n.id))
|
|
81
|
+
const risk = impact.riskScore
|
|
82
|
+
|
|
83
|
+
if (risk >= 90) {
|
|
84
|
+
return {
|
|
85
|
+
canProceed: false,
|
|
86
|
+
gate: 'RISK_SCORE',
|
|
87
|
+
reason: `Critical risk score: ${risk}/100. Changes could break significant portions of the system.`,
|
|
88
|
+
severity: 'BLOCKING',
|
|
89
|
+
bypassable: false,
|
|
90
|
+
autoFixable: false,
|
|
91
|
+
suggestedFix: 'Break changes into smaller increments or add comprehensive tests',
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (risk > this.config.maxRiskScore) {
|
|
95
|
+
return {
|
|
96
|
+
canProceed: false,
|
|
97
|
+
gate: 'RISK_SCORE',
|
|
98
|
+
reason: `High risk: ${risk}/100 exceeds threshold of ${this.config.maxRiskScore}`,
|
|
99
|
+
severity: 'BLOCKING',
|
|
100
|
+
bypassable: true,
|
|
101
|
+
bypassCommand: 'mikk safety bypass --gate=RISK_SCORE --reason="<explanation>"',
|
|
102
|
+
autoFixable: false,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return this.pass('RISK_SCORE', `Risk score ${risk} within acceptable limits`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async checkScaleGate(files: string[]): Promise<SafetyGateResult> {
|
|
110
|
+
const fileNodes = this.getFileNodes(files)
|
|
111
|
+
|
|
112
|
+
if (fileNodes.length === 0) {
|
|
113
|
+
return this.pass('IMPACT_SCALE', 'No tracked functions in modified files')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const impact = this.analyzer.analyze(fileNodes.map(n => n.id))
|
|
117
|
+
const count = impact.impacted.length
|
|
118
|
+
const hardLimit = this.config.maxImpactNodes * 2
|
|
119
|
+
|
|
120
|
+
if (count > hardLimit) {
|
|
121
|
+
return {
|
|
122
|
+
canProceed: false,
|
|
123
|
+
gate: 'IMPACT_SCALE',
|
|
124
|
+
reason: `Massive blast radius: ${count} functions affected. Hard limit: ${hardLimit}`,
|
|
125
|
+
severity: 'BLOCKING',
|
|
126
|
+
bypassable: false,
|
|
127
|
+
autoFixable: false,
|
|
128
|
+
suggestedFix: 'Split into multiple PRs or create a migration plan',
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (count > this.config.maxImpactNodes) {
|
|
132
|
+
return {
|
|
133
|
+
canProceed: false,
|
|
134
|
+
gate: 'IMPACT_SCALE',
|
|
135
|
+
reason: `Large impact: ${count} functions affected. Threshold: ${this.config.maxImpactNodes}`,
|
|
136
|
+
severity: 'BLOCKING',
|
|
137
|
+
bypassable: true,
|
|
138
|
+
bypassCommand: 'mikk safety bypass --gate=IMPACT_SCALE --reason="<explanation>"',
|
|
139
|
+
autoFixable: false,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return this.pass('IMPACT_SCALE', `Impact scale acceptable: ${count} functions`)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async checkProtectedModuleGate(files: string[]): Promise<SafetyGateResult> {
|
|
147
|
+
const touchedProtected: string[] = []
|
|
148
|
+
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
const norm = file.replace(/\\/g, '/')
|
|
151
|
+
const fileEntry = Object.values(this.lock.files).find(
|
|
152
|
+
f => f.path === norm || norm.endsWith(f.path),
|
|
153
|
+
)
|
|
154
|
+
// Guard: moduleId may be absent on file entries
|
|
155
|
+
if (fileEntry?.moduleId && this.config.protectedModules.includes(fileEntry.moduleId)) {
|
|
156
|
+
touchedProtected.push(fileEntry.moduleId)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const unique = [...new Set(touchedProtected)]
|
|
161
|
+
if (unique.length > 0) {
|
|
162
|
+
return {
|
|
163
|
+
canProceed: false,
|
|
164
|
+
gate: 'PROTECTED_MODULE',
|
|
165
|
+
reason: `Modified protected modules: ${unique.join(', ')}. These require architecture review.`,
|
|
166
|
+
severity: 'BLOCKING',
|
|
167
|
+
bypassable: false,
|
|
168
|
+
autoFixable: false,
|
|
169
|
+
suggestedFix: 'Request architecture review or schedule a change review meeting',
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return this.pass('PROTECTED_MODULE', 'No protected modules touched')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async checkBreakingChangeGate(
|
|
177
|
+
files: string[],
|
|
178
|
+
context?: { commitMessage?: string; branchName?: string },
|
|
179
|
+
): Promise<SafetyGateResult> {
|
|
180
|
+
const fileNodes = this.getFileNodes(files)
|
|
181
|
+
|
|
182
|
+
// An exported function with callers is a breaking-change candidate
|
|
183
|
+
const exportedWithCallers = fileNodes.filter(n => {
|
|
184
|
+
const fn = this.lock.functions[n.id]
|
|
185
|
+
return fn?.isExported && fn.calledBy.length > 0
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
if (exportedWithCallers.length === 0) {
|
|
189
|
+
return this.pass('BREAKING_CHANGE', 'No exported API changes with existing callers detected')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const hasExplicitIntent =
|
|
193
|
+
context?.commitMessage?.toLowerCase().includes('breaking:') ||
|
|
194
|
+
context?.branchName?.toLowerCase().includes('breaking') ||
|
|
195
|
+
context?.commitMessage?.toLowerCase().includes('api change')
|
|
196
|
+
|
|
197
|
+
if (hasExplicitIntent) {
|
|
198
|
+
return this.pass(
|
|
199
|
+
'BREAKING_CHANGE',
|
|
200
|
+
`Exported API changes (${exportedWithCallers.length} functions) with explicit breaking intent`,
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
canProceed: false,
|
|
206
|
+
gate: 'BREAKING_CHANGE',
|
|
207
|
+
reason: `${exportedWithCallers.length} exported function(s) with callers modified without explicit intent marker. Add "BREAKING:" to commit message.`,
|
|
208
|
+
severity: 'BLOCKING',
|
|
209
|
+
bypassable: true,
|
|
210
|
+
bypassCommand: 'git commit -m "BREAKING: <your message>"',
|
|
211
|
+
autoFixable: false,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async checkTestCoverageGate(files: string[]): Promise<SafetyGateResult> {
|
|
216
|
+
if (!this.config.requireTestsForChangedFiles) {
|
|
217
|
+
return this.pass('TEST_COVERAGE', 'Test coverage gate disabled')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const fileNodes = this.getFileNodes(files)
|
|
221
|
+
if (fileNodes.length === 0) return this.pass('TEST_COVERAGE', 'No tracked functions in modified files')
|
|
222
|
+
|
|
223
|
+
const impact = this.analyzer.analyze(fileNodes.map(n => n.id))
|
|
224
|
+
|
|
225
|
+
const highRisk = impact.impacted.filter(id => {
|
|
226
|
+
const fn = this.lock.functions[id]
|
|
227
|
+
return fn && (fn.calledBy.length > 10 || fn.isExported)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const hasTests = files.some(
|
|
231
|
+
f => f.includes('.test.') || f.includes('.spec.') || f.includes('__tests__'),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if (highRisk.length > 0 && !hasTests) {
|
|
235
|
+
return {
|
|
236
|
+
canProceed: false,
|
|
237
|
+
gate: 'TEST_COVERAGE',
|
|
238
|
+
reason: `${highRisk.length} high-risk change(s) without test modifications`,
|
|
239
|
+
severity: 'BLOCKING',
|
|
240
|
+
bypassable: true,
|
|
241
|
+
bypassCommand: 'mikk safety bypass --gate=TEST_COVERAGE',
|
|
242
|
+
autoFixable: true,
|
|
243
|
+
suggestedFix: 'Add tests for changed functions or affected call paths',
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return this.pass(
|
|
248
|
+
'TEST_COVERAGE',
|
|
249
|
+
hasTests ? 'Tests modified alongside changes' : 'No high-risk changes requiring tests',
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async checkDocumentationGate(files: string[]): Promise<SafetyGateResult> {
|
|
254
|
+
if (!this.config.requireDocumentationForApiChanges) {
|
|
255
|
+
return this.pass('DOCUMENTATION', 'Documentation gate disabled')
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const docFiles = ['README.md', 'API.md', 'CHANGELOG.md', 'AGENTS.md']
|
|
259
|
+
const docsUpdated = files.some(f => docFiles.some(d => f.endsWith(d)))
|
|
260
|
+
|
|
261
|
+
const fileNodes = this.getFileNodes(files)
|
|
262
|
+
const hasSignificantApiChanges = fileNodes.some(n => {
|
|
263
|
+
const fn = this.lock.functions[n.id]
|
|
264
|
+
return fn?.isExported && fn.calledBy.length > 5
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
if (hasSignificantApiChanges && !docsUpdated) {
|
|
268
|
+
return {
|
|
269
|
+
canProceed: false,
|
|
270
|
+
gate: 'DOCUMENTATION',
|
|
271
|
+
reason: 'Significant API changes detected without documentation updates',
|
|
272
|
+
severity: 'BLOCKING',
|
|
273
|
+
bypassable: true,
|
|
274
|
+
bypassCommand: 'mikk safety bypass --gate=DOCUMENTATION',
|
|
275
|
+
autoFixable: false,
|
|
276
|
+
suggestedFix: 'Update README.md, API.md, or AGENTS.md with the changes',
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return this.pass(
|
|
281
|
+
'DOCUMENTATION',
|
|
282
|
+
docsUpdated ? 'Documentation updated' : 'No significant API changes requiring docs',
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Helpers ───────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/** Collect graph-node IDs for every tracked function in the given files. */
|
|
289
|
+
private getFileNodes(files: string[]): Array<{ id: string }> {
|
|
290
|
+
const nodes: Array<{ id: string }> = []
|
|
291
|
+
for (const file of files) {
|
|
292
|
+
const norm = file.replace(/\\/g, '/')
|
|
293
|
+
for (const fn of Object.values(this.lock.functions)) {
|
|
294
|
+
if (fn.file === norm || fn.file.endsWith('/' + norm)) {
|
|
295
|
+
nodes.push({ id: fn.id })
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return nodes
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private pass(gate: string, reason: string): SafetyGateResult {
|
|
303
|
+
return { canProceed: true, gate, reason, severity: 'WARNING', bypassable: true, autoFixable: false }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ImpactResult } from '@getmikk/core'
|
|
1
|
+
import type { ImpactResult, MikkLock } from '@getmikk/core'
|
|
2
2
|
import type { DecisionResult, Explanation } from './types.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,74 +7,94 @@ import type { DecisionResult, Explanation } from './types.js'
|
|
|
7
7
|
*/
|
|
8
8
|
export class ExplanationEngine {
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* @param lock Optional lock reference — used to resolve module names accurately.
|
|
11
|
+
* If omitted, module count is estimated from file paths.
|
|
11
12
|
*/
|
|
12
|
-
|
|
13
|
-
const summary = this.getSummary(decision);
|
|
14
|
-
const details = this.getDetails(impact, decision);
|
|
15
|
-
const riskBreakdown = this.getRiskBreakdown(impact);
|
|
13
|
+
constructor(private lock?: MikkLock) {}
|
|
16
14
|
|
|
15
|
+
explain(impact: ImpactResult, decision: DecisionResult): Explanation {
|
|
17
16
|
return {
|
|
18
|
-
summary,
|
|
19
|
-
details,
|
|
20
|
-
riskBreakdown
|
|
21
|
-
}
|
|
17
|
+
summary: this.getSummary(decision),
|
|
18
|
+
details: this.getDetails(impact, decision),
|
|
19
|
+
riskBreakdown: this.getRiskBreakdown(impact),
|
|
20
|
+
}
|
|
22
21
|
}
|
|
23
22
|
|
|
24
23
|
private getSummary(decision: DecisionResult): string {
|
|
25
24
|
switch (decision.status) {
|
|
26
|
-
case 'APPROVED': return 'This change is safe and conforms to project policies.'
|
|
27
|
-
case 'WARNING':
|
|
28
|
-
case 'BLOCKED':
|
|
25
|
+
case 'APPROVED': return 'This change is safe and conforms to project policies.'
|
|
26
|
+
case 'WARNING': return 'Change detected with non-trivial impact — proceed with caution.'
|
|
27
|
+
case 'BLOCKED': return 'MODIFICATION BLOCKED: This change violates safety or architectural policies.'
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
30
|
|
|
32
31
|
private getDetails(impact: ImpactResult, decision: DecisionResult): string[] {
|
|
33
|
-
const details: string[] = []
|
|
32
|
+
const details: string[] = []
|
|
34
33
|
|
|
35
34
|
if (impact.allImpacted.length === 0) {
|
|
36
|
-
details.push('No downstream symbols are affected by this change.')
|
|
35
|
+
details.push('No downstream symbols are affected by this change.')
|
|
37
36
|
} else {
|
|
38
|
-
details.push(`Affects ${impact.allImpacted.length} symbols across ${this.countModules(impact)} modules.`)
|
|
39
|
-
details.push(`Maximum risk score is ${impact.riskScore}/100.`)
|
|
37
|
+
details.push(`Affects ${impact.allImpacted.length} symbols across ${this.countModules(impact)} modules.`)
|
|
38
|
+
details.push(`Maximum risk score is ${impact.riskScore}/100.`)
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
if (impact.classified.critical.length > 0) {
|
|
43
|
-
details.push(`⚠ Critical: affects ${impact.classified.critical.length}
|
|
42
|
+
details.push(`⚠ Critical: affects ${impact.classified.critical.length} symbol(s) in separate modules.`)
|
|
44
43
|
}
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
for (const r of decision.reasons) {
|
|
46
|
+
details.push(`Policy: ${r}`)
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
return details
|
|
49
|
+
return details
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
private getRiskBreakdown(impact: ImpactResult): { symbol: string; reason: string; score: number }[] {
|
|
54
|
-
|
|
55
|
-
return impact.allImpacted
|
|
53
|
+
return [...impact.allImpacted]
|
|
56
54
|
.sort((a, b) => b.riskScore - a.riskScore)
|
|
57
55
|
.slice(0, 3)
|
|
58
56
|
.map(node => ({
|
|
59
57
|
symbol: node.label,
|
|
60
|
-
score:
|
|
61
|
-
reason: this.getRiskReason(node.riskScore)
|
|
62
|
-
}))
|
|
58
|
+
score: node.riskScore,
|
|
59
|
+
reason: this.getRiskReason(node.riskScore),
|
|
60
|
+
}))
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
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'
|
|
64
|
+
if (score >= 90) return 'Direct critical dependency in protected module'
|
|
65
|
+
if (score >= 80) return 'Large downstream reach (potential side effects)'
|
|
66
|
+
if (score >= 60) return 'Cross-module propagation'
|
|
67
|
+
return 'Standard functional dependency'
|
|
70
68
|
}
|
|
71
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Count distinct modules touched by the impact set.
|
|
72
|
+
*
|
|
73
|
+
* Preference order:
|
|
74
|
+
* 1. lock.functions[nodeId].moduleId — accurate
|
|
75
|
+
* 2. heuristic from file path — fallback when lock is absent
|
|
76
|
+
*/
|
|
72
77
|
private countModules(impact: ImpactResult): number {
|
|
73
|
-
const modules = new Set(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
const modules = new Set<string>()
|
|
79
|
+
|
|
80
|
+
for (const node of impact.allImpacted) {
|
|
81
|
+
let moduleId: string | undefined
|
|
82
|
+
|
|
83
|
+
// Try to resolve via lock first
|
|
84
|
+
if (this.lock) {
|
|
85
|
+
moduleId = this.lock.functions[node.nodeId]?.moduleId
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!moduleId) {
|
|
89
|
+
// Fallback: first meaningful path segment after stripping common prefixes
|
|
90
|
+
const parts = node.file.replace(/\\/g, '/').split('/')
|
|
91
|
+
const segIdx = parts.findIndex(p => p !== 'src' && p !== 'packages' && p !== 'apps')
|
|
92
|
+
moduleId = segIdx >= 0 ? parts[segIdx] : (parts[0] ?? 'root')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
modules.add(moduleId)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return modules.size
|
|
79
99
|
}
|
|
80
100
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,16 @@ export { ConflictDetector } from './conflict-detector.js'
|
|
|
3
3
|
export { Suggester } from './suggester.js'
|
|
4
4
|
export { PreflightPipeline } from './preflight.js'
|
|
5
5
|
export { SemanticSearcher } from './semantic-searcher.js'
|
|
6
|
+
export { IntentUnderstanding } from './intent-understanding.js'
|
|
7
|
+
export { AutoCorrectionEngine } from './auto-correction.js'
|
|
8
|
+
export { EnforcedSafetyGates } from './enforced-safety.js'
|
|
9
|
+
export { PreEditValidation } from './pre-edit-validation.js'
|
|
10
|
+
|
|
6
11
|
export type { SemanticMatch } from './semantic-searcher.js'
|
|
12
|
+
export type { IntentContext, IntentAnalysis } from './intent-understanding.js'
|
|
13
|
+
export type { CorrectionIssue, CorrectionResult } from './auto-correction.js'
|
|
14
|
+
export type { SafetyGateResult, SafetyGateConfig } from './enforced-safety.js'
|
|
15
|
+
export type { EditProposal, ValidationResult } from './pre-edit-validation.js'
|
|
16
|
+
|
|
7
17
|
export type { Intent, Conflict, ConflictResult, Suggestion, PreflightResult, AIProviderConfig } from './types.js'
|
|
8
18
|
export { IntentSchema } from './types.js'
|