@getmikk/ai-context 1.2.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/package.json +27 -0
- package/src/claude-md-generator.ts +265 -0
- package/src/context-builder.ts +399 -0
- package/src/index.ts +4 -0
- package/src/providers.ts +155 -0
- package/src/types.ts +70 -0
- package/tests/claude-md.test.ts +137 -0
- package/tests/smoke.test.ts +5 -0
- package/tsconfig.json +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getmikk/ai-context",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"test": "bun test",
|
|
16
|
+
"publish": "npm publish --access public",
|
|
17
|
+
"dev": "tsc --watch"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@getmikk/core": "workspace:*",
|
|
21
|
+
"@getmikk/intent-engine": "workspace:*"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"typescript": "^5.7.0",
|
|
25
|
+
"@types/node": "^22.0.0"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/** Default token budget for claude.md — prevents bloating the context window */
|
|
4
|
+
const DEFAULT_TOKEN_BUDGET = 6000
|
|
5
|
+
|
|
6
|
+
/** Rough token estimation: ~4 chars per token */
|
|
7
|
+
function estimateTokens(text: string): number {
|
|
8
|
+
return Math.ceil(text.length / 4)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ClaudeMdGenerator — generates an always-accurate `claude.md` and `AGENTS.md`
|
|
13
|
+
* from the lock file and contract. Every function name, file path, and module
|
|
14
|
+
* relationship is sourced from the AST-derived lock file — never hand-authored.
|
|
15
|
+
*
|
|
16
|
+
* Tiered system per spec:
|
|
17
|
+
* Tier 1: Summary (~500 tokens) — always included
|
|
18
|
+
* Tier 2: Module details (~300 tokens/module) — included if budget allows
|
|
19
|
+
* Tier 3: Recent changes (~50 tokens/change) — last section added
|
|
20
|
+
*/
|
|
21
|
+
export class ClaudeMdGenerator {
|
|
22
|
+
constructor(
|
|
23
|
+
private contract: MikkContract,
|
|
24
|
+
private lock: MikkLock,
|
|
25
|
+
private tokenBudget: number = DEFAULT_TOKEN_BUDGET
|
|
26
|
+
) { }
|
|
27
|
+
|
|
28
|
+
/** Generate the full claude.md content */
|
|
29
|
+
generate(): string {
|
|
30
|
+
const sections: string[] = []
|
|
31
|
+
let usedTokens = 0
|
|
32
|
+
|
|
33
|
+
// ── Tier 1: Summary (always included) ──────────────────────
|
|
34
|
+
const summary = this.generateSummary()
|
|
35
|
+
sections.push(summary)
|
|
36
|
+
usedTokens += estimateTokens(summary)
|
|
37
|
+
|
|
38
|
+
// ── Tier 2: Module details (if budget allows) ──────────────
|
|
39
|
+
const modules = this.getModulesSortedByDependencyOrder()
|
|
40
|
+
for (const module of modules) {
|
|
41
|
+
const moduleSection = this.generateModuleSection(module.id)
|
|
42
|
+
const tokens = estimateTokens(moduleSection)
|
|
43
|
+
if (usedTokens + tokens > this.tokenBudget) {
|
|
44
|
+
sections.push('\n> Full details available in `mikk.lock.json`\n')
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
sections.push(moduleSection)
|
|
48
|
+
usedTokens += tokens
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Tier 3: Constraints & decisions ────────────────────────
|
|
52
|
+
const constraintsSection = this.generateConstraintsSection()
|
|
53
|
+
const constraintTokens = estimateTokens(constraintsSection)
|
|
54
|
+
if (usedTokens + constraintTokens <= this.tokenBudget) {
|
|
55
|
+
sections.push(constraintsSection)
|
|
56
|
+
usedTokens += constraintTokens
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const decisionsSection = this.generateDecisionsSection()
|
|
60
|
+
const decisionTokens = estimateTokens(decisionsSection)
|
|
61
|
+
if (usedTokens + decisionTokens <= this.tokenBudget) {
|
|
62
|
+
sections.push(decisionsSection)
|
|
63
|
+
usedTokens += decisionTokens
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return sections.join('\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Tier 1: Summary ───────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
private generateSummary(): string {
|
|
72
|
+
const lines: string[] = []
|
|
73
|
+
const moduleCount = this.contract.declared.modules.length
|
|
74
|
+
const functionCount = Object.keys(this.lock.functions).length
|
|
75
|
+
const fileCount = Object.keys(this.lock.files).length
|
|
76
|
+
|
|
77
|
+
lines.push(`# ${this.contract.project.name} — Architecture Overview`)
|
|
78
|
+
lines.push('')
|
|
79
|
+
|
|
80
|
+
if (this.contract.project.description) {
|
|
81
|
+
lines.push('## What this project does')
|
|
82
|
+
lines.push(this.contract.project.description)
|
|
83
|
+
lines.push('')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push('## Modules')
|
|
87
|
+
for (const module of this.contract.declared.modules) {
|
|
88
|
+
const fnCount = Object.values(this.lock.functions)
|
|
89
|
+
.filter(f => f.moduleId === module.id).length
|
|
90
|
+
const desc = module.intent || module.description || ''
|
|
91
|
+
const descStr = desc ? ` — ${desc}` : ''
|
|
92
|
+
lines.push(`- **${module.name}** (\`${module.id}\`): ${fnCount} functions${descStr}`)
|
|
93
|
+
}
|
|
94
|
+
lines.push('')
|
|
95
|
+
|
|
96
|
+
lines.push(`## Stats`)
|
|
97
|
+
lines.push(`- ${fileCount} files, ${functionCount} functions, ${moduleCount} modules`)
|
|
98
|
+
lines.push(`- Language: ${this.contract.project.language}`)
|
|
99
|
+
lines.push('')
|
|
100
|
+
|
|
101
|
+
// Critical constraints summary
|
|
102
|
+
if (this.contract.declared.constraints.length > 0) {
|
|
103
|
+
lines.push('## Critical Constraints')
|
|
104
|
+
for (const c of this.contract.declared.constraints) {
|
|
105
|
+
lines.push(`- ${c}`)
|
|
106
|
+
}
|
|
107
|
+
lines.push('')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return lines.join('\n')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Tier 2: Module Details ────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
private generateModuleSection(moduleId: string): string {
|
|
116
|
+
const module = this.contract.declared.modules.find(m => m.id === moduleId)
|
|
117
|
+
if (!module) return ''
|
|
118
|
+
|
|
119
|
+
const lines: string[] = []
|
|
120
|
+
const moduleFunctions = Object.values(this.lock.functions)
|
|
121
|
+
.filter(f => f.moduleId === moduleId)
|
|
122
|
+
|
|
123
|
+
lines.push(`## ${module.name} module`)
|
|
124
|
+
|
|
125
|
+
// Location
|
|
126
|
+
if (module.paths.length > 0) {
|
|
127
|
+
lines.push(`**Location:** ${module.paths.join(', ')}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Intent
|
|
131
|
+
if (module.intent) {
|
|
132
|
+
lines.push(`**Purpose:** ${module.intent}`)
|
|
133
|
+
} else if (module.description) {
|
|
134
|
+
lines.push(`**Purpose:** ${module.description}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push('')
|
|
138
|
+
|
|
139
|
+
// Entry points: functions with no calledBy (likely public API surface)
|
|
140
|
+
const entryPoints = moduleFunctions
|
|
141
|
+
.filter(fn => fn.calledBy.length === 0)
|
|
142
|
+
.sort((a, b) => b.calls.length - a.calls.length)
|
|
143
|
+
.slice(0, 5)
|
|
144
|
+
|
|
145
|
+
if (entryPoints.length > 0) {
|
|
146
|
+
lines.push('**Entry points:**')
|
|
147
|
+
for (const fn of entryPoints) {
|
|
148
|
+
const sig = this.formatSignature(fn)
|
|
149
|
+
const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
|
|
150
|
+
lines.push(` - \`${sig}\`${purpose}`)
|
|
151
|
+
}
|
|
152
|
+
lines.push('')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Key functions: top 5 by calledBy count (most depended upon)
|
|
156
|
+
const keyFunctions = [...moduleFunctions]
|
|
157
|
+
.sort((a, b) => b.calledBy.length - a.calledBy.length)
|
|
158
|
+
.filter(fn => fn.calledBy.length > 0)
|
|
159
|
+
.slice(0, 5)
|
|
160
|
+
|
|
161
|
+
if (keyFunctions.length > 0) {
|
|
162
|
+
lines.push('**Key internal functions:**')
|
|
163
|
+
for (const fn of keyFunctions) {
|
|
164
|
+
const callerCount = fn.calledBy.length
|
|
165
|
+
const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
|
|
166
|
+
lines.push(` - \`${fn.name}\` (called by ${callerCount})${purpose}`)
|
|
167
|
+
}
|
|
168
|
+
lines.push('')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Dependencies: other modules this module imports from
|
|
172
|
+
const depModuleIds = new Set<string>()
|
|
173
|
+
for (const fn of moduleFunctions) {
|
|
174
|
+
for (const callId of fn.calls) {
|
|
175
|
+
const target = this.lock.functions[callId]
|
|
176
|
+
if (target && target.moduleId !== moduleId) {
|
|
177
|
+
depModuleIds.add(target.moduleId)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (depModuleIds.size > 0) {
|
|
183
|
+
const depNames = [...depModuleIds].map(id => {
|
|
184
|
+
const mod = this.contract.declared.modules.find(m => m.id === id)
|
|
185
|
+
return mod?.name || id
|
|
186
|
+
})
|
|
187
|
+
lines.push(`**Depends on:** ${depNames.join(', ')}`)
|
|
188
|
+
lines.push('')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Module-specific constraints
|
|
192
|
+
const moduleConstraints = this.contract.declared.constraints.filter(c =>
|
|
193
|
+
c.toLowerCase().includes(moduleId.toLowerCase()) ||
|
|
194
|
+
c.toLowerCase().includes(module.name.toLowerCase())
|
|
195
|
+
)
|
|
196
|
+
if (moduleConstraints.length > 0) {
|
|
197
|
+
lines.push('**Constraints:**')
|
|
198
|
+
for (const c of moduleConstraints) {
|
|
199
|
+
lines.push(` - ${c}`)
|
|
200
|
+
}
|
|
201
|
+
lines.push('')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return lines.join('\n')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Tier 3: Constraints & Decisions ───────────────────────────
|
|
208
|
+
|
|
209
|
+
private generateConstraintsSection(): string {
|
|
210
|
+
if (this.contract.declared.constraints.length === 0) return ''
|
|
211
|
+
const lines: string[] = []
|
|
212
|
+
lines.push('## Cross-Cutting Constraints')
|
|
213
|
+
for (const c of this.contract.declared.constraints) {
|
|
214
|
+
lines.push(`- ${c}`)
|
|
215
|
+
}
|
|
216
|
+
lines.push('')
|
|
217
|
+
return lines.join('\n')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private generateDecisionsSection(): string {
|
|
221
|
+
if (this.contract.declared.decisions.length === 0) return ''
|
|
222
|
+
const lines: string[] = []
|
|
223
|
+
lines.push('## Architectural Decisions')
|
|
224
|
+
for (const d of this.contract.declared.decisions) {
|
|
225
|
+
lines.push(`- **${d.title}:** ${d.reason}`)
|
|
226
|
+
}
|
|
227
|
+
lines.push('')
|
|
228
|
+
return lines.join('\n')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Helpers ───────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
/** Format a function into a readable signature */
|
|
234
|
+
private formatSignature(fn: MikkLockFunction): string {
|
|
235
|
+
return `${fn.name}() [${fn.file}:${fn.startLine}]`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Sort modules by inter-module dependency order (depended-on modules first) */
|
|
239
|
+
private getModulesSortedByDependencyOrder(): typeof this.contract.declared.modules {
|
|
240
|
+
const modules = [...this.contract.declared.modules]
|
|
241
|
+
const dependencyCount = new Map<string, number>()
|
|
242
|
+
|
|
243
|
+
for (const mod of modules) {
|
|
244
|
+
dependencyCount.set(mod.id, 0)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Count how many other modules depend on each module
|
|
248
|
+
for (const fn of Object.values(this.lock.functions)) {
|
|
249
|
+
for (const callId of fn.calls) {
|
|
250
|
+
const target = this.lock.functions[callId]
|
|
251
|
+
if (target && target.moduleId !== fn.moduleId) {
|
|
252
|
+
dependencyCount.set(
|
|
253
|
+
target.moduleId,
|
|
254
|
+
(dependencyCount.get(target.moduleId) || 0) + 1
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Sort: most depended-on first
|
|
261
|
+
return modules.sort((a, b) =>
|
|
262
|
+
(dependencyCount.get(b.id) || 0) - (dependencyCount.get(a.id) || 0)
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
|
+
import type { AIContext, ContextQuery, ContextModule, ContextFunction } from './types.js'
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Scoring weights — tune these to adjust what "relevant" means
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
const WEIGHT = {
|
|
8
|
+
// Call-graph proximity (closer = more relevant)
|
|
9
|
+
DIRECT_CALL: 1.00, // fn directly calls or is called by focus node
|
|
10
|
+
HOP_2: 0.60, // 2 hops away
|
|
11
|
+
HOP_3: 0.35,
|
|
12
|
+
HOP_4: 0.15,
|
|
13
|
+
// Name/keyword match
|
|
14
|
+
KEYWORD_EXACT: 0.90, // function name exactly matches a task keyword
|
|
15
|
+
KEYWORD_PARTIAL: 0.45, // function name contains a task keyword
|
|
16
|
+
// Entry-point bonus — functions nothing calls deserve attention
|
|
17
|
+
ENTRY_POINT: 0.20,
|
|
18
|
+
// Exported function bonus
|
|
19
|
+
EXPORTED: 0.10,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Default token budget per context payload
|
|
23
|
+
const DEFAULT_TOKEN_BUDGET = 6000
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rough token estimator: 1 token ≈ 4 chars for code/identifiers
|
|
27
|
+
*/
|
|
28
|
+
function estimateTokens(text: string): number {
|
|
29
|
+
return Math.ceil(text.length / 4)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Graph traversal helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* BFS from a set of seed node IDs, walking BOTH upstream and downstream
|
|
38
|
+
* edges up to `maxDepth` hops. Returns a Map<nodeId, depth>.
|
|
39
|
+
*/
|
|
40
|
+
function bfsNeighbors(
|
|
41
|
+
seeds: string[],
|
|
42
|
+
functions: Record<string, MikkLockFunction>,
|
|
43
|
+
maxDepth: number
|
|
44
|
+
): Map<string, number> {
|
|
45
|
+
const visited = new Map<string, number>()
|
|
46
|
+
const queue: { id: string; depth: number }[] = seeds.map(id => ({ id, depth: 0 }))
|
|
47
|
+
|
|
48
|
+
while (queue.length > 0) {
|
|
49
|
+
const { id, depth } = queue.shift()!
|
|
50
|
+
if (visited.has(id)) continue
|
|
51
|
+
visited.set(id, depth)
|
|
52
|
+
if (depth >= maxDepth) continue
|
|
53
|
+
|
|
54
|
+
const fn = functions[id]
|
|
55
|
+
if (!fn) continue
|
|
56
|
+
|
|
57
|
+
// Walk downstream (what this fn calls)
|
|
58
|
+
for (const callee of fn.calls) {
|
|
59
|
+
if (!visited.has(callee)) {
|
|
60
|
+
queue.push({ id: callee, depth: depth + 1 })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Walk upstream (what calls this fn)
|
|
64
|
+
for (const caller of fn.calledBy) {
|
|
65
|
+
if (!visited.has(caller)) {
|
|
66
|
+
queue.push({ id: caller, depth: depth + 1 })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return visited
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert a depth value to a relevance score using the WEIGHT table.
|
|
76
|
+
*/
|
|
77
|
+
function depthToScore(depth: number): number {
|
|
78
|
+
switch (depth) {
|
|
79
|
+
case 0: return 1.0
|
|
80
|
+
case 1: return WEIGHT.DIRECT_CALL
|
|
81
|
+
case 2: return WEIGHT.HOP_2
|
|
82
|
+
case 3: return WEIGHT.HOP_3
|
|
83
|
+
default: return WEIGHT.HOP_4
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Keyword extraction — pull meaningful tokens from the task string
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
const STOP_WORDS = new Set([
|
|
92
|
+
'a', 'an', 'the', 'and', 'or', 'for', 'in', 'on', 'of', 'to',
|
|
93
|
+
'how', 'does', 'do', 'is', 'are', 'add', 'new', 'create', 'make',
|
|
94
|
+
'update', 'fix', 'get', 'set', 'this', 'that', 'with', 'from',
|
|
95
|
+
'what', 'where', 'when', 'why', 'should', 'can', 'will', 'need',
|
|
96
|
+
'want', 'like', 'just', 'also', 'some', 'all', 'any', 'my', 'your',
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
function extractKeywords(task: string): string[] {
|
|
100
|
+
return task
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9\s_-]/g, ' ')
|
|
103
|
+
.split(/\s+/)
|
|
104
|
+
.filter(w => w.length > 2 && !STOP_WORDS.has(w))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Keyword score for a function: exact match > partial match
|
|
109
|
+
*/
|
|
110
|
+
function keywordScore(fn: MikkLockFunction, keywords: string[]): number {
|
|
111
|
+
if (keywords.length === 0) return 0
|
|
112
|
+
const nameLower = fn.name.toLowerCase()
|
|
113
|
+
const fileLower = fn.file.toLowerCase()
|
|
114
|
+
let score = 0
|
|
115
|
+
|
|
116
|
+
for (const kw of keywords) {
|
|
117
|
+
if (nameLower === kw) {
|
|
118
|
+
score = Math.max(score, WEIGHT.KEYWORD_EXACT)
|
|
119
|
+
} else if (nameLower.includes(kw) || fileLower.includes(kw)) {
|
|
120
|
+
score = Math.max(score, WEIGHT.KEYWORD_PARTIAL)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return score
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Seed resolution — find the best starting nodes for graph traversal
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find seed function IDs from focusFiles, focusModules, or task keywords.
|
|
132
|
+
* Seeds are the "center of gravity" for the BFS walk.
|
|
133
|
+
*/
|
|
134
|
+
function resolveSeeds(
|
|
135
|
+
query: ContextQuery,
|
|
136
|
+
contract: MikkContract,
|
|
137
|
+
lock: MikkLock
|
|
138
|
+
): string[] {
|
|
139
|
+
const seeds = new Set<string>()
|
|
140
|
+
|
|
141
|
+
// 1. Explicit focus files → all functions in those files
|
|
142
|
+
if (query.focusFiles && query.focusFiles.length > 0) {
|
|
143
|
+
for (const filePath of query.focusFiles) {
|
|
144
|
+
for (const fn of Object.values(lock.functions)) {
|
|
145
|
+
if (fn.file.includes(filePath) || filePath.includes(fn.file)) {
|
|
146
|
+
seeds.add(fn.id)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 2. Explicit focus modules → all functions in those modules
|
|
153
|
+
if (query.focusModules && query.focusModules.length > 0) {
|
|
154
|
+
for (const modId of query.focusModules) {
|
|
155
|
+
for (const fn of Object.values(lock.functions)) {
|
|
156
|
+
if (fn.moduleId === modId) seeds.add(fn.id)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 3. Keyword match against function names and file paths
|
|
162
|
+
if (seeds.size === 0) {
|
|
163
|
+
const keywords = extractKeywords(query.task)
|
|
164
|
+
for (const fn of Object.values(lock.functions)) {
|
|
165
|
+
if (keywordScore(fn, keywords) >= WEIGHT.KEYWORD_PARTIAL) {
|
|
166
|
+
seeds.add(fn.id)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 4. Module name match against task
|
|
172
|
+
if (seeds.size === 0) {
|
|
173
|
+
const taskLower = query.task.toLowerCase()
|
|
174
|
+
for (const mod of contract.declared.modules) {
|
|
175
|
+
if (
|
|
176
|
+
taskLower.includes(mod.id.toLowerCase()) ||
|
|
177
|
+
taskLower.includes(mod.name.toLowerCase())
|
|
178
|
+
) {
|
|
179
|
+
for (const fn of Object.values(lock.functions)) {
|
|
180
|
+
if (fn.moduleId === mod.id) seeds.add(fn.id)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [...seeds]
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Main ContextBuilder
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export class ContextBuilder {
|
|
194
|
+
constructor(
|
|
195
|
+
private contract: MikkContract,
|
|
196
|
+
private lock: MikkLock
|
|
197
|
+
) { }
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build AI context for a given query.
|
|
201
|
+
*
|
|
202
|
+
* Algorithm:
|
|
203
|
+
* 1. Resolve seed nodes from focusFiles / focusModules / keyword match
|
|
204
|
+
* 2. BFS outward up to maxHops, collecting proximity scores
|
|
205
|
+
* 3. Add keyword scores on top
|
|
206
|
+
* 4. Sort all functions by total score, descending
|
|
207
|
+
* 5. Fill a token budget greedily — highest-scored functions first
|
|
208
|
+
* 6. Group survivors by module, emit structured context
|
|
209
|
+
*/
|
|
210
|
+
build(query: ContextQuery): AIContext {
|
|
211
|
+
const tokenBudget = query.tokenBudget ?? DEFAULT_TOKEN_BUDGET
|
|
212
|
+
const maxHops = query.maxHops ?? 4
|
|
213
|
+
|
|
214
|
+
// ── Step 1: Resolve seeds ──────────────────────────────────────────
|
|
215
|
+
const seeds = resolveSeeds(query, this.contract, this.lock)
|
|
216
|
+
|
|
217
|
+
// ── Step 2: BFS proximity scores ──────────────────────────────────
|
|
218
|
+
const proximityMap = seeds.length > 0
|
|
219
|
+
? bfsNeighbors(seeds, this.lock.functions, maxHops)
|
|
220
|
+
: new Map<string, number>()
|
|
221
|
+
|
|
222
|
+
// ── Step 3: Score every function ──────────────────────────────────
|
|
223
|
+
const keywords = extractKeywords(query.task)
|
|
224
|
+
const allFunctions = Object.values(this.lock.functions)
|
|
225
|
+
|
|
226
|
+
const scored: { fn: MikkLockFunction; score: number }[] = allFunctions.map(fn => {
|
|
227
|
+
let score = 0
|
|
228
|
+
|
|
229
|
+
// Proximity from BFS
|
|
230
|
+
const depth = proximityMap.get(fn.id)
|
|
231
|
+
if (depth !== undefined) {
|
|
232
|
+
score += depthToScore(depth)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Keyword match
|
|
236
|
+
score += keywordScore(fn, keywords)
|
|
237
|
+
|
|
238
|
+
// Entry-point bonus
|
|
239
|
+
if (fn.calledBy.length === 0) score += WEIGHT.ENTRY_POINT
|
|
240
|
+
|
|
241
|
+
return { fn, score }
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ── Step 4: Sort by score descending ──────────────────────────────
|
|
245
|
+
scored.sort((a, b) => b.score - a.score)
|
|
246
|
+
|
|
247
|
+
// ── Step 5: Fill token budget ──────────────────────────────────────
|
|
248
|
+
const selected: MikkLockFunction[] = []
|
|
249
|
+
let usedTokens = 0
|
|
250
|
+
|
|
251
|
+
for (const { fn, score } of scored) {
|
|
252
|
+
if (score <= 0 && seeds.length > 0) break // Nothing relevant left
|
|
253
|
+
if (selected.length >= (query.maxFunctions ?? 80)) break
|
|
254
|
+
|
|
255
|
+
const snippet = this.buildFunctionSnippet(fn, query)
|
|
256
|
+
const tokens = estimateTokens(snippet)
|
|
257
|
+
|
|
258
|
+
if (usedTokens + tokens > tokenBudget) continue // skip, try smaller ones later
|
|
259
|
+
selected.push(fn)
|
|
260
|
+
usedTokens += tokens
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Step 6: Group by module ────────────────────────────────────────
|
|
264
|
+
const byModule = new Map<string, MikkLockFunction[]>()
|
|
265
|
+
for (const fn of selected) {
|
|
266
|
+
if (!byModule.has(fn.moduleId)) byModule.set(fn.moduleId, [])
|
|
267
|
+
byModule.get(fn.moduleId)!.push(fn)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const contextModules: ContextModule[] = []
|
|
271
|
+
for (const [modId, fns] of byModule) {
|
|
272
|
+
const modDef = this.contract.declared.modules.find(m => m.id === modId)
|
|
273
|
+
const moduleFiles = Object.values(this.lock.files)
|
|
274
|
+
.filter(f => f.moduleId === modId)
|
|
275
|
+
.map(f => f.path)
|
|
276
|
+
|
|
277
|
+
contextModules.push({
|
|
278
|
+
id: modId,
|
|
279
|
+
name: modDef?.name ?? modId,
|
|
280
|
+
description: modDef?.description ?? '',
|
|
281
|
+
intent: modDef?.intent,
|
|
282
|
+
functions: fns.map(fn => this.toContextFunction(fn, query)),
|
|
283
|
+
files: moduleFiles,
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Sort modules: ones with more selected functions first
|
|
288
|
+
contextModules.sort((a, b) => b.functions.length - a.functions.length)
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
project: {
|
|
292
|
+
name: this.contract.project.name,
|
|
293
|
+
language: this.contract.project.language,
|
|
294
|
+
description: this.contract.project.description,
|
|
295
|
+
moduleCount: this.contract.declared.modules.length,
|
|
296
|
+
functionCount: Object.keys(this.lock.functions).length,
|
|
297
|
+
},
|
|
298
|
+
modules: contextModules,
|
|
299
|
+
constraints: this.contract.declared.constraints,
|
|
300
|
+
decisions: this.contract.declared.decisions.map(d => ({
|
|
301
|
+
title: d.title,
|
|
302
|
+
reason: d.reason,
|
|
303
|
+
})),
|
|
304
|
+
prompt: this.generatePrompt(query, contextModules),
|
|
305
|
+
meta: {
|
|
306
|
+
seedCount: seeds.length,
|
|
307
|
+
totalFunctionsConsidered: allFunctions.length,
|
|
308
|
+
selectedFunctions: selected.length,
|
|
309
|
+
estimatedTokens: usedTokens,
|
|
310
|
+
keywords,
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Private helpers ────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
private toContextFunction(fn: MikkLockFunction, query: ContextQuery): ContextFunction {
|
|
318
|
+
return {
|
|
319
|
+
name: fn.name,
|
|
320
|
+
file: fn.file,
|
|
321
|
+
startLine: fn.startLine,
|
|
322
|
+
endLine: fn.endLine,
|
|
323
|
+
calls: query.includeCallGraph !== false ? fn.calls : [],
|
|
324
|
+
calledBy: query.includeCallGraph !== false ? fn.calledBy : [],
|
|
325
|
+
purpose: fn.purpose,
|
|
326
|
+
errorHandling: fn.errorHandling?.map(e => `${e.type} @ line ${e.line}: ${e.detail}`),
|
|
327
|
+
edgeCases: fn.edgeCasesHandled,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Build a compact text snippet for token estimation.
|
|
333
|
+
* Mirrors what the providers will emit.
|
|
334
|
+
*/
|
|
335
|
+
private buildFunctionSnippet(fn: MikkLockFunction, query: ContextQuery): string {
|
|
336
|
+
const parts = [`${fn.name}(${fn.file}:${fn.startLine}-${fn.endLine})`]
|
|
337
|
+
if (fn.purpose) parts.push(` — ${fn.purpose}`)
|
|
338
|
+
if (query.includeCallGraph !== false && fn.calls.length > 0) {
|
|
339
|
+
parts.push(` calls:[${fn.calls.join(',')}]`)
|
|
340
|
+
}
|
|
341
|
+
return parts.join('')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Generate the natural-language prompt section */
|
|
345
|
+
private generatePrompt(query: ContextQuery, modules: ContextModule[]): string {
|
|
346
|
+
const lines: string[] = []
|
|
347
|
+
|
|
348
|
+
lines.push('=== ARCHITECTURAL CONTEXT ===')
|
|
349
|
+
lines.push(`Project: ${this.contract.project.name} (${this.contract.project.language})`)
|
|
350
|
+
if (this.contract.project.description) {
|
|
351
|
+
lines.push(`Description: ${this.contract.project.description}`)
|
|
352
|
+
}
|
|
353
|
+
lines.push(`Task: ${query.task}`)
|
|
354
|
+
lines.push('')
|
|
355
|
+
|
|
356
|
+
for (const mod of modules) {
|
|
357
|
+
lines.push(`--- Module: ${mod.name} (${mod.id}) ---`)
|
|
358
|
+
if (mod.description) lines.push(mod.description)
|
|
359
|
+
if (mod.intent) lines.push(`Intent: ${mod.intent}`)
|
|
360
|
+
lines.push('')
|
|
361
|
+
|
|
362
|
+
for (const fn of mod.functions) {
|
|
363
|
+
const callStr = fn.calls.length > 0
|
|
364
|
+
? ` → [${fn.calls.join(', ')}]`
|
|
365
|
+
: ''
|
|
366
|
+
const calledByStr = fn.calledBy.length > 0
|
|
367
|
+
? ` ← called by [${fn.calledBy.join(', ')}]`
|
|
368
|
+
: ''
|
|
369
|
+
lines.push(` ${fn.name} ${fn.file}:${fn.startLine}-${fn.endLine}${callStr}${calledByStr}`)
|
|
370
|
+
if (fn.purpose) lines.push(` purpose: ${fn.purpose}`)
|
|
371
|
+
if (fn.edgeCases && fn.edgeCases.length > 0) {
|
|
372
|
+
lines.push(` edge cases: ${fn.edgeCases.join('; ')}`)
|
|
373
|
+
}
|
|
374
|
+
if (fn.errorHandling && fn.errorHandling.length > 0) {
|
|
375
|
+
lines.push(` error handling: ${fn.errorHandling.join('; ')}`)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
lines.push('')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (this.contract.declared.constraints.length > 0) {
|
|
382
|
+
lines.push('=== CONSTRAINTS (MUST follow) ===')
|
|
383
|
+
for (const c of this.contract.declared.constraints) {
|
|
384
|
+
lines.push(` • ${c}`)
|
|
385
|
+
}
|
|
386
|
+
lines.push('')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (this.contract.declared.decisions.length > 0) {
|
|
390
|
+
lines.push('=== ARCHITECTURAL DECISIONS ===')
|
|
391
|
+
for (const d of this.contract.declared.decisions) {
|
|
392
|
+
lines.push(` • ${d.title}: ${d.reason}`)
|
|
393
|
+
}
|
|
394
|
+
lines.push('')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return lines.join('\n')
|
|
398
|
+
}
|
|
399
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ContextBuilder } from './context-builder.js'
|
|
2
|
+
export { ClaudeMdGenerator } from './claude-md-generator.js'
|
|
3
|
+
export { ClaudeProvider, GenericProvider, getProvider } from './providers.js'
|
|
4
|
+
export type { AIContext, ContextModule, ContextFunction, ContextQuery, ContextProvider } from './types.js'
|
package/src/providers.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { AIContext, ContextProvider } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ClaudeProvider — formats context for Anthropic Claude models.
|
|
5
|
+
*
|
|
6
|
+
* Uses structured XML tags so Claude can parse boundaries clearly.
|
|
7
|
+
* Includes meta block so the model knows how much context was trimmed.
|
|
8
|
+
*/
|
|
9
|
+
export class ClaudeProvider implements ContextProvider {
|
|
10
|
+
name = 'claude'
|
|
11
|
+
maxTokens = 200000
|
|
12
|
+
|
|
13
|
+
formatContext(context: AIContext): string {
|
|
14
|
+
const lines: string[] = []
|
|
15
|
+
|
|
16
|
+
lines.push('<mikk_context>')
|
|
17
|
+
|
|
18
|
+
// ── Project ────────────────────────────────────────────────────────
|
|
19
|
+
lines.push(`<project name="${esc(context.project.name)}" language="${esc(context.project.language)}">`)
|
|
20
|
+
lines.push(` <description>${esc(context.project.description)}</description>`)
|
|
21
|
+
lines.push(` <stats modules="${context.project.moduleCount}" functions="${context.project.functionCount}"/>`)
|
|
22
|
+
lines.push('</project>')
|
|
23
|
+
lines.push('')
|
|
24
|
+
|
|
25
|
+
// ── Context quality meta ───────────────────────────────────────────
|
|
26
|
+
lines.push('<context_meta>')
|
|
27
|
+
lines.push(` <task>${esc(context.meta?.keywords?.join(', ') ?? '')}</task>`)
|
|
28
|
+
lines.push(` <seeds_found>${context.meta?.seedCount ?? 0}</seeds_found>`)
|
|
29
|
+
lines.push(` <functions_selected>${context.meta?.selectedFunctions ?? 0} of ${context.meta?.totalFunctionsConsidered ?? 0}</functions_selected>`)
|
|
30
|
+
lines.push(` <estimated_tokens>${context.meta?.estimatedTokens ?? 0}</estimated_tokens>`)
|
|
31
|
+
lines.push('</context_meta>')
|
|
32
|
+
lines.push('')
|
|
33
|
+
|
|
34
|
+
// ── Modules ────────────────────────────────────────────────────────
|
|
35
|
+
for (const mod of context.modules) {
|
|
36
|
+
lines.push(`<module id="${esc(mod.id)}" name="${esc(mod.name)}">`)
|
|
37
|
+
lines.push(` <description>${esc(mod.description)}</description>`)
|
|
38
|
+
if (mod.intent) lines.push(` <intent>${esc(mod.intent)}</intent>`)
|
|
39
|
+
lines.push(` <files count="${mod.files.length}">`)
|
|
40
|
+
for (const f of mod.files) {
|
|
41
|
+
lines.push(` <file>${esc(f)}</file>`)
|
|
42
|
+
}
|
|
43
|
+
lines.push(' </files>')
|
|
44
|
+
|
|
45
|
+
if (mod.functions.length > 0) {
|
|
46
|
+
lines.push(' <functions>')
|
|
47
|
+
for (const fn of mod.functions) {
|
|
48
|
+
const calls = fn.calls.length > 0
|
|
49
|
+
? ` calls="${esc(fn.calls.join(','))}"`
|
|
50
|
+
: ''
|
|
51
|
+
const calledBy = fn.calledBy.length > 0
|
|
52
|
+
? ` calledBy="${esc(fn.calledBy.join(','))}"`
|
|
53
|
+
: ''
|
|
54
|
+
lines.push(` <fn name="${esc(fn.name)}" file="${esc(fn.file)}" lines="${fn.startLine}-${fn.endLine}"${calls}${calledBy}>`)
|
|
55
|
+
if (fn.purpose) lines.push(` <purpose>${esc(fn.purpose)}</purpose>`)
|
|
56
|
+
if (fn.edgeCases && fn.edgeCases.length > 0) {
|
|
57
|
+
lines.push(` <edge_cases>${esc(fn.edgeCases.join('; '))}</edge_cases>`)
|
|
58
|
+
}
|
|
59
|
+
if (fn.errorHandling && fn.errorHandling.length > 0) {
|
|
60
|
+
lines.push(` <error_handling>${esc(fn.errorHandling.join('; '))}</error_handling>`)
|
|
61
|
+
}
|
|
62
|
+
lines.push(' </fn>')
|
|
63
|
+
}
|
|
64
|
+
lines.push(' </functions>')
|
|
65
|
+
}
|
|
66
|
+
lines.push('</module>')
|
|
67
|
+
lines.push('')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Constraints ────────────────────────────────────────────────────
|
|
71
|
+
if (context.constraints.length > 0) {
|
|
72
|
+
lines.push('<constraints>')
|
|
73
|
+
for (const c of context.constraints) {
|
|
74
|
+
lines.push(` <constraint>${esc(c)}</constraint>`)
|
|
75
|
+
}
|
|
76
|
+
lines.push('</constraints>')
|
|
77
|
+
lines.push('')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Decisions ─────────────────────────────────────────────────────
|
|
81
|
+
if (context.decisions.length > 0) {
|
|
82
|
+
lines.push('<architectural_decisions>')
|
|
83
|
+
for (const d of context.decisions) {
|
|
84
|
+
lines.push(` <decision title="${esc(d.title)}">${esc(d.reason)}</decision>`)
|
|
85
|
+
}
|
|
86
|
+
lines.push('</architectural_decisions>')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push('</mikk_context>')
|
|
90
|
+
return lines.join('\n')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* GenericProvider — clean plain-text format for any model.
|
|
96
|
+
* Identical to the natural-language prompt generated by ContextBuilder.
|
|
97
|
+
*/
|
|
98
|
+
export class GenericProvider implements ContextProvider {
|
|
99
|
+
name = 'generic'
|
|
100
|
+
maxTokens = 128000
|
|
101
|
+
|
|
102
|
+
formatContext(context: AIContext): string {
|
|
103
|
+
return context.prompt
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CompactProvider — ultra-minimal format for small context windows.
|
|
109
|
+
* One line per function, no XML, no prose.
|
|
110
|
+
*/
|
|
111
|
+
export class CompactProvider implements ContextProvider {
|
|
112
|
+
name = 'compact'
|
|
113
|
+
maxTokens = 16000
|
|
114
|
+
|
|
115
|
+
formatContext(context: AIContext): string {
|
|
116
|
+
const lines: string[] = [
|
|
117
|
+
`# ${context.project.name} (${context.project.language})`,
|
|
118
|
+
`Task keywords: ${context.meta?.keywords?.join(', ') ?? ''}`,
|
|
119
|
+
'',
|
|
120
|
+
]
|
|
121
|
+
for (const mod of context.modules) {
|
|
122
|
+
lines.push(`## ${mod.name}`)
|
|
123
|
+
for (const fn of mod.functions) {
|
|
124
|
+
const calls = fn.calls.length > 0 ? ` → ${fn.calls.join(',')}` : ''
|
|
125
|
+
lines.push(` ${fn.name} [${fn.file}:${fn.startLine}]${calls}`)
|
|
126
|
+
}
|
|
127
|
+
lines.push('')
|
|
128
|
+
}
|
|
129
|
+
if (context.constraints.length > 0) {
|
|
130
|
+
lines.push('CONSTRAINTS: ' + context.constraints.join(' | '))
|
|
131
|
+
}
|
|
132
|
+
return lines.join('\n')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getProvider(name: string): ContextProvider {
|
|
137
|
+
switch (name.toLowerCase()) {
|
|
138
|
+
case 'claude':
|
|
139
|
+
case 'anthropic':
|
|
140
|
+
return new ClaudeProvider()
|
|
141
|
+
case 'compact':
|
|
142
|
+
return new CompactProvider()
|
|
143
|
+
default:
|
|
144
|
+
return new GenericProvider()
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Minimal XML attribute escaping */
|
|
149
|
+
function esc(s: string): string {
|
|
150
|
+
return s
|
|
151
|
+
.replace(/&/g, '&')
|
|
152
|
+
.replace(/</g, '<')
|
|
153
|
+
.replace(/>/g, '>')
|
|
154
|
+
.replace(/"/g, '"')
|
|
155
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
|
+
|
|
3
|
+
/** The structured context object passed to AI models */
|
|
4
|
+
export interface AIContext {
|
|
5
|
+
project: {
|
|
6
|
+
name: string
|
|
7
|
+
language: string
|
|
8
|
+
description: string
|
|
9
|
+
moduleCount: number
|
|
10
|
+
functionCount: number
|
|
11
|
+
}
|
|
12
|
+
modules: ContextModule[]
|
|
13
|
+
constraints: string[]
|
|
14
|
+
decisions: { title: string; reason: string }[]
|
|
15
|
+
prompt: string
|
|
16
|
+
/** Diagnostic info — helpful for debugging context quality */
|
|
17
|
+
meta: {
|
|
18
|
+
seedCount: number
|
|
19
|
+
totalFunctionsConsidered: number
|
|
20
|
+
selectedFunctions: number
|
|
21
|
+
estimatedTokens: number
|
|
22
|
+
keywords: string[]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ContextModule {
|
|
27
|
+
id: string
|
|
28
|
+
name: string
|
|
29
|
+
description: string
|
|
30
|
+
intent?: string
|
|
31
|
+
functions: ContextFunction[]
|
|
32
|
+
files: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ContextFunction {
|
|
36
|
+
name: string
|
|
37
|
+
file: string
|
|
38
|
+
startLine: number
|
|
39
|
+
endLine: number
|
|
40
|
+
calls: string[]
|
|
41
|
+
calledBy: string[]
|
|
42
|
+
purpose?: string
|
|
43
|
+
errorHandling?: string[]
|
|
44
|
+
edgeCases?: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Query options for context generation */
|
|
48
|
+
export interface ContextQuery {
|
|
49
|
+
/** The user's task description — the primary relevance signal */
|
|
50
|
+
task: string
|
|
51
|
+
/** Specific files to anchor the graph traversal from */
|
|
52
|
+
focusFiles?: string[]
|
|
53
|
+
/** Specific modules to include */
|
|
54
|
+
focusModules?: string[]
|
|
55
|
+
/** Max functions to include in output (hard cap) */
|
|
56
|
+
maxFunctions?: number
|
|
57
|
+
/** Max BFS hops from seed nodes (default 4) */
|
|
58
|
+
maxHops?: number
|
|
59
|
+
/** Approximate token budget for function listings (default 6000) */
|
|
60
|
+
tokenBudget?: number
|
|
61
|
+
/** Include call graph arrows (default true) */
|
|
62
|
+
includeCallGraph?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Context provider interface for different AI platforms */
|
|
66
|
+
export interface ContextProvider {
|
|
67
|
+
name: string
|
|
68
|
+
formatContext(context: AIContext): string
|
|
69
|
+
maxTokens: number
|
|
70
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test'
|
|
2
|
+
import { ClaudeMdGenerator } from '../src/claude-md-generator'
|
|
3
|
+
import type { MikkContract, MikkLock } from '@getmikk/core'
|
|
4
|
+
|
|
5
|
+
const mockContract: MikkContract = {
|
|
6
|
+
version: '1.0.0',
|
|
7
|
+
project: {
|
|
8
|
+
name: 'TestProject',
|
|
9
|
+
description: 'A test project for validating claude.md generation',
|
|
10
|
+
language: 'TypeScript',
|
|
11
|
+
entryPoints: ['src/index.ts'],
|
|
12
|
+
},
|
|
13
|
+
declared: {
|
|
14
|
+
modules: [
|
|
15
|
+
{ id: 'auth', name: 'Authentication', description: 'Handles user authentication', intent: 'JWT-based auth flow', paths: ['src/auth/**'] },
|
|
16
|
+
{ id: 'api', name: 'API', description: 'REST API layer', paths: ['src/api/**'] },
|
|
17
|
+
],
|
|
18
|
+
constraints: [
|
|
19
|
+
'No direct DB access outside db/',
|
|
20
|
+
'All auth must go through auth.middleware',
|
|
21
|
+
],
|
|
22
|
+
decisions: [
|
|
23
|
+
{ id: 'd1', title: 'Use JWT', reason: 'Stateless auth for scalability', date: '2024-01-01' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
overwrite: { mode: 'never', requireConfirmation: true },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mockLock: MikkLock = {
|
|
30
|
+
version: '1.0.0',
|
|
31
|
+
generatedAt: new Date().toISOString(),
|
|
32
|
+
generatorVersion: '1.1.0',
|
|
33
|
+
projectRoot: '/test',
|
|
34
|
+
syncState: { status: 'clean', lastSyncAt: new Date().toISOString(), lockHash: 'a', contractHash: 'b' },
|
|
35
|
+
modules: {
|
|
36
|
+
auth: { id: 'auth', files: ['src/auth/verify.ts'], hash: 'h1', fragmentPath: '.mikk/fragments/auth.json' },
|
|
37
|
+
api: { id: 'api', files: ['src/api/login.ts'], hash: 'h2', fragmentPath: '.mikk/fragments/api.json' },
|
|
38
|
+
},
|
|
39
|
+
functions: {
|
|
40
|
+
'fn:auth:verifyToken': {
|
|
41
|
+
id: 'fn:auth:verifyToken', name: 'verifyToken', file: 'src/auth/verify.ts',
|
|
42
|
+
startLine: 1, endLine: 10, hash: 'h1', calls: [], calledBy: ['fn:api:handleLogin'],
|
|
43
|
+
moduleId: 'auth', purpose: 'Verify JWT tokens',
|
|
44
|
+
},
|
|
45
|
+
'fn:auth:refreshToken': {
|
|
46
|
+
id: 'fn:auth:refreshToken', name: 'refreshToken', file: 'src/auth/refresh.ts',
|
|
47
|
+
startLine: 1, endLine: 15, hash: 'h2', calls: [], calledBy: [],
|
|
48
|
+
moduleId: 'auth',
|
|
49
|
+
},
|
|
50
|
+
'fn:api:handleLogin': {
|
|
51
|
+
id: 'fn:api:handleLogin', name: 'handleLogin', file: 'src/api/login.ts',
|
|
52
|
+
startLine: 1, endLine: 20, hash: 'h3', calls: ['fn:auth:verifyToken'], calledBy: [],
|
|
53
|
+
moduleId: 'api',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
files: {
|
|
57
|
+
'src/auth/verify.ts': { path: 'src/auth/verify.ts', hash: 'fh1', moduleId: 'auth', lastModified: new Date().toISOString() },
|
|
58
|
+
'src/api/login.ts': { path: 'src/api/login.ts', hash: 'fh2', moduleId: 'api', lastModified: new Date().toISOString() },
|
|
59
|
+
},
|
|
60
|
+
graph: { nodes: 3, edges: 1, rootHash: 'root' },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('ClaudeMdGenerator', () => {
|
|
64
|
+
test('generates valid markdown', () => {
|
|
65
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
66
|
+
const md = gen.generate()
|
|
67
|
+
expect(md).toContain('# TestProject')
|
|
68
|
+
expect(md).toContain('Architecture Overview')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('includes project description', () => {
|
|
72
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
73
|
+
const md = gen.generate()
|
|
74
|
+
expect(md).toContain('A test project for validating claude.md generation')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('includes module sections', () => {
|
|
78
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
79
|
+
const md = gen.generate()
|
|
80
|
+
expect(md).toContain('Authentication module')
|
|
81
|
+
expect(md).toContain('API module')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('includes function names', () => {
|
|
85
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
86
|
+
const md = gen.generate()
|
|
87
|
+
expect(md).toContain('verifyToken')
|
|
88
|
+
expect(md).toContain('handleLogin')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('includes constraints', () => {
|
|
92
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
93
|
+
const md = gen.generate()
|
|
94
|
+
expect(md).toContain('No direct DB access outside db/')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('includes decisions', () => {
|
|
98
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
99
|
+
const md = gen.generate()
|
|
100
|
+
expect(md).toContain('Use JWT')
|
|
101
|
+
expect(md).toContain('Stateless auth for scalability')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('includes dependency info', () => {
|
|
105
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
106
|
+
const md = gen.generate()
|
|
107
|
+
// API depends on Auth (handleLogin calls verifyToken)
|
|
108
|
+
expect(md).toContain('Depends on')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('respects token budget', () => {
|
|
112
|
+
// Use a very small budget — should truncate
|
|
113
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock, 200)
|
|
114
|
+
const md = gen.generate()
|
|
115
|
+
// Should have the summary but may be truncated
|
|
116
|
+
expect(md).toContain('TestProject')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('includes stats', () => {
|
|
120
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
121
|
+
const md = gen.generate()
|
|
122
|
+
expect(md).toContain('3 functions')
|
|
123
|
+
expect(md).toContain('2 modules')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('shows purpose when available', () => {
|
|
127
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
128
|
+
const md = gen.generate()
|
|
129
|
+
expect(md).toContain('Verify JWT tokens')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('shows calledBy count for key functions', () => {
|
|
133
|
+
const gen = new ClaudeMdGenerator(mockContract, mockLock)
|
|
134
|
+
const md = gen.generate()
|
|
135
|
+
expect(md).toContain('called by 1')
|
|
136
|
+
})
|
|
137
|
+
})
|