@getmikk/ai-context 1.3.2 → 1.5.1
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 +3 -3
- package/src/claude-md-generator.ts +490 -15
- package/src/context-builder.ts +343 -3
- package/src/index.ts +1 -0
- package/src/providers.ts +68 -2
- package/src/types.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getmikk/ai-context",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
"dev": "tsc --watch"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@getmikk/core": "^1.
|
|
25
|
-
"@getmikk/intent-engine": "^1.
|
|
24
|
+
"@getmikk/core": "^1.5.1",
|
|
25
|
+
"@getmikk/intent-engine": "^1.5.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"typescript": "^5.7.0",
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
2
|
|
|
3
|
-
/** Default token budget for claude.md —
|
|
4
|
-
const DEFAULT_TOKEN_BUDGET =
|
|
3
|
+
/** Default token budget for claude.md — generous but still bounded */
|
|
4
|
+
const DEFAULT_TOKEN_BUDGET = 12000
|
|
5
5
|
|
|
6
6
|
/** Rough token estimation: ~4 chars per token */
|
|
7
7
|
function estimateTokens(text: string): number {
|
|
8
8
|
return Math.ceil(text.length / 4)
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/** Metadata from package.json that enriches the AI context */
|
|
12
|
+
export interface ProjectMeta {
|
|
13
|
+
description?: string
|
|
14
|
+
scripts?: Record<string, string>
|
|
15
|
+
dependencies?: Record<string, string>
|
|
16
|
+
devDependencies?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* ClaudeMdGenerator — generates an always-accurate `claude.md` and `AGENTS.md`
|
|
13
21
|
* from the lock file and contract. Every function name, file path, and module
|
|
@@ -19,11 +27,16 @@ function estimateTokens(text: string): number {
|
|
|
19
27
|
* Tier 3: Recent changes (~50 tokens/change) — last section added
|
|
20
28
|
*/
|
|
21
29
|
export class ClaudeMdGenerator {
|
|
30
|
+
private meta: ProjectMeta
|
|
31
|
+
|
|
22
32
|
constructor(
|
|
23
33
|
private contract: MikkContract,
|
|
24
34
|
private lock: MikkLock,
|
|
25
|
-
private tokenBudget: number = DEFAULT_TOKEN_BUDGET
|
|
26
|
-
|
|
35
|
+
private tokenBudget: number = DEFAULT_TOKEN_BUDGET,
|
|
36
|
+
meta?: ProjectMeta
|
|
37
|
+
) {
|
|
38
|
+
this.meta = meta || {}
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
/** Generate the full claude.md content */
|
|
29
42
|
generate(): string {
|
|
@@ -35,8 +48,29 @@ export class ClaudeMdGenerator {
|
|
|
35
48
|
sections.push(summary)
|
|
36
49
|
usedTokens += estimateTokens(summary)
|
|
37
50
|
|
|
51
|
+
// ── Tech stack & conventions (always included if detectable) ──
|
|
52
|
+
const techSection = this.generateTechStackSection()
|
|
53
|
+
if (techSection) {
|
|
54
|
+
sections.push(techSection)
|
|
55
|
+
usedTokens += estimateTokens(techSection)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Build / test / run commands ─────────────────────────────
|
|
59
|
+
const commandsSection = this.generateCommandsSection()
|
|
60
|
+
if (commandsSection) {
|
|
61
|
+
sections.push(commandsSection)
|
|
62
|
+
usedTokens += estimateTokens(commandsSection)
|
|
63
|
+
}
|
|
64
|
+
|
|
38
65
|
// ── Tier 2: Module details (if budget allows) ──────────────
|
|
66
|
+
// Skip modules with zero functions — they waste AI tokens
|
|
39
67
|
const modules = this.getModulesSortedByDependencyOrder()
|
|
68
|
+
.filter(m => {
|
|
69
|
+
const fnCount = Object.values(this.lock.functions)
|
|
70
|
+
.filter(f => f.moduleId === m.id).length
|
|
71
|
+
return fnCount > 0
|
|
72
|
+
})
|
|
73
|
+
|
|
40
74
|
for (const module of modules) {
|
|
41
75
|
const moduleSection = this.generateModuleSection(module.id)
|
|
42
76
|
const tokens = estimateTokens(moduleSection)
|
|
@@ -48,6 +82,36 @@ export class ClaudeMdGenerator {
|
|
|
48
82
|
usedTokens += tokens
|
|
49
83
|
}
|
|
50
84
|
|
|
85
|
+
// ── Context files: schemas, data models, config ─────────
|
|
86
|
+
const contextSection = this.generateContextFilesSection()
|
|
87
|
+
if (contextSection) {
|
|
88
|
+
const ctxTokens = estimateTokens(contextSection)
|
|
89
|
+
if (usedTokens + ctxTokens <= this.tokenBudget) {
|
|
90
|
+
sections.push(contextSection)
|
|
91
|
+
usedTokens += ctxTokens
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── File import graph per module ────────────────────────────
|
|
96
|
+
const importSection = this.generateImportGraphSection()
|
|
97
|
+
if (importSection) {
|
|
98
|
+
const impTokens = estimateTokens(importSection)
|
|
99
|
+
if (usedTokens + impTokens <= this.tokenBudget) {
|
|
100
|
+
sections.push(importSection)
|
|
101
|
+
usedTokens += impTokens
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── HTTP Routes (Express + Next.js) ─────────────────────────
|
|
106
|
+
const routesSection = this.generateRoutesSection()
|
|
107
|
+
if (routesSection) {
|
|
108
|
+
const routeTokens = estimateTokens(routesSection)
|
|
109
|
+
if (usedTokens + routeTokens <= this.tokenBudget) {
|
|
110
|
+
sections.push(routesSection)
|
|
111
|
+
usedTokens += routeTokens
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
// ── Tier 3: Constraints & decisions ────────────────────────
|
|
52
116
|
const constraintsSection = this.generateConstraintsSection()
|
|
53
117
|
const constraintTokens = estimateTokens(constraintsSection)
|
|
@@ -77,24 +141,35 @@ export class ClaudeMdGenerator {
|
|
|
77
141
|
lines.push(`# ${this.contract.project.name} — Architecture Overview`)
|
|
78
142
|
lines.push('')
|
|
79
143
|
|
|
80
|
-
|
|
144
|
+
// Project description: prefer contract, fall back to package.json
|
|
145
|
+
const description = this.contract.project.description || this.meta.description
|
|
146
|
+
if (description) {
|
|
81
147
|
lines.push('## What this project does')
|
|
82
|
-
lines.push(
|
|
148
|
+
lines.push(description)
|
|
83
149
|
lines.push('')
|
|
84
150
|
}
|
|
85
151
|
|
|
152
|
+
// Only list modules that have functions (skip empty ones)
|
|
153
|
+
const nonEmptyModules = this.contract.declared.modules.filter(m => {
|
|
154
|
+
const fnCount = Object.values(this.lock.functions)
|
|
155
|
+
.filter(f => f.moduleId === m.id).length
|
|
156
|
+
return fnCount > 0
|
|
157
|
+
})
|
|
158
|
+
|
|
86
159
|
lines.push('## Modules')
|
|
87
|
-
for (const module of
|
|
160
|
+
for (const module of nonEmptyModules) {
|
|
88
161
|
const fnCount = Object.values(this.lock.functions)
|
|
89
162
|
.filter(f => f.moduleId === module.id).length
|
|
90
163
|
const desc = module.intent || module.description || ''
|
|
91
|
-
|
|
164
|
+
// Strip leading "N functions — " from auto-generated descriptions to avoid double-counting
|
|
165
|
+
const cleanDesc = desc.replace(/^\d+ functions\s*—\s*/, '')
|
|
166
|
+
const descStr = cleanDesc ? ` — ${cleanDesc}` : ''
|
|
92
167
|
lines.push(`- **${module.name}** (\`${module.id}\`): ${fnCount} functions${descStr}`)
|
|
93
168
|
}
|
|
94
169
|
lines.push('')
|
|
95
170
|
|
|
96
171
|
lines.push(`## Stats`)
|
|
97
|
-
lines.push(`- ${fileCount} files, ${functionCount} functions, ${
|
|
172
|
+
lines.push(`- ${fileCount} files, ${functionCount} functions, ${nonEmptyModules.length} modules`)
|
|
98
173
|
lines.push(`- Language: ${this.contract.project.language}`)
|
|
99
174
|
lines.push('')
|
|
100
175
|
|
|
@@ -122,9 +197,10 @@ export class ClaudeMdGenerator {
|
|
|
122
197
|
|
|
123
198
|
lines.push(`## ${module.name} module`)
|
|
124
199
|
|
|
125
|
-
// Location
|
|
200
|
+
// Location — collapse to common prefix when many paths share a root
|
|
126
201
|
if (module.paths.length > 0) {
|
|
127
|
-
|
|
202
|
+
const collapsed = this.collapsePaths(module.paths)
|
|
203
|
+
lines.push(`**Location:** ${collapsed}`)
|
|
128
204
|
}
|
|
129
205
|
|
|
130
206
|
// Intent
|
|
@@ -146,7 +222,7 @@ export class ClaudeMdGenerator {
|
|
|
146
222
|
lines.push('**Entry points:**')
|
|
147
223
|
for (const fn of entryPoints) {
|
|
148
224
|
const sig = this.formatSignature(fn)
|
|
149
|
-
const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
|
|
225
|
+
const purpose = fn.purpose ? ` — ${this.oneLine(fn.purpose)}` : ''
|
|
150
226
|
lines.push(` - \`${sig}\`${purpose}`)
|
|
151
227
|
}
|
|
152
228
|
lines.push('')
|
|
@@ -162,7 +238,7 @@ export class ClaudeMdGenerator {
|
|
|
162
238
|
lines.push('**Key internal functions:**')
|
|
163
239
|
for (const fn of keyFunctions) {
|
|
164
240
|
const callerCount = fn.calledBy.length
|
|
165
|
-
const purpose = fn.purpose ? ` — ${fn.purpose}` : ''
|
|
241
|
+
const purpose = fn.purpose ? ` — ${this.oneLine(fn.purpose)}` : ''
|
|
166
242
|
lines.push(` - \`${fn.name}\` (called by ${callerCount})${purpose}`)
|
|
167
243
|
}
|
|
168
244
|
lines.push('')
|
|
@@ -228,11 +304,410 @@ export class ClaudeMdGenerator {
|
|
|
228
304
|
return lines.join('\n')
|
|
229
305
|
}
|
|
230
306
|
|
|
307
|
+
// ── Context Files Section ──────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/** Generate a section with discovered schema/config files inlined */
|
|
310
|
+
private generateContextFilesSection(): string | null {
|
|
311
|
+
const ctxFiles = this.lock.contextFiles
|
|
312
|
+
if (!ctxFiles || ctxFiles.length === 0) return null
|
|
313
|
+
|
|
314
|
+
const lines: string[] = []
|
|
315
|
+
lines.push('## Data Models & Schemas')
|
|
316
|
+
lines.push('')
|
|
317
|
+
lines.push('These files define the project\'s data structures, schemas, and configuration.')
|
|
318
|
+
lines.push('They are auto-discovered and included verbatim from the source.')
|
|
319
|
+
lines.push('')
|
|
320
|
+
|
|
321
|
+
for (const cf of ctxFiles) {
|
|
322
|
+
const ext = cf.path.split('.').pop() || 'txt'
|
|
323
|
+
const lang = this.extToLang(ext)
|
|
324
|
+
lines.push(`### \`${cf.path}\` (${cf.type})`)
|
|
325
|
+
lines.push('')
|
|
326
|
+
lines.push('```' + lang)
|
|
327
|
+
// Trim content to avoid blowing up the token budget
|
|
328
|
+
const maxChars = 8000 // ~2000 tokens per file
|
|
329
|
+
if (cf.content.length > maxChars) {
|
|
330
|
+
lines.push(cf.content.slice(0, maxChars))
|
|
331
|
+
lines.push(`// ... truncated (${cf.size} bytes total)`)
|
|
332
|
+
} else {
|
|
333
|
+
lines.push(cf.content.trimEnd())
|
|
334
|
+
}
|
|
335
|
+
lines.push('```')
|
|
336
|
+
lines.push('')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return lines.join('\n')
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Map file extensions to Markdown code fence languages */
|
|
343
|
+
private extToLang(ext: string): string {
|
|
344
|
+
const map: Record<string, string> = {
|
|
345
|
+
ts: 'typescript', js: 'javascript', tsx: 'tsx', jsx: 'jsx',
|
|
346
|
+
mjs: 'javascript', cjs: 'javascript',
|
|
347
|
+
py: 'python', go: 'go', rs: 'rust', rb: 'ruby',
|
|
348
|
+
java: 'java', kt: 'kotlin', cs: 'csharp', swift: 'swift',
|
|
349
|
+
dart: 'dart', ex: 'elixir', exs: 'elixir', php: 'php',
|
|
350
|
+
prisma: 'prisma', graphql: 'graphql', gql: 'graphql',
|
|
351
|
+
sql: 'sql', proto: 'protobuf', yaml: 'yaml', yml: 'yaml',
|
|
352
|
+
json: 'json', toml: 'toml', xml: 'xml',
|
|
353
|
+
css: 'css', scss: 'scss', html: 'html',
|
|
354
|
+
svelte: 'svelte', vue: 'vue',
|
|
355
|
+
md: 'markdown', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
356
|
+
dockerfile: 'dockerfile', tf: 'hcl',
|
|
357
|
+
avsc: 'json', thrift: 'thrift',
|
|
358
|
+
}
|
|
359
|
+
return map[ext.toLowerCase()] || ext
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Routes Section ──────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
/** Generate a section with detected HTTP route registrations + Next.js filesystem routes */
|
|
365
|
+
private generateRoutesSection(): string | null {
|
|
366
|
+
const expressRoutes = this.lock.routes || []
|
|
367
|
+
const nextRoutes = this.detectNextJsRoutes()
|
|
368
|
+
if (expressRoutes.length === 0 && nextRoutes.length === 0) return null
|
|
369
|
+
|
|
370
|
+
const lines: string[] = []
|
|
371
|
+
lines.push('## HTTP Routes')
|
|
372
|
+
lines.push('')
|
|
373
|
+
|
|
374
|
+
// Next.js App Router routes (detected from file paths)
|
|
375
|
+
if (nextRoutes.length > 0) {
|
|
376
|
+
lines.push('### API Routes (Next.js App Router)')
|
|
377
|
+
for (const r of nextRoutes) {
|
|
378
|
+
const methods = r.methods.length > 0 ? r.methods.join(', ') : 'handler'
|
|
379
|
+
lines.push(`- **${methods}** \`${r.urlPath}\` *(${r.file})*`)
|
|
380
|
+
}
|
|
381
|
+
lines.push('')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Express/Koa/Hono routes
|
|
385
|
+
if (expressRoutes.length > 0) {
|
|
386
|
+
if (nextRoutes.length > 0) lines.push('### Server Routes')
|
|
387
|
+
for (const r of expressRoutes) {
|
|
388
|
+
const mw = r.middlewares.length > 0 ? ` → [${r.middlewares.join(', ')}]` : ''
|
|
389
|
+
lines.push(`- **${r.method}** \`${r.path}\` → \`${r.handler}\`${mw} *(${r.file}:${r.line})*`)
|
|
390
|
+
}
|
|
391
|
+
lines.push('')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return lines.join('\n')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Tech Stack Section ──────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
/** Detect technology stack from dependencies and config */
|
|
400
|
+
private generateTechStackSection(): string | null {
|
|
401
|
+
const deps = { ...this.meta.dependencies, ...this.meta.devDependencies }
|
|
402
|
+
if (Object.keys(deps).length === 0) return null
|
|
403
|
+
|
|
404
|
+
const detected: string[] = []
|
|
405
|
+
|
|
406
|
+
// Language / Runtime
|
|
407
|
+
const lang = this.contract.project.language
|
|
408
|
+
if (lang && lang !== 'typescript' && lang !== 'javascript') {
|
|
409
|
+
detected.push(lang.charAt(0).toUpperCase() + lang.slice(1))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Frameworks — JS/TS
|
|
413
|
+
if (deps['next']) detected.push(`Next.js ${deps['next'].replace(/^\^|~/, '')}`)
|
|
414
|
+
else if (deps['nuxt']) detected.push(`Nuxt ${deps['nuxt'].replace(/^\^|~/, '')}`)
|
|
415
|
+
else if (deps['@sveltejs/kit']) detected.push('SvelteKit')
|
|
416
|
+
else if (deps['@remix-run/react'] || deps['@remix-run/node']) detected.push('Remix')
|
|
417
|
+
else if (deps['astro']) detected.push('Astro')
|
|
418
|
+
else if (deps['gatsby']) detected.push('Gatsby')
|
|
419
|
+
else if (deps['express']) detected.push('Express')
|
|
420
|
+
else if (deps['fastify']) detected.push('Fastify')
|
|
421
|
+
else if (deps['hono']) detected.push('Hono')
|
|
422
|
+
else if (deps['koa']) detected.push('Koa')
|
|
423
|
+
else if (deps['nestjs'] || deps['@nestjs/core']) detected.push('NestJS')
|
|
424
|
+
if (deps['react']) detected.push('React')
|
|
425
|
+
if (deps['vue']) detected.push('Vue')
|
|
426
|
+
if (deps['svelte']) detected.push('Svelte')
|
|
427
|
+
if (deps['solid-js']) detected.push('SolidJS')
|
|
428
|
+
if (deps['angular'] || deps['@angular/core']) detected.push('Angular')
|
|
429
|
+
|
|
430
|
+
// Mobile / Desktop
|
|
431
|
+
if (deps['react-native']) detected.push('React Native')
|
|
432
|
+
if (deps['expo']) detected.push('Expo')
|
|
433
|
+
if (deps['@capacitor/core']) detected.push('Capacitor')
|
|
434
|
+
if (deps['electron']) detected.push('Electron')
|
|
435
|
+
if (deps['tauri'] || deps['@tauri-apps/api']) detected.push('Tauri')
|
|
436
|
+
|
|
437
|
+
// Database / ORM
|
|
438
|
+
if (deps['prisma'] || deps['@prisma/client']) detected.push('Prisma ORM')
|
|
439
|
+
else if (deps['drizzle-orm']) detected.push('Drizzle ORM')
|
|
440
|
+
else if (deps['typeorm']) detected.push('TypeORM')
|
|
441
|
+
else if (deps['mongoose']) detected.push('Mongoose')
|
|
442
|
+
else if (deps['sequelize']) detected.push('Sequelize')
|
|
443
|
+
else if (deps['knex']) detected.push('Knex')
|
|
444
|
+
else if (deps['pg'] || deps['mysql2'] || deps['better-sqlite3']) detected.push('SQL client')
|
|
445
|
+
if (deps['redis'] || deps['ioredis']) detected.push('Redis')
|
|
446
|
+
|
|
447
|
+
// Auth
|
|
448
|
+
if (deps['next-auth'] || deps['@auth/core']) detected.push('NextAuth')
|
|
449
|
+
else if (deps['passport']) detected.push('Passport.js')
|
|
450
|
+
else if (deps['clerk'] || deps['@clerk/nextjs']) detected.push('Clerk')
|
|
451
|
+
else if (deps['lucia'] || deps['lucia-auth']) detected.push('Lucia')
|
|
452
|
+
|
|
453
|
+
// Styling
|
|
454
|
+
if (deps['tailwindcss']) detected.push('Tailwind CSS')
|
|
455
|
+
if (deps['radix-ui'] || deps['@radix-ui/react-dialog'] || deps['@radix-ui/react-slot']) detected.push('Radix UI')
|
|
456
|
+
if (deps['shadcn'] || deps['shadcn-ui']) detected.push('shadcn/ui')
|
|
457
|
+
if (deps['styled-components']) detected.push('styled-components')
|
|
458
|
+
if (deps['@emotion/react']) detected.push('Emotion')
|
|
459
|
+
|
|
460
|
+
// State / Data
|
|
461
|
+
if (deps['@tanstack/react-query']) detected.push('TanStack Query')
|
|
462
|
+
if (deps['zustand']) detected.push('Zustand')
|
|
463
|
+
if (deps['jotai']) detected.push('Jotai')
|
|
464
|
+
if (deps['@reduxjs/toolkit'] || deps['redux']) detected.push('Redux')
|
|
465
|
+
if (deps['zod']) detected.push('Zod validation')
|
|
466
|
+
if (deps['@trpc/server'] || deps['@trpc/client']) detected.push('tRPC')
|
|
467
|
+
if (deps['graphql'] || deps['@apollo/client']) detected.push('GraphQL')
|
|
468
|
+
|
|
469
|
+
// Content / Docs
|
|
470
|
+
if (deps['fumadocs-core'] || deps['fumadocs-mdx']) detected.push('Fumadocs')
|
|
471
|
+
if (deps['next-mdx-remote'] || deps['@next/mdx']) detected.push('MDX')
|
|
472
|
+
if (deps['contentlayer'] || deps['contentlayer2']) detected.push('Contentlayer')
|
|
473
|
+
|
|
474
|
+
// Animation
|
|
475
|
+
if (deps['motion'] || deps['framer-motion']) detected.push('Motion')
|
|
476
|
+
|
|
477
|
+
// Analytics / Monitoring
|
|
478
|
+
if (deps['posthog-js'] || deps['@posthog/react']) detected.push('PostHog')
|
|
479
|
+
if (deps['@vercel/analytics']) detected.push('Vercel Analytics')
|
|
480
|
+
if (deps['@sentry/node'] || deps['@sentry/nextjs'] || deps['@sentry/browser']) detected.push('Sentry')
|
|
481
|
+
|
|
482
|
+
// URL state
|
|
483
|
+
if (deps['nuqs']) detected.push('nuqs')
|
|
484
|
+
|
|
485
|
+
// Messaging / Queue
|
|
486
|
+
if (deps['bullmq'] || deps['bull']) detected.push('BullMQ')
|
|
487
|
+
if (deps['amqplib']) detected.push('RabbitMQ')
|
|
488
|
+
if (deps['kafkajs']) detected.push('Kafka')
|
|
489
|
+
|
|
490
|
+
// Cloud / Infra
|
|
491
|
+
if (deps['aws-sdk'] || deps['@aws-sdk/client-s3']) detected.push('AWS SDK')
|
|
492
|
+
if (deps['@google-cloud/storage']) detected.push('Google Cloud')
|
|
493
|
+
if (deps['firebase'] || deps['firebase-admin']) detected.push('Firebase')
|
|
494
|
+
if (deps['@supabase/supabase-js']) detected.push('Supabase')
|
|
495
|
+
if (deps['convex']) detected.push('Convex')
|
|
496
|
+
|
|
497
|
+
// Testing
|
|
498
|
+
if (deps['jest']) detected.push('Jest')
|
|
499
|
+
else if (deps['vitest']) detected.push('Vitest')
|
|
500
|
+
if (deps['playwright'] || deps['@playwright/test']) detected.push('Playwright')
|
|
501
|
+
if (deps['cypress']) detected.push('Cypress')
|
|
502
|
+
if (deps['@testing-library/react']) detected.push('Testing Library')
|
|
503
|
+
|
|
504
|
+
// Build
|
|
505
|
+
if (deps['turbo'] || deps['turbo-json']) detected.push('Turborepo')
|
|
506
|
+
if (deps['nx']) detected.push('Nx')
|
|
507
|
+
if (deps['webpack']) detected.push('Webpack')
|
|
508
|
+
if (deps['vite']) detected.push('Vite')
|
|
509
|
+
if (deps['esbuild']) detected.push('esbuild')
|
|
510
|
+
if (deps['rollup']) detected.push('Rollup')
|
|
511
|
+
|
|
512
|
+
if (detected.length === 0) return null
|
|
513
|
+
|
|
514
|
+
const lines: string[] = []
|
|
515
|
+
lines.push('## Tech Stack')
|
|
516
|
+
lines.push(detected.join(' · '))
|
|
517
|
+
lines.push('')
|
|
518
|
+
return lines.join('\n')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Commands Section ────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
/** Detect build/test/dev commands from package.json scripts */
|
|
524
|
+
private generateCommandsSection(): string | null {
|
|
525
|
+
const scripts = this.meta.scripts
|
|
526
|
+
if (!scripts || Object.keys(scripts).length === 0) return null
|
|
527
|
+
|
|
528
|
+
// Auto-detect package manager from lockfile hints in meta, or fall back to heuristic
|
|
529
|
+
let pm = 'npm run'
|
|
530
|
+
const deps = { ...this.meta.dependencies, ...this.meta.devDependencies }
|
|
531
|
+
// Heuristic: check for telltale signs in scripts values
|
|
532
|
+
const allScriptValues = Object.values(scripts).join(' ')
|
|
533
|
+
if (allScriptValues.includes('bun ') || deps['bun-types']) pm = 'bun run'
|
|
534
|
+
else if (allScriptValues.includes('pnpm ') || allScriptValues.includes('pnpm-')) pm = 'pnpm'
|
|
535
|
+
else if (allScriptValues.includes('yarn ')) pm = 'yarn'
|
|
536
|
+
|
|
537
|
+
const useful: [string, string][] = []
|
|
538
|
+
const interestingKeys = ['dev', 'build', 'start', 'test', 'lint', 'format', 'typecheck', 'check', 'e2e', 'storybook', 'db:push', 'db:migrate', 'db:seed', 'prisma:generate', 'generate']
|
|
539
|
+
|
|
540
|
+
for (const key of interestingKeys) {
|
|
541
|
+
if (scripts[key]) {
|
|
542
|
+
useful.push([key, scripts[key]])
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (useful.length === 0) return null
|
|
547
|
+
|
|
548
|
+
const lines: string[] = []
|
|
549
|
+
lines.push('## Commands')
|
|
550
|
+
for (const [key, cmd] of useful) {
|
|
551
|
+
lines.push(`- \`${pm} ${key}\` \u2014 \`${cmd}\``)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
lines.push('')
|
|
555
|
+
return lines.join('\n')
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Next.js Route Detection ─────────────────────────────────
|
|
559
|
+
|
|
560
|
+
/** Detect Next.js App Router routes from file paths in the lock */
|
|
561
|
+
private detectNextJsRoutes(): { urlPath: string; methods: string[]; file: string }[] {
|
|
562
|
+
const routes: { urlPath: string; methods: string[]; file: string }[] = []
|
|
563
|
+
const allFiles = Object.keys(this.lock.files)
|
|
564
|
+
|
|
565
|
+
for (const filePath of allFiles) {
|
|
566
|
+
const normalised = filePath.replace(/\\/g, '/')
|
|
567
|
+
|
|
568
|
+
// App Router API routes: app/**/route.ts
|
|
569
|
+
const routeMatch = normalised.match(/^(?:src\/)?app\/(.+)\/route\.[jt]sx?$/)
|
|
570
|
+
if (routeMatch) {
|
|
571
|
+
const urlSegments = routeMatch[1]
|
|
572
|
+
.replace(/\([\w-]+\)\/?/g, '') // strip route groups (marketing)/
|
|
573
|
+
.replace(/\[\.\.\.(\w+)\]/g, ':$1*') // [...slug] → :slug*
|
|
574
|
+
.replace(/\[(\w+)\]/g, ':$1') // [id] → :id
|
|
575
|
+
const urlPath = `/${urlSegments}` || '/'
|
|
576
|
+
|
|
577
|
+
// Detect exported HTTP methods from functions AND generics
|
|
578
|
+
// (Next.js handlers can be `export async function GET` → fn: or `export const GET =` → const:)
|
|
579
|
+
const methods: string[] = []
|
|
580
|
+
const HTTP_VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
|
|
581
|
+
for (const [fnId, fn] of Object.entries(this.lock.functions)) {
|
|
582
|
+
if (fn.file === filePath && fn.isExported && HTTP_VERBS.includes(fn.name)) {
|
|
583
|
+
methods.push(fn.name)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (this.lock.generics) {
|
|
587
|
+
for (const [gId, g] of Object.entries(this.lock.generics)) {
|
|
588
|
+
// Direct match: this generic is in this file
|
|
589
|
+
if (g.file === filePath && g.isExported && HTTP_VERBS.includes(g.name)) {
|
|
590
|
+
if (!methods.includes(g.name)) methods.push(g.name)
|
|
591
|
+
}
|
|
592
|
+
// alsoIn match: deduped generics list the same verb in other files
|
|
593
|
+
if (g.isExported && HTTP_VERBS.includes(g.name) && g.alsoIn?.includes(filePath)) {
|
|
594
|
+
if (!methods.includes(g.name)) methods.push(g.name)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
routes.push({ urlPath, methods, file: filePath })
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// App Router pages: app/**/page.tsx (informational)
|
|
603
|
+
const pageMatch = normalised.match(/^(?:src\/)?app\/(.+)\/page\.[jt]sx?$/)
|
|
604
|
+
if (pageMatch) {
|
|
605
|
+
const urlSegments = pageMatch[1]
|
|
606
|
+
.replace(/\([\w-]+\)\/?/g, '') // strip route groups
|
|
607
|
+
.replace(/\[\.\.\.(\w+)\]/g, ':$1*')
|
|
608
|
+
.replace(/\[(\w+)\]/g, ':$1')
|
|
609
|
+
const urlPath = `/${urlSegments}` || '/'
|
|
610
|
+
routes.push({ urlPath, methods: ['PAGE'], file: filePath })
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Sort by URL path
|
|
615
|
+
return routes.sort((a, b) => a.urlPath.localeCompare(b.urlPath))
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ── Import Graph Section ──────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
/** Generate a per-module file import map */
|
|
621
|
+
private generateImportGraphSection(): string | null {
|
|
622
|
+
const filesWithImports = Object.values(this.lock.files)
|
|
623
|
+
.filter(f => f.imports && f.imports.length > 0)
|
|
624
|
+
if (filesWithImports.length === 0) return null
|
|
625
|
+
|
|
626
|
+
const lines: string[] = []
|
|
627
|
+
lines.push('## File Import Graph')
|
|
628
|
+
lines.push('')
|
|
629
|
+
lines.push('Which files import which — useful for understanding data flow.')
|
|
630
|
+
lines.push('')
|
|
631
|
+
|
|
632
|
+
// Group by module
|
|
633
|
+
const byModule = new Map<string, typeof filesWithImports>()
|
|
634
|
+
for (const f of filesWithImports) {
|
|
635
|
+
const existing = byModule.get(f.moduleId) || []
|
|
636
|
+
existing.push(f)
|
|
637
|
+
byModule.set(f.moduleId, existing)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const [moduleId, files] of byModule) {
|
|
641
|
+
const mod = this.contract.declared.modules.find(m => m.id === moduleId)
|
|
642
|
+
const name = mod?.name || moduleId
|
|
643
|
+
lines.push(`### ${name}`)
|
|
644
|
+
for (const f of files) {
|
|
645
|
+
const imports = f.imports!.map(imp => `\`${imp}\``).join(', ')
|
|
646
|
+
lines.push(`- \`${f.path}\` → ${imports}`)
|
|
647
|
+
}
|
|
648
|
+
lines.push('')
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return lines.join('\n')
|
|
652
|
+
}
|
|
653
|
+
|
|
231
654
|
// ── Helpers ───────────────────────────────────────────────────
|
|
232
655
|
|
|
233
|
-
/**
|
|
656
|
+
/** Collapse multi-line text to a single trimmed line */
|
|
657
|
+
private oneLine(text: string): string {
|
|
658
|
+
return text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim()
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Format a function into a compact single-line signature */
|
|
234
662
|
private formatSignature(fn: MikkLockFunction): string {
|
|
235
|
-
|
|
663
|
+
const asyncPrefix = fn.isAsync ? 'async ' : ''
|
|
664
|
+
const params = fn.params && fn.params.length > 0
|
|
665
|
+
? fn.params.map(p => {
|
|
666
|
+
const opt = p.optional ? '?' : ''
|
|
667
|
+
// Use just name (skip destructured object types — they bloat multi-line)
|
|
668
|
+
const name = p.name.replace(/\n/g, ' ').replace(/\s+/g, ' ')
|
|
669
|
+
return `${name}${opt}`
|
|
670
|
+
}).join(', ')
|
|
671
|
+
: ''
|
|
672
|
+
return `${asyncPrefix}${fn.name}(${params}) [${fn.file}:${fn.startLine}]`
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Collapse many paths into fewest glob patterns.
|
|
677
|
+
* e.g. ["src/features/portfolio/components/awards/**", "src/features/portfolio/components/bookmarks/**", ...]
|
|
678
|
+
* becomes "src/features/portfolio/**"
|
|
679
|
+
*/
|
|
680
|
+
private collapsePaths(paths: string[]): string {
|
|
681
|
+
if (paths.length <= 2) return paths.join(', ')
|
|
682
|
+
|
|
683
|
+
// Split each path into segments (strip trailing **)
|
|
684
|
+
const stripped = paths.map(p => p.replace(/\/\*\*$/, ''))
|
|
685
|
+
|
|
686
|
+
// Try progressively shorter common prefixes
|
|
687
|
+
// Find the longest common directory prefix shared by majority of paths
|
|
688
|
+
const segments = stripped.map(p => p.split('/'))
|
|
689
|
+
let bestPrefix = ''
|
|
690
|
+
for (let depth = 1; depth <= (segments[0]?.length ?? 0); depth++) {
|
|
691
|
+
const prefix = segments[0].slice(0, depth).join('/')
|
|
692
|
+
const matching = stripped.filter(p => p === prefix || p.startsWith(prefix + '/'))
|
|
693
|
+
if (matching.length >= Math.ceil(paths.length * 0.6)) {
|
|
694
|
+
bestPrefix = prefix
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (bestPrefix) {
|
|
699
|
+
// Some paths outside the prefix
|
|
700
|
+
const outside = paths.filter(p => {
|
|
701
|
+
const s = p.replace(/\/\*\*$/, '')
|
|
702
|
+
return s !== bestPrefix && !s.startsWith(bestPrefix + '/')
|
|
703
|
+
})
|
|
704
|
+
if (outside.length === 0) {
|
|
705
|
+
return `${bestPrefix}/**`
|
|
706
|
+
}
|
|
707
|
+
return [`${bestPrefix}/**`, ...outside].join(', ')
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return paths.join(', ')
|
|
236
711
|
}
|
|
237
712
|
|
|
238
713
|
/** Sort modules by inter-module dependency order (depended-on modules first) */
|
package/src/context-builder.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { MikkContract, MikkLock, MikkLockFunction } from '@getmikk/core'
|
|
2
2
|
import type { AIContext, ContextQuery, ContextModule, ContextFunction } from './types.js'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as path from 'node:path'
|
|
3
5
|
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
5
7
|
// Scoring weights — tune these to adjust what "relevant" means
|
|
@@ -301,6 +303,19 @@ export class ContextBuilder {
|
|
|
301
303
|
title: d.title,
|
|
302
304
|
reason: d.reason,
|
|
303
305
|
})),
|
|
306
|
+
contextFiles: this.lock.contextFiles?.map(cf => ({
|
|
307
|
+
path: cf.path,
|
|
308
|
+
content: cf.content,
|
|
309
|
+
type: cf.type,
|
|
310
|
+
})),
|
|
311
|
+
routes: this.lock.routes?.map(r => ({
|
|
312
|
+
method: r.method,
|
|
313
|
+
path: r.path,
|
|
314
|
+
handler: r.handler,
|
|
315
|
+
middlewares: r.middlewares,
|
|
316
|
+
file: r.file,
|
|
317
|
+
line: r.line,
|
|
318
|
+
})),
|
|
304
319
|
prompt: this.generatePrompt(query, contextModules),
|
|
305
320
|
meta: {
|
|
306
321
|
seedCount: seeds.length,
|
|
@@ -315,17 +330,53 @@ export class ContextBuilder {
|
|
|
315
330
|
// ── Private helpers ────────────────────────────────────────────────────
|
|
316
331
|
|
|
317
332
|
private toContextFunction(fn: MikkLockFunction, query: ContextQuery): ContextFunction {
|
|
318
|
-
|
|
333
|
+
const base: ContextFunction = {
|
|
319
334
|
name: fn.name,
|
|
320
335
|
file: fn.file,
|
|
321
336
|
startLine: fn.startLine,
|
|
322
337
|
endLine: fn.endLine,
|
|
323
338
|
calls: query.includeCallGraph !== false ? fn.calls : [],
|
|
324
339
|
calledBy: query.includeCallGraph !== false ? fn.calledBy : [],
|
|
340
|
+
params: fn.params,
|
|
341
|
+
returnType: fn.returnType,
|
|
342
|
+
isAsync: fn.isAsync,
|
|
343
|
+
isExported: fn.isExported,
|
|
325
344
|
purpose: fn.purpose,
|
|
326
345
|
errorHandling: fn.errorHandling?.map(e => `${e.type} @ line ${e.line}: ${e.detail}`),
|
|
327
346
|
edgeCases: fn.edgeCasesHandled,
|
|
328
347
|
}
|
|
348
|
+
|
|
349
|
+
// Attach body if requested and projectRoot is available
|
|
350
|
+
if (query.includeBodies !== false && query.projectRoot) {
|
|
351
|
+
base.body = this.readFunctionBody(fn, query.projectRoot)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return base
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Read the actual source code of a function from disk.
|
|
359
|
+
* Uses startLine/endLine from the lock to extract the relevant lines.
|
|
360
|
+
* Large bodies are compressed to preserve logic while stripping noise.
|
|
361
|
+
*/
|
|
362
|
+
private readFunctionBody(fn: MikkLockFunction, projectRoot: string): string | undefined {
|
|
363
|
+
try {
|
|
364
|
+
const filePath = path.resolve(projectRoot, fn.file)
|
|
365
|
+
if (!fs.existsSync(filePath)) return undefined
|
|
366
|
+
|
|
367
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
368
|
+
const lines = content.split('\n')
|
|
369
|
+
const start = Math.max(0, fn.startLine - 1) // Convert to 0-based
|
|
370
|
+
const end = Math.min(lines.length, fn.endLine)
|
|
371
|
+
const body = lines.slice(start, end).join('\n')
|
|
372
|
+
|
|
373
|
+
// Skip if body is trivially small (single-line setters etc.)
|
|
374
|
+
if (body.length < 20) return undefined
|
|
375
|
+
|
|
376
|
+
return compressBody(body)
|
|
377
|
+
} catch {
|
|
378
|
+
return undefined
|
|
379
|
+
}
|
|
329
380
|
}
|
|
330
381
|
|
|
331
382
|
/**
|
|
@@ -333,11 +384,21 @@ export class ContextBuilder {
|
|
|
333
384
|
* Mirrors what the providers will emit.
|
|
334
385
|
*/
|
|
335
386
|
private buildFunctionSnippet(fn: MikkLockFunction, query: ContextQuery): string {
|
|
336
|
-
const
|
|
387
|
+
const asyncStr = fn.isAsync ? 'async ' : ''
|
|
388
|
+
const params = fn.params?.map(p => `${p.name}: ${p.type}`).join(', ') || ''
|
|
389
|
+
const retStr = fn.returnType ? `: ${fn.returnType}` : ''
|
|
390
|
+
const parts = [`${asyncStr}${fn.name}(${params})${retStr} ${fn.file}:${fn.startLine}-${fn.endLine}`]
|
|
337
391
|
if (fn.purpose) parts.push(` — ${fn.purpose}`)
|
|
338
392
|
if (query.includeCallGraph !== false && fn.calls.length > 0) {
|
|
339
393
|
parts.push(` calls:[${fn.calls.join(',')}]`)
|
|
340
394
|
}
|
|
395
|
+
// Estimate body contribution to tokens if bodies will be included
|
|
396
|
+
if (query.includeBodies !== false && query.projectRoot) {
|
|
397
|
+
const bodyLines = (fn.endLine - fn.startLine) + 1
|
|
398
|
+
// Compressed bodies are ~40-60% smaller; use reduced estimate
|
|
399
|
+
const charsPerLine = bodyLines > 15 ? 20 : 40
|
|
400
|
+
parts.push('X'.repeat(bodyLines * charsPerLine))
|
|
401
|
+
}
|
|
341
402
|
return parts.join('')
|
|
342
403
|
}
|
|
343
404
|
|
|
@@ -353,6 +414,35 @@ export class ContextBuilder {
|
|
|
353
414
|
lines.push(`Task: ${query.task}`)
|
|
354
415
|
lines.push('')
|
|
355
416
|
|
|
417
|
+
// Include routes (API endpoints) — critical for understanding how the app works
|
|
418
|
+
const routes = this.lock.routes
|
|
419
|
+
if (routes && routes.length > 0) {
|
|
420
|
+
lines.push('=== HTTP ROUTES ===')
|
|
421
|
+
for (const r of routes) {
|
|
422
|
+
const mw = r.middlewares.length > 0 ? ` [${r.middlewares.join(', ')}]` : ''
|
|
423
|
+
lines.push(` ${r.method} ${r.path} → ${r.handler}${mw} (${r.file}:${r.line})`)
|
|
424
|
+
}
|
|
425
|
+
lines.push('')
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Include context files (schemas, data models) first — they define the shape
|
|
429
|
+
const ctxFiles = this.lock.contextFiles
|
|
430
|
+
if (ctxFiles && ctxFiles.length > 0) {
|
|
431
|
+
lines.push('=== DATA MODELS & SCHEMAS ===')
|
|
432
|
+
for (const cf of ctxFiles) {
|
|
433
|
+
lines.push(`--- ${cf.path} (${cf.type}) ---`)
|
|
434
|
+
// Trim to ~2000 chars per file in prompt output
|
|
435
|
+
const maxChars = 2000
|
|
436
|
+
if (cf.content.length > maxChars) {
|
|
437
|
+
lines.push(cf.content.slice(0, maxChars))
|
|
438
|
+
lines.push(`... (truncated, ${cf.size} bytes total)`)
|
|
439
|
+
} else {
|
|
440
|
+
lines.push(cf.content.trimEnd())
|
|
441
|
+
}
|
|
442
|
+
lines.push('')
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
356
446
|
for (const mod of modules) {
|
|
357
447
|
lines.push(`--- Module: ${mod.name} (${mod.id}) ---`)
|
|
358
448
|
if (mod.description) lines.push(mod.description)
|
|
@@ -360,13 +450,22 @@ export class ContextBuilder {
|
|
|
360
450
|
lines.push('')
|
|
361
451
|
|
|
362
452
|
for (const fn of mod.functions) {
|
|
453
|
+
// Rich signature
|
|
454
|
+
const asyncStr = fn.isAsync ? 'async ' : ''
|
|
455
|
+
const params = fn.params && fn.params.length > 0
|
|
456
|
+
? fn.params.map(p => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', ')
|
|
457
|
+
: ''
|
|
458
|
+
const retStr = fn.returnType ? `: ${fn.returnType}` : ''
|
|
459
|
+
const exported = fn.isExported ? 'export ' : ''
|
|
460
|
+
const sig = `${exported}${asyncStr}${fn.name}(${params})${retStr}`
|
|
461
|
+
|
|
363
462
|
const callStr = fn.calls.length > 0
|
|
364
463
|
? ` → [${fn.calls.join(', ')}]`
|
|
365
464
|
: ''
|
|
366
465
|
const calledByStr = fn.calledBy.length > 0
|
|
367
466
|
? ` ← called by [${fn.calledBy.join(', ')}]`
|
|
368
467
|
: ''
|
|
369
|
-
lines.push(` ${
|
|
468
|
+
lines.push(` ${sig} ${fn.file}:${fn.startLine}-${fn.endLine}${callStr}${calledByStr}`)
|
|
370
469
|
if (fn.purpose) lines.push(` purpose: ${fn.purpose}`)
|
|
371
470
|
if (fn.edgeCases && fn.edgeCases.length > 0) {
|
|
372
471
|
lines.push(` edge cases: ${fn.edgeCases.join('; ')}`)
|
|
@@ -374,6 +473,11 @@ export class ContextBuilder {
|
|
|
374
473
|
if (fn.errorHandling && fn.errorHandling.length > 0) {
|
|
375
474
|
lines.push(` error handling: ${fn.errorHandling.join('; ')}`)
|
|
376
475
|
}
|
|
476
|
+
if (fn.body) {
|
|
477
|
+
lines.push(' ```')
|
|
478
|
+
lines.push(fn.body)
|
|
479
|
+
lines.push(' ```')
|
|
480
|
+
}
|
|
377
481
|
}
|
|
378
482
|
lines.push('')
|
|
379
483
|
}
|
|
@@ -396,4 +500,240 @@ export class ContextBuilder {
|
|
|
396
500
|
|
|
397
501
|
return lines.join('\n')
|
|
398
502
|
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ---------------------------------------------------------------------------
|
|
506
|
+
// Body compressor — produces dense pseudo-code preserving all logic
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Compress a function body for context output.
|
|
511
|
+
* Bodies ≤ 15 lines pass through unchanged.
|
|
512
|
+
* Larger bodies get noise stripped, templates collapsed, and blocks condensed.
|
|
513
|
+
*/
|
|
514
|
+
function compressBody(raw: string): string {
|
|
515
|
+
const lines = raw.split('\n')
|
|
516
|
+
if (lines.length <= 15) return raw
|
|
517
|
+
|
|
518
|
+
let result = stripNoise(lines)
|
|
519
|
+
result = removeEmptyBlocks(result)
|
|
520
|
+
result = collapseTemplates(result)
|
|
521
|
+
result = collapseChains(result)
|
|
522
|
+
result = collapseSimpleBlocks(result)
|
|
523
|
+
result = dedent(result)
|
|
524
|
+
|
|
525
|
+
return result.join('\n')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Strip blank lines, comment-only lines, and console.* statements */
|
|
529
|
+
function stripNoise(lines: string[]): string[] {
|
|
530
|
+
const out: string[] = []
|
|
531
|
+
let inBlock = false
|
|
532
|
+
|
|
533
|
+
for (const line of lines) {
|
|
534
|
+
const t = line.trim()
|
|
535
|
+
|
|
536
|
+
// Track block comments
|
|
537
|
+
if (inBlock) {
|
|
538
|
+
if (t.includes('*/')) inBlock = false
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
if (t.startsWith('/*')) {
|
|
542
|
+
if (!t.includes('*/')) inBlock = true
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Skip blank lines
|
|
547
|
+
if (!t) continue
|
|
548
|
+
|
|
549
|
+
// Skip single-line comments (preserve TODO/FIXME/NOTE)
|
|
550
|
+
if (t.startsWith('//') && !/\b(TODO|FIXME|HACK|NOTE)\b/i.test(t)) continue
|
|
551
|
+
|
|
552
|
+
// Skip console.log/error/warn/info/debug statements
|
|
553
|
+
if (/^\s*console\.(log|error|warn|info|debug)\s*\(/.test(line)) continue
|
|
554
|
+
|
|
555
|
+
out.push(line)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return out
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Remove empty blocks left after noise stripping (empty else {}, catch {}) */
|
|
562
|
+
function removeEmptyBlocks(lines: string[]): string[] {
|
|
563
|
+
const out: string[] = []
|
|
564
|
+
let i = 0
|
|
565
|
+
|
|
566
|
+
while (i < lines.length) {
|
|
567
|
+
const t = lines[i].trim()
|
|
568
|
+
const next = i + 1 < lines.length ? lines[i + 1].trim() : ''
|
|
569
|
+
|
|
570
|
+
// "} else {" followed by "}" → just "}" (closing the if-block)
|
|
571
|
+
if (/^}\s*else\s*\{$/.test(t) && next === '}') {
|
|
572
|
+
const indent = lines[i].match(/^(\s*)/)?.[1] || ''
|
|
573
|
+
out.push(`${indent}}`)
|
|
574
|
+
i += 2
|
|
575
|
+
continue
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// "} catch (...) {" followed by "}" → "} catch (...) {}" on one line
|
|
579
|
+
if (/^}\s*catch\s*(\(.*\))?\s*\{$/.test(t) && next === '}') {
|
|
580
|
+
const indent = lines[i].match(/^(\s*)/)?.[1] || ''
|
|
581
|
+
out.push(`${indent}${t} }`)
|
|
582
|
+
i += 2
|
|
583
|
+
continue
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
out.push(lines[i])
|
|
587
|
+
i++
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return out
|
|
591
|
+
}
|
|
592
|
+
/** Collapse multi-line template literals (>5 lines) into short descriptors */
|
|
593
|
+
function collapseTemplates(lines: string[]): string[] {
|
|
594
|
+
const out: string[] = []
|
|
595
|
+
let i = 0
|
|
596
|
+
|
|
597
|
+
while (i < lines.length) {
|
|
598
|
+
const line = lines[i]
|
|
599
|
+
const t = line.trim()
|
|
600
|
+
|
|
601
|
+
// Count unescaped backticks on this line
|
|
602
|
+
const bts = (t.replace(/\\`/g, '').match(/`/g) || []).length
|
|
603
|
+
|
|
604
|
+
if (bts % 2 === 1) {
|
|
605
|
+
// Odd count → opens a multi-line template literal
|
|
606
|
+
const start = i
|
|
607
|
+
const collected: string[] = [t]
|
|
608
|
+
i++
|
|
609
|
+
|
|
610
|
+
while (i < lines.length) {
|
|
611
|
+
const tl = lines[i].trim()
|
|
612
|
+
collected.push(tl)
|
|
613
|
+
const tlBts = (tl.replace(/\\`/g, '').match(/`/g) || []).length
|
|
614
|
+
if (tlBts % 2 === 1) { i++; break }
|
|
615
|
+
i++
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Only collapse if the template is large (>5 lines)
|
|
619
|
+
if (collected.length > 5) {
|
|
620
|
+
const content = collected.join('\n')
|
|
621
|
+
const desc = describeTemplate(content)
|
|
622
|
+
const indent = line.match(/^(\s*)/)?.[1] || ''
|
|
623
|
+
const btIdx = t.indexOf('`')
|
|
624
|
+
const prefix = btIdx >= 0 ? t.substring(0, btIdx) : ''
|
|
625
|
+
out.push(`${indent}${prefix}[template: ${desc}]`)
|
|
626
|
+
} else {
|
|
627
|
+
// Small template — keep original lines
|
|
628
|
+
for (let j = start; j < i; j++) out.push(lines[j])
|
|
629
|
+
}
|
|
630
|
+
continue
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
out.push(line)
|
|
634
|
+
i++
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return out
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Analyze template content and produce a short description */
|
|
641
|
+
function describeTemplate(content: string): string {
|
|
642
|
+
const lower = content.toLowerCase()
|
|
643
|
+
const f: string[] = []
|
|
644
|
+
|
|
645
|
+
if (lower.includes('<!doctype') || lower.includes('<html')) f.push('HTML page')
|
|
646
|
+
else if (lower.includes('<div') || lower.includes('<span')) f.push('HTML fragment')
|
|
647
|
+
if (lower.includes('<style>') || lower.includes('font-family')) f.push('with CSS')
|
|
648
|
+
if (lower.includes('<script>')) f.push('with JS')
|
|
649
|
+
if (/\bselect\b|\binsert\b|\bupdate\b.*\bset\b/i.test(content)) f.push('SQL query')
|
|
650
|
+
|
|
651
|
+
const interps = (content.match(/\$\{/g) || []).length
|
|
652
|
+
if (interps > 0) f.push(`${interps} vars`)
|
|
653
|
+
|
|
654
|
+
return f.length > 0 ? f.join(', ') : `${content.split('\n').length}-line string`
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Collapse 3+ consecutive .replace() lines into a summary */
|
|
658
|
+
function collapseChains(lines: string[]): string[] {
|
|
659
|
+
const out: string[] = []
|
|
660
|
+
let i = 0
|
|
661
|
+
|
|
662
|
+
while (i < lines.length) {
|
|
663
|
+
if (lines[i].trim().includes('.replace(')) {
|
|
664
|
+
const start = i
|
|
665
|
+
let count = 0
|
|
666
|
+
while (i < lines.length && lines[i].trim().includes('.replace(')) {
|
|
667
|
+
count++
|
|
668
|
+
i++
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (count >= 3) {
|
|
672
|
+
const indent = lines[start].match(/^(\s*)/)?.[1] || ''
|
|
673
|
+
// If the previous line is the chain's assignment target (no semicolon), merge
|
|
674
|
+
if (out.length > 0) {
|
|
675
|
+
const prev = out[out.length - 1].trimEnd()
|
|
676
|
+
if (!prev.endsWith(';') && !prev.endsWith('{') && !prev.endsWith('}')) {
|
|
677
|
+
out[out.length - 1] = `${prev} [${count}x .replace() chain]`
|
|
678
|
+
continue
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
out.push(`${indent}[${count}x .replace() chain]`)
|
|
682
|
+
} else {
|
|
683
|
+
for (let j = start; j < i; j++) out.push(lines[j])
|
|
684
|
+
}
|
|
685
|
+
continue
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
out.push(lines[i])
|
|
689
|
+
i++
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return out
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Collapse single-statement if/else blocks (3 lines → 1 line) */
|
|
696
|
+
function collapseSimpleBlocks(lines: string[]): string[] {
|
|
697
|
+
const out: string[] = []
|
|
698
|
+
let i = 0
|
|
699
|
+
|
|
700
|
+
while (i < lines.length) {
|
|
701
|
+
const t = lines[i].trim()
|
|
702
|
+
|
|
703
|
+
// Match: if (...) { or else if (...) { or else {
|
|
704
|
+
if (/^(if\s*\(.*\)|else\s+if\s*\(.*\)|else)\s*\{\s*$/.test(t) && i + 2 < lines.length) {
|
|
705
|
+
const body = lines[i + 1].trim()
|
|
706
|
+
const close = lines[i + 2].trim()
|
|
707
|
+
|
|
708
|
+
// Only collapse if the next line is a single statement and line after is closing }
|
|
709
|
+
if (close === '}' && !body.startsWith('if') && !body.startsWith('for') &&
|
|
710
|
+
!body.startsWith('while') && !body.startsWith('switch')) {
|
|
711
|
+
const indent = lines[i].match(/^(\s*)/)?.[1] || ''
|
|
712
|
+
out.push(`${indent}${t} ${body} }`)
|
|
713
|
+
i += 3
|
|
714
|
+
continue
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
out.push(lines[i])
|
|
719
|
+
i++
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
return out
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Remove common leading indentation */
|
|
726
|
+
function dedent(lines: string[]): string[] {
|
|
727
|
+
let min = Infinity
|
|
728
|
+
for (const l of lines) {
|
|
729
|
+
const m = l.match(/^(\s+)\S/)
|
|
730
|
+
if (m && m[1].length < min) min = m[1].length
|
|
731
|
+
}
|
|
732
|
+
if (min === Infinity || min <= 0) return lines
|
|
733
|
+
|
|
734
|
+
return lines.map(l => {
|
|
735
|
+
if (!l.trim()) return l
|
|
736
|
+
const spaces = l.length - l.trimStart().length
|
|
737
|
+
return l.substring(Math.min(min, spaces))
|
|
738
|
+
})
|
|
399
739
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { ContextBuilder } from './context-builder.js'
|
|
2
2
|
export { ClaudeMdGenerator } from './claude-md-generator.js'
|
|
3
|
+
export type { ProjectMeta } from './claude-md-generator.js'
|
|
3
4
|
export { ClaudeProvider, GenericProvider, getProvider } from './providers.js'
|
|
4
5
|
export type { AIContext, ContextModule, ContextFunction, ContextQuery, ContextProvider } from './types.js'
|
package/src/providers.ts
CHANGED
|
@@ -51,7 +51,14 @@ export class ClaudeProvider implements ContextProvider {
|
|
|
51
51
|
const calledBy = fn.calledBy.length > 0
|
|
52
52
|
? ` calledBy="${esc(fn.calledBy.join(','))}"`
|
|
53
53
|
: ''
|
|
54
|
-
|
|
54
|
+
// Rich signature attributes
|
|
55
|
+
const asyncAttr = fn.isAsync ? ' async="true"' : ''
|
|
56
|
+
const exportedAttr = fn.isExported ? ' exported="true"' : ''
|
|
57
|
+
const retAttr = fn.returnType ? ` returns="${esc(fn.returnType)}"` : ''
|
|
58
|
+
const paramStr = fn.params && fn.params.length > 0
|
|
59
|
+
? ` params="${esc(fn.params.map(p => `${p.name}${p.optional ? '?' : ''}: ${p.type}`).join(', '))}"`
|
|
60
|
+
: ''
|
|
61
|
+
lines.push(` <fn name="${esc(fn.name)}" file="${esc(fn.file)}" lines="${fn.startLine}-${fn.endLine}"${asyncAttr}${exportedAttr}${paramStr}${retAttr}${calls}${calledBy}>`)
|
|
55
62
|
if (fn.purpose) lines.push(` <purpose>${esc(fn.purpose)}</purpose>`)
|
|
56
63
|
if (fn.edgeCases && fn.edgeCases.length > 0) {
|
|
57
64
|
lines.push(` <edge_cases>${esc(fn.edgeCases.join('; '))}</edge_cases>`)
|
|
@@ -59,6 +66,11 @@ export class ClaudeProvider implements ContextProvider {
|
|
|
59
66
|
if (fn.errorHandling && fn.errorHandling.length > 0) {
|
|
60
67
|
lines.push(` <error_handling>${esc(fn.errorHandling.join('; '))}</error_handling>`)
|
|
61
68
|
}
|
|
69
|
+
if (fn.body) {
|
|
70
|
+
lines.push(` <body>`)
|
|
71
|
+
lines.push(esc(fn.body))
|
|
72
|
+
lines.push(` </body>`)
|
|
73
|
+
}
|
|
62
74
|
lines.push(' </fn>')
|
|
63
75
|
}
|
|
64
76
|
lines.push(' </functions>')
|
|
@@ -67,6 +79,36 @@ export class ClaudeProvider implements ContextProvider {
|
|
|
67
79
|
lines.push('')
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
// ── Context files (schemas, data models, config) ───────────────────
|
|
83
|
+
if (context.contextFiles && context.contextFiles.length > 0) {
|
|
84
|
+
lines.push('<context_files>')
|
|
85
|
+
for (const cf of context.contextFiles) {
|
|
86
|
+
lines.push(` <file path="${esc(cf.path)}" type="${esc(cf.type)}">`)
|
|
87
|
+
// Trim to ~2000 chars per file to stay within token budget
|
|
88
|
+
const maxChars = 2000
|
|
89
|
+
if (cf.content.length > maxChars) {
|
|
90
|
+
lines.push(esc(cf.content.slice(0, maxChars)))
|
|
91
|
+
lines.push(`... (truncated)`)
|
|
92
|
+
} else {
|
|
93
|
+
lines.push(esc(cf.content.trimEnd()))
|
|
94
|
+
}
|
|
95
|
+
lines.push(' </file>')
|
|
96
|
+
}
|
|
97
|
+
lines.push('</context_files>')
|
|
98
|
+
lines.push('')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Routes (HTTP endpoints) ────────────────────────────────────────
|
|
102
|
+
if (context.routes && context.routes.length > 0) {
|
|
103
|
+
lines.push('<routes>')
|
|
104
|
+
for (const r of context.routes) {
|
|
105
|
+
const mw = r.middlewares.length > 0 ? ` middlewares="${esc(r.middlewares.join(','))}"` : ''
|
|
106
|
+
lines.push(` <route method="${esc(r.method)}" path="${esc(r.path)}" handler="${esc(r.handler)}" file="${esc(r.file)}" line="${r.line}"${mw}/>`)
|
|
107
|
+
}
|
|
108
|
+
lines.push('</routes>')
|
|
109
|
+
lines.push('')
|
|
110
|
+
}
|
|
111
|
+
|
|
70
112
|
// ── Constraints ────────────────────────────────────────────────────
|
|
71
113
|
if (context.constraints.length > 0) {
|
|
72
114
|
lines.push('<constraints>')
|
|
@@ -121,8 +163,32 @@ export class CompactProvider implements ContextProvider {
|
|
|
121
163
|
for (const mod of context.modules) {
|
|
122
164
|
lines.push(`## ${mod.name}`)
|
|
123
165
|
for (const fn of mod.functions) {
|
|
166
|
+
const asyncStr = fn.isAsync ? 'async ' : ''
|
|
167
|
+
const params = fn.params?.map(p => `${p.name}: ${p.type}`).join(', ') || ''
|
|
168
|
+
const retStr = fn.returnType ? `: ${fn.returnType}` : ''
|
|
124
169
|
const calls = fn.calls.length > 0 ? ` → ${fn.calls.join(',')}` : ''
|
|
125
|
-
lines.push(` ${fn.name} [${fn.file}:${fn.startLine}]${calls}`)
|
|
170
|
+
lines.push(` ${asyncStr}${fn.name}(${params})${retStr} [${fn.file}:${fn.startLine}]${calls}`)
|
|
171
|
+
if (fn.body) {
|
|
172
|
+
lines.push(' ```')
|
|
173
|
+
lines.push(fn.body)
|
|
174
|
+
lines.push(' ```')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
lines.push('')
|
|
178
|
+
}
|
|
179
|
+
if (context.contextFiles && context.contextFiles.length > 0) {
|
|
180
|
+
lines.push('## Schemas & Data Models')
|
|
181
|
+
for (const cf of context.contextFiles) {
|
|
182
|
+
lines.push(`### ${cf.path} (${cf.type})`)
|
|
183
|
+
lines.push(cf.content.length > 1500 ? cf.content.slice(0, 1500) + '\n...(truncated)' : cf.content.trimEnd())
|
|
184
|
+
lines.push('')
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (context.routes && context.routes.length > 0) {
|
|
188
|
+
lines.push('## Routes')
|
|
189
|
+
for (const r of context.routes) {
|
|
190
|
+
const mw = r.middlewares.length > 0 ? ` [${r.middlewares.join(', ')}]` : ''
|
|
191
|
+
lines.push(` ${r.method} ${r.path} → ${r.handler}${mw} (${r.file}:${r.line})`)
|
|
126
192
|
}
|
|
127
193
|
lines.push('')
|
|
128
194
|
}
|
package/src/types.ts
CHANGED
|
@@ -12,6 +12,10 @@ export interface AIContext {
|
|
|
12
12
|
modules: ContextModule[]
|
|
13
13
|
constraints: string[]
|
|
14
14
|
decisions: { title: string; reason: string }[]
|
|
15
|
+
/** Discovered schema/config/model files included verbatim */
|
|
16
|
+
contextFiles?: { path: string; content: string; type: string }[]
|
|
17
|
+
/** Detected HTTP route registrations */
|
|
18
|
+
routes?: { method: string; path: string; handler: string; middlewares: string[]; file: string; line: number }[]
|
|
15
19
|
prompt: string
|
|
16
20
|
/** Diagnostic info — helpful for debugging context quality */
|
|
17
21
|
meta: {
|
|
@@ -39,9 +43,15 @@ export interface ContextFunction {
|
|
|
39
43
|
endLine: number
|
|
40
44
|
calls: string[]
|
|
41
45
|
calledBy: string[]
|
|
46
|
+
params?: { name: string; type: string; optional?: boolean }[]
|
|
47
|
+
returnType?: string
|
|
48
|
+
isAsync?: boolean
|
|
49
|
+
isExported?: boolean
|
|
42
50
|
purpose?: string
|
|
43
51
|
errorHandling?: string[]
|
|
44
52
|
edgeCases?: string[]
|
|
53
|
+
/** The actual source code body (only included for top-scored functions) */
|
|
54
|
+
body?: string
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
/** Query options for context generation */
|
|
@@ -60,6 +70,10 @@ export interface ContextQuery {
|
|
|
60
70
|
tokenBudget?: number
|
|
61
71
|
/** Include call graph arrows (default true) */
|
|
62
72
|
includeCallGraph?: boolean
|
|
73
|
+
/** Include function bodies for top-scored functions (default true) */
|
|
74
|
+
includeBodies?: boolean
|
|
75
|
+
/** Absolute filesystem path to the project root (needed for body reading) */
|
|
76
|
+
projectRoot?: string
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/** Context provider interface for different AI platforms */
|