@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.
@@ -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
+ }