@getmikk/ai-context 1.7.1 → 1.9.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 +59 -288
- package/package.json +3 -3
- package/src/claude-md-generator.ts +69 -73
- package/src/context-builder.ts +196 -22
- package/src/providers.ts +42 -1
- package/src/token-counter.ts +224 -0
- package/src/types.ts +15 -1
- package/tests/claude-md.test.ts +88 -8
- package/tests/context-builder.test.ts +159 -0
package/src/providers.ts
CHANGED
|
@@ -28,6 +28,16 @@ export class ClaudeProvider implements ContextProvider {
|
|
|
28
28
|
lines.push(` <seeds_found>${context.meta?.seedCount ?? 0}</seeds_found>`)
|
|
29
29
|
lines.push(` <functions_selected>${context.meta?.selectedFunctions ?? 0} of ${context.meta?.totalFunctionsConsidered ?? 0}</functions_selected>`)
|
|
30
30
|
lines.push(` <estimated_tokens>${context.meta?.estimatedTokens ?? 0}</estimated_tokens>`)
|
|
31
|
+
if (context.meta?.reasons && context.meta.reasons.length > 0) {
|
|
32
|
+
for (const reason of context.meta.reasons) {
|
|
33
|
+
lines.push(` <reason>${esc(reason)}</reason>`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (context.meta?.suggestions && context.meta.suggestions.length > 0) {
|
|
37
|
+
for (const s of context.meta.suggestions) {
|
|
38
|
+
lines.push(` <suggestion>${esc(s)}</suggestion>`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
31
41
|
lines.push('</context_meta>')
|
|
32
42
|
lines.push('')
|
|
33
43
|
|
|
@@ -79,6 +89,23 @@ export class ClaudeProvider implements ContextProvider {
|
|
|
79
89
|
lines.push('')
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
if (context.modules.length === 0 && context.meta?.reasons?.length) {
|
|
93
|
+
lines.push('<no_match_reason>')
|
|
94
|
+
for (const reason of context.meta.reasons) {
|
|
95
|
+
lines.push(` <item>${esc(reason)}</item>`)
|
|
96
|
+
}
|
|
97
|
+
lines.push('</no_match_reason>')
|
|
98
|
+
lines.push('')
|
|
99
|
+
if (context.meta.suggestions && context.meta.suggestions.length > 0) {
|
|
100
|
+
lines.push('<did_you_mean>')
|
|
101
|
+
for (const suggestion of context.meta.suggestions) {
|
|
102
|
+
lines.push(` <item>${esc(suggestion)}</item>`)
|
|
103
|
+
}
|
|
104
|
+
lines.push('</did_you_mean>')
|
|
105
|
+
lines.push('')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
// ── Context files (schemas, data models, config) ───────────────────
|
|
83
110
|
if (context.contextFiles && context.contextFiles.length > 0) {
|
|
84
111
|
lines.push('<context_files>')
|
|
@@ -160,6 +187,20 @@ export class CompactProvider implements ContextProvider {
|
|
|
160
187
|
`Task keywords: ${context.meta?.keywords?.join(', ') ?? ''}`,
|
|
161
188
|
'',
|
|
162
189
|
]
|
|
190
|
+
if (context.modules.length === 0 && context.meta?.reasons?.length) {
|
|
191
|
+
lines.push('No exact context selected:')
|
|
192
|
+
for (const reason of context.meta.reasons) {
|
|
193
|
+
lines.push(`- ${reason}`)
|
|
194
|
+
}
|
|
195
|
+
if (context.meta.suggestions && context.meta.suggestions.length > 0) {
|
|
196
|
+
lines.push('')
|
|
197
|
+
lines.push('Did you mean:')
|
|
198
|
+
for (const suggestion of context.meta.suggestions) {
|
|
199
|
+
lines.push(`- ${suggestion}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
lines.push('')
|
|
203
|
+
}
|
|
163
204
|
for (const mod of context.modules) {
|
|
164
205
|
lines.push(`## ${mod.name}`)
|
|
165
206
|
for (const fn of mod.functions) {
|
|
@@ -218,4 +259,4 @@ function esc(s: string): string {
|
|
|
218
259
|
.replace(/</g, '<')
|
|
219
260
|
.replace(/>/g, '>')
|
|
220
261
|
.replace(/"/g, '"')
|
|
221
|
-
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Improved Token Counter
|
|
3
|
+
*
|
|
4
|
+
* Provides more accurate token counting than the simple length/4 approximation.
|
|
5
|
+
* Uses a GPT-4 compatible tokenizer approximation for better budget management.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Character-based token approximation (more accurate than simple division)
|
|
9
|
+
const CHARS_PER_TOKEN = 3.8 // Average for GPT-4 tokenizer
|
|
10
|
+
const MIN_CHARS_PER_TOKEN = 2.0 // For dense code
|
|
11
|
+
const MAX_CHARS_PER_TOKEN = 6.0 // For sparse text
|
|
12
|
+
|
|
13
|
+
// Special token patterns that affect tokenization
|
|
14
|
+
const TOKEN_PATTERNS = {
|
|
15
|
+
// Common programming patterns that typically tokenize as single tokens
|
|
16
|
+
SINGLE_TOKEN_PATTERNS: [
|
|
17
|
+
/\b(if|else|for|while|function|return|const|let|var|class|import|export)\b/g,
|
|
18
|
+
/\b(true|false|null|undefined)\b/g,
|
|
19
|
+
/\b(async|await|try|catch|throw|new|this)\b/g,
|
|
20
|
+
// Operators and punctuation
|
|
21
|
+
/[+\-*\/=<>!&|]+/g,
|
|
22
|
+
/[{}()\[\];,\.]/g,
|
|
23
|
+
// Common function names
|
|
24
|
+
/\b(console\.log|console\.error|console\.warn)\b/g,
|
|
25
|
+
/\b(Math\.(floor|ceil|round|max|min))\b/g,
|
|
26
|
+
],
|
|
27
|
+
|
|
28
|
+
// Patterns that typically increase token count
|
|
29
|
+
HIGH_TOKEN_PATTERNS: [
|
|
30
|
+
// String literals (each character ~0.25 tokens)
|
|
31
|
+
/'[^']*'/g,
|
|
32
|
+
/"[^"]*"/g,
|
|
33
|
+
/`[^`]*`/g,
|
|
34
|
+
// Numbers (digits ~0.5 tokens each)
|
|
35
|
+
/\b\d+\.?\d*\b/g,
|
|
36
|
+
// Long identifiers (split into multiple tokens)
|
|
37
|
+
/\b[a-z][a-zA-Z0-9]{8,}\b/g,
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Count tokens with improved accuracy using position-based pattern matching
|
|
43
|
+
*/
|
|
44
|
+
export function countTokens(text: string): number {
|
|
45
|
+
if (!text || text.length === 0) return 0
|
|
46
|
+
|
|
47
|
+
let tokenCount = 0
|
|
48
|
+
const processedPositions = new Set<number>() // Track positions to avoid double-counting
|
|
49
|
+
|
|
50
|
+
// Count single-token patterns with position tracking
|
|
51
|
+
for (const pattern of TOKEN_PATTERNS.SINGLE_TOKEN_PATTERNS) {
|
|
52
|
+
for (const match of text.matchAll(pattern)) {
|
|
53
|
+
const start = match.index!
|
|
54
|
+
const end = start + match[0].length
|
|
55
|
+
|
|
56
|
+
// Check if this range overlaps with already processed ranges
|
|
57
|
+
let overlaps = false
|
|
58
|
+
for (let i = start; i < end; i++) {
|
|
59
|
+
if (processedPositions.has(i)) {
|
|
60
|
+
overlaps = true
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!overlaps) {
|
|
66
|
+
tokenCount += 1
|
|
67
|
+
// Mark positions as processed
|
|
68
|
+
for (let i = start; i < end; i++) {
|
|
69
|
+
processedPositions.add(i)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Count high-token patterns (strings, numbers, long identifiers)
|
|
76
|
+
for (const pattern of TOKEN_PATTERNS.HIGH_TOKEN_PATTERNS) {
|
|
77
|
+
for (const match of text.matchAll(pattern)) {
|
|
78
|
+
const start = match.index!
|
|
79
|
+
const end = start + match[0].length
|
|
80
|
+
|
|
81
|
+
// Check for overlaps
|
|
82
|
+
let overlaps = false
|
|
83
|
+
for (let i = start; i < end; i++) {
|
|
84
|
+
if (processedPositions.has(i)) {
|
|
85
|
+
overlaps = true
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!overlaps) {
|
|
91
|
+
let tokensToAdd = 0
|
|
92
|
+
if (match[0].startsWith('\'') || match[0].startsWith('"') || match[0].startsWith('`')) {
|
|
93
|
+
// String literal: roughly 1 token per 4 characters
|
|
94
|
+
tokensToAdd = Math.ceil(match[0].length / 4)
|
|
95
|
+
} else if (/^\d/.test(match[0])) {
|
|
96
|
+
// Number: roughly 1 token per 2 digits
|
|
97
|
+
tokensToAdd = Math.ceil(match[0].length / 2)
|
|
98
|
+
} else {
|
|
99
|
+
// Long identifier: roughly 1 token per 6 characters
|
|
100
|
+
tokensToAdd = Math.ceil(match[0].length / 6)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
tokenCount += tokensToAdd
|
|
104
|
+
// Mark positions as processed
|
|
105
|
+
for (let i = start; i < end; i++) {
|
|
106
|
+
processedPositions.add(i)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Count remaining characters (general text)
|
|
113
|
+
const remainingText = Array.from(text.split(''))
|
|
114
|
+
.map((char, index) => processedPositions.has(index) ? '' : char)
|
|
115
|
+
.join('')
|
|
116
|
+
|
|
117
|
+
if (remainingText.length > 0) {
|
|
118
|
+
// Use variable rate based on character density
|
|
119
|
+
const avgWordLength = remainingText.split(/\s+/).reduce((sum, word) => sum + word.length, 0) / Math.max(remainingText.split(/\s+/).length, 1)
|
|
120
|
+
|
|
121
|
+
let charsPerToken = CHARS_PER_TOKEN
|
|
122
|
+
if (avgWordLength < 4) {
|
|
123
|
+
charsPerToken = MIN_CHARS_PER_TOKEN // Dense code
|
|
124
|
+
} else if (avgWordLength > 8) {
|
|
125
|
+
charsPerToken = MAX_CHARS_PER_TOKEN // Sparse text
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
tokenCount += Math.ceil(remainingText.length / charsPerToken)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Apply bounds checking for sanity
|
|
132
|
+
const minEstimate = Math.ceil(text.length / MAX_CHARS_PER_TOKEN)
|
|
133
|
+
const maxEstimate = Math.ceil(text.length / MIN_CHARS_PER_TOKEN)
|
|
134
|
+
|
|
135
|
+
return Math.max(minEstimate, Math.min(maxEstimate, tokenCount))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fast token count for quick estimates (still more accurate than length/4)
|
|
140
|
+
*/
|
|
141
|
+
export function countTokensFast(text: string): number {
|
|
142
|
+
if (!text || text.length === 0) return 0
|
|
143
|
+
|
|
144
|
+
// Quick heuristic based on character patterns
|
|
145
|
+
const codeDensity = (text.match(/[a-zA-Z0-9]/g) || []).length / text.length
|
|
146
|
+
const stringRatio = (text.match(/['"`]/g) || []).length / text.length
|
|
147
|
+
|
|
148
|
+
// Adjust chars per token based on content type
|
|
149
|
+
let charsPerToken = CHARS_PER_TOKEN
|
|
150
|
+
if (codeDensity > 0.7) {
|
|
151
|
+
charsPerToken = 3.2 // Dense code
|
|
152
|
+
} else if (stringRatio > 0.2) {
|
|
153
|
+
charsPerToken = 4.5 // String-heavy
|
|
154
|
+
} else if (codeDensity < 0.3) {
|
|
155
|
+
charsPerToken = 5.0 // Sparse text/comments
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return Math.ceil(text.length / charsPerToken)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Estimate tokens for a file with content type awareness
|
|
163
|
+
*/
|
|
164
|
+
export function estimateFileTokens(content: string, filePath: string): number {
|
|
165
|
+
const extension = filePath.split('.').pop()?.toLowerCase()
|
|
166
|
+
|
|
167
|
+
// Adjust counting based on file type
|
|
168
|
+
switch (extension) {
|
|
169
|
+
case 'json':
|
|
170
|
+
// JSON is token-heavy due to strings and structure
|
|
171
|
+
return countTokens(content) * 1.1
|
|
172
|
+
case 'md':
|
|
173
|
+
// Markdown has more natural language
|
|
174
|
+
return countTokens(content) * 0.9
|
|
175
|
+
case 'ts':
|
|
176
|
+
case 'tsx':
|
|
177
|
+
case 'js':
|
|
178
|
+
case 'jsx':
|
|
179
|
+
// Code files benefit from pattern recognition
|
|
180
|
+
return countTokens(content)
|
|
181
|
+
default:
|
|
182
|
+
// Use standard counting for unknown types
|
|
183
|
+
return countTokens(content)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Token budget manager with overflow protection
|
|
189
|
+
*/
|
|
190
|
+
export class TokenBudget {
|
|
191
|
+
constructor(private maxTokens: number, private overflowAllowance: number = 0.1) {}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Check if content fits within budget
|
|
195
|
+
*/
|
|
196
|
+
fits(content: string): boolean {
|
|
197
|
+
const tokens = countTokens(content)
|
|
198
|
+
return tokens <= this.maxTokens * (1 + this.overflowAllowance)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get remaining token count
|
|
203
|
+
*/
|
|
204
|
+
remaining(usedTokens: number): number {
|
|
205
|
+
return Math.max(0, this.maxTokens - usedTokens)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Truncate content to fit within budget
|
|
210
|
+
*/
|
|
211
|
+
truncate(content: string, usedTokens: number = 0): string {
|
|
212
|
+
const available = this.remaining(usedTokens)
|
|
213
|
+
if (available <= 0) return ''
|
|
214
|
+
|
|
215
|
+
const estimatedTokens = countTokens(content)
|
|
216
|
+
if (estimatedTokens <= available) return content
|
|
217
|
+
|
|
218
|
+
// Rough truncation based on character ratio
|
|
219
|
+
const ratio = available / estimatedTokens
|
|
220
|
+
const truncateAt = Math.floor(content.length * ratio * 0.9) // 10% buffer
|
|
221
|
+
|
|
222
|
+
return content.substring(0, truncateAt) + '\n... [truncated due to token budget]'
|
|
223
|
+
}
|
|
224
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -24,6 +24,8 @@ export interface AIContext {
|
|
|
24
24
|
selectedFunctions: number
|
|
25
25
|
estimatedTokens: number
|
|
26
26
|
keywords: string[]
|
|
27
|
+
reasons?: string[]
|
|
28
|
+
suggestions?: string[]
|
|
27
29
|
}
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -72,6 +74,18 @@ export interface ContextQuery {
|
|
|
72
74
|
includeCallGraph?: boolean
|
|
73
75
|
/** Include function bodies for top-scored functions (default true) */
|
|
74
76
|
includeBodies?: boolean
|
|
77
|
+
/** Relevance mode: balanced (default) or strict (high-precision filtering) */
|
|
78
|
+
relevanceMode?: 'balanced' | 'strict'
|
|
79
|
+
/** Additional required terms (comma-separated in CLI) that must be respected */
|
|
80
|
+
requiredKeywords?: string[]
|
|
81
|
+
/** In strict mode, require all extracted/required keywords to match */
|
|
82
|
+
requireAllKeywords?: boolean
|
|
83
|
+
/** Minimum number of matched keywords required in strict mode (default 1) */
|
|
84
|
+
minKeywordMatches?: number
|
|
85
|
+
/** Hard gate in strict mode: final output keeps only strict keyword matches */
|
|
86
|
+
exactOnly?: boolean
|
|
87
|
+
/** In strict mode, return empty context if no exact matches are found */
|
|
88
|
+
failFast?: boolean
|
|
75
89
|
/** Absolute filesystem path to the project root (needed for body reading) */
|
|
76
90
|
projectRoot?: string
|
|
77
91
|
}
|
|
@@ -81,4 +95,4 @@ export interface ContextProvider {
|
|
|
81
95
|
name: string
|
|
82
96
|
formatContext(context: AIContext): string
|
|
83
97
|
maxTokens: number
|
|
84
|
-
}
|
|
98
|
+
}
|
package/tests/claude-md.test.ts
CHANGED
|
@@ -64,8 +64,8 @@ describe('ClaudeMdGenerator', () => {
|
|
|
64
64
|
test('generates valid markdown', () => {
|
|
65
65
|
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
66
66
|
const md = gen.generate()
|
|
67
|
-
expect(md).toContain('
|
|
68
|
-
expect(md).toContain('
|
|
67
|
+
expect(md).toContain('<name>TestProject</name>')
|
|
68
|
+
expect(md).toContain('<repository_context>')
|
|
69
69
|
})
|
|
70
70
|
|
|
71
71
|
test('includes project description', () => {
|
|
@@ -77,8 +77,8 @@ describe('ClaudeMdGenerator', () => {
|
|
|
77
77
|
test('includes module sections', () => {
|
|
78
78
|
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
79
79
|
const md = gen.generate()
|
|
80
|
-
expect(md).toContain('
|
|
81
|
-
expect(md).toContain('
|
|
80
|
+
expect(md).toContain('<module id="auth">')
|
|
81
|
+
expect(md).toContain('<module id="api">')
|
|
82
82
|
})
|
|
83
83
|
|
|
84
84
|
test('includes function names', () => {
|
|
@@ -105,7 +105,7 @@ describe('ClaudeMdGenerator', () => {
|
|
|
105
105
|
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
106
106
|
const md = gen.generate()
|
|
107
107
|
// API depends on Auth (handleLogin calls verifyToken)
|
|
108
|
-
expect(md).toContain('
|
|
108
|
+
expect(md).toContain('<depends_on>Authentication</depends_on>')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
111
|
test('respects token budget', () => {
|
|
@@ -119,8 +119,8 @@ describe('ClaudeMdGenerator', () => {
|
|
|
119
119
|
test('includes stats', () => {
|
|
120
120
|
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
121
121
|
const md = gen.generate()
|
|
122
|
-
expect(md).toContain('3
|
|
123
|
-
expect(md).toContain('2
|
|
122
|
+
expect(md).toContain('<functions>3</functions>')
|
|
123
|
+
expect(md).toContain('<modules>2</modules>')
|
|
124
124
|
})
|
|
125
125
|
|
|
126
126
|
test('shows purpose when available', () => {
|
|
@@ -132,6 +132,86 @@ describe('ClaudeMdGenerator', () => {
|
|
|
132
132
|
test('shows calledBy count for key functions', () => {
|
|
133
133
|
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
134
134
|
const md = gen.generate()
|
|
135
|
-
expect(md).toContain('
|
|
135
|
+
expect(md).toContain('callers="1"')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('Edge Cases and Fault Tolerance', () => {
|
|
139
|
+
test('handles completely empty lock and contract without throwing', () => {
|
|
140
|
+
const emptyContract: MikkContract = {
|
|
141
|
+
version: '1',
|
|
142
|
+
project: { name: 'Empty', language: 'TS', description: '', entryPoints: [] },
|
|
143
|
+
declared: { modules: [], constraints: [], decisions: [] },
|
|
144
|
+
overwrite: { mode: 'never', requireConfirmation: false }
|
|
145
|
+
}
|
|
146
|
+
const emptyLock: MikkLock = {
|
|
147
|
+
version: '1', generatedAt: new Date().toISOString(), generatorVersion: '1', projectRoot: '', syncState: { status: 'clean', lastSyncAt: '', lockHash: '', contractHash: '' },
|
|
148
|
+
files: {}, functions: {}, classes: {}, modules: {}, graph: { nodes: 0, edges: 0, rootHash: '' }
|
|
149
|
+
}
|
|
150
|
+
const gen = new ClaudeMdGenerator(emptyContract, emptyLock)
|
|
151
|
+
const md = gen.generate()
|
|
152
|
+
expect(md).toContain('<name>Empty</name>')
|
|
153
|
+
expect(md).toContain('<modules>0</modules>')
|
|
154
|
+
expect(md).toContain('<functions>0</functions>')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('handles missing optional fields gracefully', () => {
|
|
158
|
+
const partialContract: MikkContract = {
|
|
159
|
+
version: '1',
|
|
160
|
+
project: { name: 'Partial', language: 'TS', description: '', entryPoints: [] },
|
|
161
|
+
declared: { modules: [{ id: 'core', name: 'Core', description: '', paths: [] }], constraints: [], decisions: [] },
|
|
162
|
+
overwrite: { mode: 'never', requireConfirmation: false }
|
|
163
|
+
}
|
|
164
|
+
const partialLock: MikkLock = {
|
|
165
|
+
version: '1', generatedAt: new Date().toISOString(), generatorVersion: '1', projectRoot: '', syncState: { status: 'clean', lastSyncAt: '', lockHash: '', contractHash: '' },
|
|
166
|
+
files: {},
|
|
167
|
+
functions: {
|
|
168
|
+
'f1': { id: 'f1', name: 'func', file: 'a.ts', startLine: 1, endLine: 2, hash: 'h', calls: [], calledBy: [], moduleId: 'core' }
|
|
169
|
+
},
|
|
170
|
+
classes: {},
|
|
171
|
+
modules: {
|
|
172
|
+
'core': { id: 'core', files: [], hash: 'h', fragmentPath: 'p' }
|
|
173
|
+
},
|
|
174
|
+
graph: { nodes: 0, edges: 0, rootHash: '' }
|
|
175
|
+
}
|
|
176
|
+
const gen = new ClaudeMdGenerator(partialContract, partialLock)
|
|
177
|
+
const md = gen.generate()
|
|
178
|
+
expect(md).toContain('<name>Core</name>')
|
|
179
|
+
expect(md).toContain('func')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('handles circular dependencies between functions gracefully', () => {
|
|
183
|
+
const circLock: MikkLock = {
|
|
184
|
+
...mockLock,
|
|
185
|
+
functions: {
|
|
186
|
+
'fn:a': { id: 'fn:a', name: 'A', file: 'a.ts', startLine: 1, endLine: 2, hash: 'h', calls: ['fn:b'], calledBy: ['fn:b'], moduleId: 'auth' },
|
|
187
|
+
'fn:b': { id: 'fn:b', name: 'B', file: 'b.ts', startLine: 1, endLine: 2, hash: 'h', calls: ['fn:a'], calledBy: ['fn:a'], moduleId: 'auth' },
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const gen = new ClaudeMdGenerator(mockContract, circLock)
|
|
191
|
+
const md = gen.generate()
|
|
192
|
+
expect(md).toContain('A')
|
|
193
|
+
expect(md).toContain('B')
|
|
194
|
+
expect(md.length).toBeLessThan(10000)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('truncates extremely strict token budgets without losing core layout', () => {
|
|
198
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock, 50)
|
|
199
|
+
const md = gen.generate()
|
|
200
|
+
expect(md).toContain('<repository_context>')
|
|
201
|
+
expect(md).not.toContain('Use JWT')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('handles foreign or orphaned modules robustly', () => {
|
|
205
|
+
const orphanedLock: MikkLock = {
|
|
206
|
+
...mockLock,
|
|
207
|
+
functions: {
|
|
208
|
+
'fn:orphan': { id: 'fn:orphan', name: 'OrphanFn', file: 'o.ts', startLine: 1, endLine: 2, hash: 'h', calls: [], calledBy: [], moduleId: 'unknown-module' }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const gen = new ClaudeMdGenerator(mockContract, orphanedLock)
|
|
212
|
+
const md = gen.generate()
|
|
213
|
+
// Unknown module functions are typically skipped entirely. The generator should not crash.
|
|
214
|
+
expect(md).not.toContain('OrphanFn')
|
|
215
|
+
})
|
|
136
216
|
})
|
|
137
217
|
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { ContextBuilder } from '../src/context-builder.js'
|
|
3
|
+
import type { ContextQuery } from '../src/types.js'
|
|
4
|
+
|
|
5
|
+
function makeFixture() {
|
|
6
|
+
const contract = {
|
|
7
|
+
project: {
|
|
8
|
+
name: 'mikk',
|
|
9
|
+
language: 'typescript',
|
|
10
|
+
description: 'fixture',
|
|
11
|
+
},
|
|
12
|
+
declared: {
|
|
13
|
+
modules: [
|
|
14
|
+
{ id: 'core-parser', name: 'Core Parser', description: '', paths: [], entryFunctions: [] },
|
|
15
|
+
{ id: 'ui', name: 'UI', description: '', paths: [], entryFunctions: [] },
|
|
16
|
+
],
|
|
17
|
+
constraints: [],
|
|
18
|
+
decisions: [],
|
|
19
|
+
},
|
|
20
|
+
} as any
|
|
21
|
+
|
|
22
|
+
const fnResolver = {
|
|
23
|
+
id: 'fn:parser:resolver',
|
|
24
|
+
name: 'resolveImports',
|
|
25
|
+
file: 'packages/core/src/parser/ts-resolver.ts',
|
|
26
|
+
moduleId: 'core-parser',
|
|
27
|
+
startLine: 1,
|
|
28
|
+
endLine: 10,
|
|
29
|
+
params: [],
|
|
30
|
+
returnType: 'void',
|
|
31
|
+
isAsync: false,
|
|
32
|
+
isExported: true,
|
|
33
|
+
purpose: 'resolve ts imports',
|
|
34
|
+
calls: ['fn:parser:helper'],
|
|
35
|
+
calledBy: [],
|
|
36
|
+
edgeCasesHandled: [],
|
|
37
|
+
errorHandling: [],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fnHelper = {
|
|
41
|
+
id: 'fn:parser:helper',
|
|
42
|
+
name: 'normalizeTsPath',
|
|
43
|
+
file: 'packages/core/src/parser/path.ts',
|
|
44
|
+
moduleId: 'core-parser',
|
|
45
|
+
startLine: 1,
|
|
46
|
+
endLine: 8,
|
|
47
|
+
params: [],
|
|
48
|
+
returnType: 'string',
|
|
49
|
+
isAsync: false,
|
|
50
|
+
isExported: false,
|
|
51
|
+
purpose: 'normalize ts path',
|
|
52
|
+
calls: [],
|
|
53
|
+
calledBy: ['fn:parser:resolver'],
|
|
54
|
+
edgeCasesHandled: [],
|
|
55
|
+
errorHandling: [],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const fnUnrelated = {
|
|
59
|
+
id: 'fn:ui:render',
|
|
60
|
+
name: 'renderHeader',
|
|
61
|
+
file: 'apps/web/components/header.tsx',
|
|
62
|
+
moduleId: 'ui',
|
|
63
|
+
startLine: 1,
|
|
64
|
+
endLine: 8,
|
|
65
|
+
params: [],
|
|
66
|
+
returnType: 'void',
|
|
67
|
+
isAsync: false,
|
|
68
|
+
isExported: true,
|
|
69
|
+
purpose: 'render ui header',
|
|
70
|
+
calls: [],
|
|
71
|
+
calledBy: [],
|
|
72
|
+
edgeCasesHandled: [],
|
|
73
|
+
errorHandling: [],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const lock = {
|
|
77
|
+
functions: {
|
|
78
|
+
[fnResolver.id]: fnResolver,
|
|
79
|
+
[fnHelper.id]: fnHelper,
|
|
80
|
+
[fnUnrelated.id]: fnUnrelated,
|
|
81
|
+
},
|
|
82
|
+
files: {
|
|
83
|
+
[fnResolver.file]: { path: fnResolver.file, moduleId: fnResolver.moduleId, imports: [] },
|
|
84
|
+
[fnHelper.file]: { path: fnHelper.file, moduleId: fnHelper.moduleId, imports: [] },
|
|
85
|
+
[fnUnrelated.file]: { path: fnUnrelated.file, moduleId: fnUnrelated.moduleId, imports: [] },
|
|
86
|
+
},
|
|
87
|
+
routes: [],
|
|
88
|
+
contextFiles: [],
|
|
89
|
+
} as any
|
|
90
|
+
|
|
91
|
+
return { contract, lock }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function namesFrom(query: ContextQuery): string[] {
|
|
95
|
+
const { contract, lock } = makeFixture()
|
|
96
|
+
const builder = new ContextBuilder(contract, lock)
|
|
97
|
+
const ctx = builder.build(query)
|
|
98
|
+
return ctx.modules.flatMap(m => m.functions.map(f => f.name))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('ContextBuilder strict relevance mode', () => {
|
|
102
|
+
test('strict mode filters unrelated entry-point noise', () => {
|
|
103
|
+
const balanced = namesFrom({
|
|
104
|
+
task: 'fix ts resolver imports',
|
|
105
|
+
tokenBudget: 1200,
|
|
106
|
+
includeBodies: false,
|
|
107
|
+
includeCallGraph: false,
|
|
108
|
+
relevanceMode: 'balanced',
|
|
109
|
+
})
|
|
110
|
+
const strict = namesFrom({
|
|
111
|
+
task: 'fix ts resolver imports',
|
|
112
|
+
tokenBudget: 1200,
|
|
113
|
+
includeBodies: false,
|
|
114
|
+
includeCallGraph: false,
|
|
115
|
+
relevanceMode: 'strict',
|
|
116
|
+
minKeywordMatches: 1,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(balanced).toContain('renderHeader')
|
|
120
|
+
expect(strict).not.toContain('renderHeader')
|
|
121
|
+
expect(strict).toContain('resolveImports')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('requiredKeywords enforces exact focus in strict mode', () => {
|
|
125
|
+
const strict = namesFrom({
|
|
126
|
+
task: 'resolver imports',
|
|
127
|
+
tokenBudget: 1200,
|
|
128
|
+
includeBodies: false,
|
|
129
|
+
includeCallGraph: false,
|
|
130
|
+
relevanceMode: 'strict',
|
|
131
|
+
requiredKeywords: ['ts'],
|
|
132
|
+
minKeywordMatches: 1,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
expect(strict).toContain('resolveImports')
|
|
136
|
+
expect(strict).toContain('normalizeTsPath')
|
|
137
|
+
expect(strict).not.toContain('renderHeader')
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test('failFast returns empty context when exact match is impossible', () => {
|
|
141
|
+
const { contract, lock } = makeFixture()
|
|
142
|
+
const builder = new ContextBuilder(contract, lock)
|
|
143
|
+
const ctx = builder.build({
|
|
144
|
+
task: 'resolver imports',
|
|
145
|
+
tokenBudget: 1200,
|
|
146
|
+
includeBodies: false,
|
|
147
|
+
includeCallGraph: false,
|
|
148
|
+
relevanceMode: 'strict',
|
|
149
|
+
requiredKeywords: ['nonexistent'],
|
|
150
|
+
exactOnly: true,
|
|
151
|
+
failFast: true,
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
expect(ctx.modules.length).toBe(0)
|
|
155
|
+
expect(ctx.meta.selectedFunctions).toBe(0)
|
|
156
|
+
expect((ctx.meta.reasons?.length ?? 0) > 0).toBe(true)
|
|
157
|
+
expect((ctx.meta.suggestions?.length ?? 0) > 0).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
})
|