@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 +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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ImpactResult, MikkLock } 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
|
+
* @param lock Optional lock reference — used to resolve module names accurately.
|
|
11
|
+
* If omitted, module count is estimated from file paths.
|
|
12
|
+
*/
|
|
13
|
+
constructor(private lock?: MikkLock) {}
|
|
14
|
+
|
|
15
|
+
explain(impact: ImpactResult, decision: DecisionResult): Explanation {
|
|
16
|
+
return {
|
|
17
|
+
summary: this.getSummary(decision),
|
|
18
|
+
details: this.getDetails(impact, decision),
|
|
19
|
+
riskBreakdown: this.getRiskBreakdown(impact),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private getSummary(decision: DecisionResult): string {
|
|
24
|
+
switch (decision.status) {
|
|
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.'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private getDetails(impact: ImpactResult, decision: DecisionResult): string[] {
|
|
32
|
+
const details: string[] = []
|
|
33
|
+
|
|
34
|
+
if (impact.allImpacted.length === 0) {
|
|
35
|
+
details.push('No downstream symbols are affected by this change.')
|
|
36
|
+
} else {
|
|
37
|
+
details.push(`Affects ${impact.allImpacted.length} symbols across ${this.countModules(impact)} modules.`)
|
|
38
|
+
details.push(`Maximum risk score is ${impact.riskScore}/100.`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (impact.classified.critical.length > 0) {
|
|
42
|
+
details.push(`⚠ Critical: affects ${impact.classified.critical.length} symbol(s) in separate modules.`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const r of decision.reasons) {
|
|
46
|
+
details.push(`Policy: ${r}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return details
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private getRiskBreakdown(impact: ImpactResult): { symbol: string; reason: string; score: number }[] {
|
|
53
|
+
return [...impact.allImpacted]
|
|
54
|
+
.sort((a, b) => b.riskScore - a.riskScore)
|
|
55
|
+
.slice(0, 3)
|
|
56
|
+
.map(node => ({
|
|
57
|
+
symbol: node.label,
|
|
58
|
+
score: node.riskScore,
|
|
59
|
+
reason: this.getRiskReason(node.riskScore),
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private getRiskReason(score: number): string {
|
|
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'
|
|
68
|
+
}
|
|
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
|
+
*/
|
|
77
|
+
private countModules(impact: ImpactResult): number {
|
|
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
|
|
99
|
+
}
|
|
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'
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { ImpactResult, MikkContract, MikkLock } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IntentUnderstanding - Analyzes if breaking changes are intentional.
|
|
5
|
+
*
|
|
6
|
+
* Understands developer intent through:
|
|
7
|
+
* 1. Explicit intent declarations ("REFACTOR:", "BREAKING:" in commit messages)
|
|
8
|
+
* 2. Change pattern analysis (rename vs bugfix vs new feature)
|
|
9
|
+
* 3. Context clues (branch name, PR description, file patterns)
|
|
10
|
+
*/
|
|
11
|
+
export interface IntentContext {
|
|
12
|
+
commitMessage?: string
|
|
13
|
+
branchName?: string
|
|
14
|
+
prDescription?: string
|
|
15
|
+
author?: string
|
|
16
|
+
filesChanged: string[]
|
|
17
|
+
changeType: 'refactor' | 'feature' | 'bugfix' | 'breaking' | 'unknown'
|
|
18
|
+
confidence: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IntentAnalysis {
|
|
22
|
+
isIntentionalBreakingChange: boolean
|
|
23
|
+
confidence: number
|
|
24
|
+
reasoning: string[]
|
|
25
|
+
suggestedActions: string[]
|
|
26
|
+
riskAcceptance: 'none' | 'low' | 'medium' | 'high'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class IntentUnderstanding {
|
|
30
|
+
constructor(private contract: MikkContract, private lock: MikkLock) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Analyze if breaking changes are intentional based on context.
|
|
34
|
+
*
|
|
35
|
+
* NOTE: `impact` is typed as ImpactResult. ClassifiedImpact nodes expose
|
|
36
|
+
* `nodeId` (not `id` or `functionId`) — all node lookups use that field.
|
|
37
|
+
*/
|
|
38
|
+
analyzeIntent(impact: ImpactResult, context: Partial<IntentContext> = {}): IntentAnalysis {
|
|
39
|
+
const reasoning: string[] = []
|
|
40
|
+
let confidence = 0.5
|
|
41
|
+
let isIntentional = false
|
|
42
|
+
let riskAcceptance: IntentAnalysis['riskAcceptance'] = 'none'
|
|
43
|
+
|
|
44
|
+
// 1. Check explicit markers in commit message
|
|
45
|
+
if (context.commitMessage) {
|
|
46
|
+
const msg = context.commitMessage.toLowerCase()
|
|
47
|
+
const breakingMarkers = ['breaking:', 'breaking change', 'refactor:', 'migration:', 'api change']
|
|
48
|
+
const found = breakingMarkers.find(m => msg.includes(m))
|
|
49
|
+
|
|
50
|
+
if (found) {
|
|
51
|
+
isIntentional = true
|
|
52
|
+
confidence += 0.3
|
|
53
|
+
reasoning.push(`Explicit breaking change marker found: "${found}"`)
|
|
54
|
+
riskAcceptance = 'high'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const safetyMarkers = ['fix:', 'hotfix:', 'patch:', 'bugfix:']
|
|
58
|
+
if (safetyMarkers.some(m => msg.includes(m))) {
|
|
59
|
+
confidence += 0.2
|
|
60
|
+
reasoning.push('Safety fix detected — changes should be minimal and safe')
|
|
61
|
+
if (riskAcceptance === 'none') riskAcceptance = 'low'
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Analyze branch naming patterns
|
|
66
|
+
if (context.branchName) {
|
|
67
|
+
const branch = context.branchName.toLowerCase()
|
|
68
|
+
if (branch.includes('refactor') || branch.includes('breaking') || branch.includes('v2')) {
|
|
69
|
+
isIntentional = true
|
|
70
|
+
confidence += 0.2
|
|
71
|
+
reasoning.push(`Branch name "${context.branchName}" suggests intentional restructuring`)
|
|
72
|
+
if (riskAcceptance === 'none') riskAcceptance = 'medium'
|
|
73
|
+
}
|
|
74
|
+
if (branch.includes('hotfix') || branch.includes('patch')) {
|
|
75
|
+
reasoning.push('Hotfix branch — expect minimal, safe changes')
|
|
76
|
+
if (riskAcceptance === 'none') riskAcceptance = 'low'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 3. Analyze change patterns
|
|
81
|
+
const changePattern = this.analyzeChangePattern(impact, context.filesChanged ?? [])
|
|
82
|
+
if (changePattern.isRename) {
|
|
83
|
+
isIntentional = true
|
|
84
|
+
confidence += 0.15
|
|
85
|
+
reasoning.push('Pattern suggests systematic rename/refactor')
|
|
86
|
+
}
|
|
87
|
+
if (changePattern.isSignatureChange) {
|
|
88
|
+
reasoning.push('Function signature changes detected')
|
|
89
|
+
if (!isIntentional) confidence -= 0.1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Check against contract migration policies
|
|
93
|
+
const migrationPhase = this.detectMigrationPhase(impact)
|
|
94
|
+
if (migrationPhase === 'planned') {
|
|
95
|
+
isIntentional = true
|
|
96
|
+
confidence += 0.25
|
|
97
|
+
reasoning.push('Changes align with planned migration in contract')
|
|
98
|
+
riskAcceptance = 'high'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 5. Historical analysis
|
|
102
|
+
if (context.author) {
|
|
103
|
+
const pattern = this.analyzeAuthorPattern(context.author)
|
|
104
|
+
if (pattern === 'careful') {
|
|
105
|
+
confidence += 0.1
|
|
106
|
+
reasoning.push(`${context.author} has history of careful, safe changes`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
confidence = Math.max(0, Math.min(1, confidence))
|
|
111
|
+
|
|
112
|
+
const suggestedActions = this.generateSuggestions(impact, isIntentional, confidence, riskAcceptance)
|
|
113
|
+
|
|
114
|
+
return { isIntentionalBreakingChange: isIntentional, confidence, reasoning, suggestedActions, riskAcceptance }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Private helpers ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
private detectMigrationPhase(impact: ImpactResult): 'none' | 'planned' {
|
|
120
|
+
const decisions = this.contract.declared?.decisions ?? []
|
|
121
|
+
|
|
122
|
+
for (const decision of decisions) {
|
|
123
|
+
const text = `${decision.title ?? ''} ${decision.reason ?? ''}`.toLowerCase()
|
|
124
|
+
if (!text.includes('migration') && !text.includes('deprecat')) continue
|
|
125
|
+
|
|
126
|
+
// Use `nodeId` — the correct field on ClassifiedImpact
|
|
127
|
+
const affectedModules = new Set<string>()
|
|
128
|
+
for (const node of impact.allImpacted) {
|
|
129
|
+
const fn = this.lock.functions[node.nodeId]
|
|
130
|
+
if (fn?.moduleId) affectedModules.add(fn.moduleId)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const decisionModules = this.extractModulesFromDecision(`${decision.title ?? ''} ${decision.reason ?? ''}`)
|
|
134
|
+
if ([...affectedModules].some(m => decisionModules.includes(m))) return 'planned'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return 'none'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private extractModulesFromDecision(decision: string): string[] {
|
|
141
|
+
const modules = this.contract.declared?.modules ?? []
|
|
142
|
+
const mentioned: string[] = []
|
|
143
|
+
for (const mod of modules) {
|
|
144
|
+
if (
|
|
145
|
+
decision.toLowerCase().includes(mod.id.toLowerCase()) ||
|
|
146
|
+
decision.toLowerCase().includes(mod.name.toLowerCase())
|
|
147
|
+
) {
|
|
148
|
+
mentioned.push(mod.id)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return mentioned
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private analyzeChangePattern(
|
|
155
|
+
impact: ImpactResult,
|
|
156
|
+
filesChanged: string[],
|
|
157
|
+
): { isRename: boolean; isSignatureChange: boolean; isNewFeature: boolean } {
|
|
158
|
+
const result = { isRename: false, isSignatureChange: false, isNewFeature: false }
|
|
159
|
+
|
|
160
|
+
const lockFilePaths = new Set(Object.keys(this.lock.files))
|
|
161
|
+
const norm = (f: string) => f.replace(/\\/g, '/')
|
|
162
|
+
const deletedFiles = filesChanged.filter(f => !lockFilePaths.has(norm(f)))
|
|
163
|
+
const addedFiles = filesChanged.filter(f => lockFilePaths.has(norm(f)))
|
|
164
|
+
|
|
165
|
+
if (deletedFiles.length > 0 && addedFiles.length > 0) {
|
|
166
|
+
for (const deleted of deletedFiles) {
|
|
167
|
+
const base = deleted.split('/').pop()?.split('.')[0]
|
|
168
|
+
if (base && addedFiles.some(a => norm(a).includes(base))) {
|
|
169
|
+
result.isRename = true
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Use `nodeId` — correct field on ClassifiedImpact
|
|
176
|
+
for (const node of impact.allImpacted) {
|
|
177
|
+
const fn = this.lock.functions[node.nodeId]
|
|
178
|
+
if (fn?.params && fn.params.length > 0) {
|
|
179
|
+
result.isSignatureChange = true
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const newExports = filesChanged.flatMap(file => {
|
|
185
|
+
const n = norm(file)
|
|
186
|
+
return Object.values(this.lock.functions).filter(
|
|
187
|
+
f => (f.file === n || f.file.endsWith('/' + n)) && f.isExported && f.calledBy.length === 0,
|
|
188
|
+
)
|
|
189
|
+
})
|
|
190
|
+
if (newExports.length > 3) result.isNewFeature = true
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private analyzeAuthorPattern(_author: string): 'careful' | 'normal' | 'aggressive' {
|
|
196
|
+
return 'normal'
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private generateSuggestions(
|
|
200
|
+
impact: ImpactResult,
|
|
201
|
+
isIntentional: boolean,
|
|
202
|
+
confidence: number,
|
|
203
|
+
riskAcceptance: IntentAnalysis['riskAcceptance'],
|
|
204
|
+
): string[] {
|
|
205
|
+
const out: string[] = []
|
|
206
|
+
|
|
207
|
+
if (isIntentional && confidence > 0.7) {
|
|
208
|
+
out.push('✓ Breaking change appears intentional — proceeding with caution')
|
|
209
|
+
if (impact.impacted.length > 10) out.push('Consider breaking this into smaller PRs for easier review')
|
|
210
|
+
out.push('Ensure tests exist for all impacted call paths')
|
|
211
|
+
out.push('Update relevant documentation for API changes')
|
|
212
|
+
if (riskAcceptance === 'high') out.push('Schedule deployment during low-traffic period')
|
|
213
|
+
} else if (impact.riskScore > 80) {
|
|
214
|
+
out.push('⚠ HIGH RISK: Breaking changes without explicit intent detected')
|
|
215
|
+
out.push('Add "BREAKING:" prefix to commit message if intentional')
|
|
216
|
+
out.push('Run full test suite before committing')
|
|
217
|
+
out.push('Consider creating a migration guide for consumers')
|
|
218
|
+
} else if (impact.impacted.length > 5) {
|
|
219
|
+
out.push('Review impacted functions to ensure changes are necessary')
|
|
220
|
+
out.push('Check if any changes can be made backward-compatible')
|
|
221
|
+
} else {
|
|
222
|
+
out.push('Changes appear low-risk — standard review process recommended')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return out
|
|
226
|
+
}
|
|
227
|
+
}
|