@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/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, '&lt;')
219
260
  .replace(/>/g, '&gt;')
220
261
  .replace(/"/g, '&quot;')
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
+ }
@@ -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('# TestProject')
68
- expect(md).toContain('Architecture Overview')
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('Authentication module')
81
- expect(md).toContain('API module')
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('Depends on')
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 functions')
123
- expect(md).toContain('2 modules')
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('called by 1')
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
+ })