feat(ipuaro): add impact score to initial context

Add High Impact Files section to initial context showing which files
are most critical based on percentage of codebase that depends on them.

Changes:
- Add impactScore field to FileMeta (0-100)
- Add calculateImpactScore() helper function
- Update MetaAnalyzer to compute impact scores
- Add formatHighImpactFiles() to prompts.ts
- Add includeHighImpactFiles config option (default: true)
- 28 new tests (1826 total)
This commit is contained in:
imfozilbek
2025-12-05 15:43:24 +05:00
parent d6d15dd271
commit e9aaa708fe
9 changed files with 606 additions and 12 deletions

View File

@@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.29.0] - 2025-12-05 - Impact Score
### Added
- **High Impact Files in Initial Context (v0.29.0)**
- New `## High Impact Files` section in initial context
- Shows files with highest impact scores (percentage of codebase depending on them)
- Table format with File, Impact %, and Dependents count
- Files sorted by impact score descending
- Default: shows top 10 files with impact score >= 5%
- **Impact Score Computation**
- New `impactScore: number` field in `FileMeta` (0-100)
- Formula: `(dependents.length / (totalFiles - 1)) * 100`
- Computed in `MetaAnalyzer.analyzeAll()` after all files analyzed
- New `calculateImpactScore()` helper function in FileMeta.ts
- **Configuration Option**
- `includeHighImpactFiles: boolean` in ContextConfigSchema (default: `true`)
- `includeHighImpactFiles` option in `BuildContextOptions`
- Users can disable to save tokens: `context.includeHighImpactFiles: false`
- **New Helper Function in prompts.ts**
- `formatHighImpactFiles()` - formats high impact files table for display
### New Context Format
```
## High Impact Files
| File | Impact | Dependents |
|------|--------|------------|
| utils/validation | 67% | 12 files |
| types/user | 45% | 8 files |
| services/user | 34% | 6 files |
```
### Technical Details
- Total tests: 1826 passed (was 1798, +28 new tests)
- 9 new tests for calculateImpactScore()
- 14 new tests for formatHighImpactFiles() and buildInitialContext
- 5 new tests for includeHighImpactFiles config option
- Coverage: 97.52% lines, 91.3% branches, 98.63% functions
- 0 ESLint errors, 3 warnings (pre-existing complexity)
- Build successful
### Notes
This completes v0.29.0 of the Graph Metrics milestone:
- ✅ 0.27.0 - Inline Dependency Graph
- ✅ 0.28.0 - Circular Dependencies in Context
- ✅ 0.29.0 - Impact Score
Next milestone: v0.30.0 - Transitive Dependencies Count
---
## [0.28.0] - 2025-12-05 - Circular Dependencies in Context
### Added

View File

@@ -1950,10 +1950,10 @@ Enhance initial context for LLM: add function signatures, interface field types,
---
## Version 0.29.0 - Impact Score 📈
## Version 0.29.0 - Impact Score 📈
**Priority:** MEDIUM
**Status:** Planned
**Status:** Complete (v0.29.0 released)
### Description
@@ -1972,10 +1972,16 @@ Enhance initial context for LLM: add function signatures, interface field types,
```
**Changes:**
- [ ] Add `impactScore: number` to FileMeta (0-100)
- [ ] Compute in MetaAnalyzer: (transitiveDepByCount / totalFiles) * 100
- [ ] Add `formatHighImpactFiles()` to prompts.ts
- [ ] Show top-10 high impact files
- [x] Add `impactScore: number` to FileMeta (0-100)
- [x] Compute in MetaAnalyzer: (dependents.length / (totalFiles - 1)) * 100
- [x] Add `formatHighImpactFiles()` to prompts.ts
- [x] Show top-10 high impact files
- [x] Add `includeHighImpactFiles` config option (default: true)
**Tests:**
- [x] Unit tests for calculateImpactScore (9 tests)
- [x] Unit tests for formatHighImpactFiles (14 tests)
- [x] Unit tests for includeHighImpactFiles config (5 tests)
**Why:** LLM understands which files are critical for changes.
@@ -2022,12 +2028,12 @@ interface FileMeta {
- [x] Error handling complete ✅ (v0.16.0)
- [ ] Performance optimized
- [x] Documentation complete ✅ (v0.17.0)
- [x] Test coverage ≥91% branches, ≥95% lines/functions/statements ✅ (91.13% branches, 97.48% lines, 98.63% functions, 97.48% statements - 1798 tests)
- [x] Test coverage ≥91% branches, ≥95% lines/functions/statements ✅ (91.3% branches, 97.52% lines, 98.63% functions, 97.52% statements - 1826 tests)
- [x] 0 ESLint errors ✅
- [x] Examples working ✅ (v0.18.0)
- [x] CHANGELOG.md up to date ✅
- [x] Rich initial context (v0.24.0-v0.26.0) — function signatures, interface fields, enum values, decorators ✅
- [ ] Graph metrics in context (v0.27.0-v0.30.0) — dependency graph ✅, circular deps ✅, impact score, transitive deps
- [ ] Graph metrics in context (v0.27.0-v0.30.0) — dependency graph ✅, circular deps ✅, impact score, transitive deps
---
@@ -2106,7 +2112,7 @@ sessions:list # List<session_id>
**Last Updated:** 2025-12-05
**Target Version:** 1.0.0
**Current Version:** 0.28.0
**Next Milestones:** v0.29.0 (Impact Score), v0.30.0 (Transitive Deps)
**Current Version:** 0.29.0
**Next Milestones:** v0.30.0 (Transitive Deps), v1.0.0 (Production Ready)
> **Note:** Rich Initial Context complete ✅ (v0.24.0-v0.26.0). Graph Metrics in progress (v0.27.0 ✅, v0.28.0 ✅, v0.29.0-v0.30.0 pending) for 1.0.0 release.
> **Note:** Rich Initial Context complete ✅ (v0.24.0-v0.26.0). Graph Metrics in progress (v0.27.0 ✅, v0.28.0 ✅, v0.29.0 ✅, v0.30.0 pending) for 1.0.0 release.

View File

@@ -26,6 +26,8 @@ export interface FileMeta {
isEntryPoint: boolean
/** File type classification */
fileType: "source" | "test" | "config" | "types" | "unknown"
/** Impact score (0-100): percentage of codebase that depends on this file */
impactScore: number
}
export function createFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
@@ -41,6 +43,7 @@ export function createFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
isHub: false,
isEntryPoint: false,
fileType: "unknown",
impactScore: 0,
...partial,
}
}
@@ -48,3 +51,20 @@ export function createFileMeta(partial: Partial<FileMeta> = {}): FileMeta {
export function isHubFile(dependentCount: number): boolean {
return dependentCount > 5
}
/**
* Calculate impact score based on number of dependents and total files.
* Impact score represents what percentage of the codebase depends on this file.
* @param dependentCount - Number of files that depend on this file
* @param totalFiles - Total number of files in the project
* @returns Impact score from 0 to 100
*/
export function calculateImpactScore(dependentCount: number, totalFiles: number): number {
if (totalFiles <= 1) {
return 0
}
// Exclude the file itself from the total
const maxPossibleDependents = totalFiles - 1
const score = (dependentCount / maxPossibleDependents) * 100
return Math.round(Math.min(100, score))
}

View File

@@ -1,5 +1,6 @@
import * as path from "node:path"
import {
calculateImpactScore,
type ComplexityMetrics,
createFileMeta,
type FileMeta,
@@ -430,6 +431,7 @@ export class MetaAnalyzer {
/**
* Batch analyze multiple files.
* Computes impact scores after all files are analyzed.
*/
analyzeAll(files: Map<string, { ast: FileAST; content: string }>): Map<string, FileMeta> {
const allASTs = new Map<string, FileAST>()
@@ -443,6 +445,12 @@ export class MetaAnalyzer {
results.set(filePath, meta)
}
// Compute impact scores now that we know total file count
const totalFiles = results.size
for (const [, meta] of results) {
meta.impactScore = calculateImpactScore(meta.dependents.length, totalFiles)
}
return results
}
}

View File

@@ -18,6 +18,7 @@ export interface BuildContextOptions {
includeSignatures?: boolean
includeDepsGraph?: boolean
includeCircularDeps?: boolean
includeHighImpactFiles?: boolean
circularDeps?: string[][]
}
@@ -132,6 +133,7 @@ export function buildInitialContext(
const includeSignatures = options?.includeSignatures ?? true
const includeDepsGraph = options?.includeDepsGraph ?? true
const includeCircularDeps = options?.includeCircularDeps ?? true
const includeHighImpactFiles = options?.includeHighImpactFiles ?? true
sections.push(formatProjectHeader(structure))
sections.push(formatDirectoryTree(structure))
@@ -144,6 +146,13 @@ export function buildInitialContext(
}
}
if (includeHighImpactFiles && metas && metas.size > 0) {
const highImpactSection = formatHighImpactFiles(metas)
if (highImpactSection) {
sections.push(highImpactSection)
}
}
if (includeCircularDeps && options?.circularDeps && options.circularDeps.length > 0) {
const circularDepsSection = formatCircularDeps(options.circularDeps)
if (circularDepsSection) {
@@ -568,6 +577,74 @@ export function formatCircularDeps(cycles: string[][]): string | null {
return lines.join("\n")
}
/**
* Format high impact files table for display in context.
* Shows files with highest impact scores (most dependents).
*
* Format:
* ## High Impact Files
* | File | Impact | Dependents |
* |------|--------|------------|
* | src/utils/validation.ts | 67% | 12 files |
*
* @param metas - Map of file paths to their metadata
* @param limit - Maximum number of files to show (default: 10)
* @param minImpact - Minimum impact score to include (default: 5)
*/
export function formatHighImpactFiles(
metas: Map<string, FileMeta>,
limit = 10,
minImpact = 5,
): string | null {
if (metas.size === 0) {
return null
}
// Collect files with impact score >= minImpact
const impactFiles: { path: string; impact: number; dependents: number }[] = []
for (const [path, meta] of metas) {
if (meta.impactScore >= minImpact) {
impactFiles.push({
path,
impact: meta.impactScore,
dependents: meta.dependents.length,
})
}
}
if (impactFiles.length === 0) {
return null
}
// Sort by impact score descending, then by path
impactFiles.sort((a, b) => {
if (a.impact !== b.impact) {
return b.impact - a.impact
}
return a.path.localeCompare(b.path)
})
// Take top N files
const topFiles = impactFiles.slice(0, limit)
const lines: string[] = [
"## High Impact Files",
"",
"| File | Impact | Dependents |",
"|------|--------|------------|",
]
for (const file of topFiles) {
const shortPath = shortenPath(file.path)
const impact = `${String(file.impact)}%`
const dependents = file.dependents === 1 ? "1 file" : `${String(file.dependents)} files`
lines.push(`| ${shortPath} | ${impact} | ${dependents} |`)
}
return lines.join("\n")
}
/**
* Format line range for display.
*/

View File

@@ -117,6 +117,7 @@ export const ContextConfigSchema = z.object({
includeSignatures: z.boolean().default(true),
includeDepsGraph: z.boolean().default(true),
includeCircularDeps: z.boolean().default(true),
includeHighImpactFiles: z.boolean().default(true),
})
/**

View File

@@ -1,5 +1,9 @@
import { describe, it, expect } from "vitest"
import { createFileMeta, isHubFile } from "../../../../src/domain/value-objects/FileMeta.js"
import {
calculateImpactScore,
createFileMeta,
isHubFile,
} from "../../../../src/domain/value-objects/FileMeta.js"
describe("FileMeta", () => {
describe("createFileMeta", () => {
@@ -15,6 +19,7 @@ describe("FileMeta", () => {
expect(meta.isHub).toBe(false)
expect(meta.isEntryPoint).toBe(false)
expect(meta.fileType).toBe("unknown")
expect(meta.impactScore).toBe(0)
})
it("should merge partial values", () => {
@@ -42,4 +47,51 @@ describe("FileMeta", () => {
expect(isHubFile(0)).toBe(false)
})
})
describe("calculateImpactScore", () => {
it("should return 0 for file with 0 dependents", () => {
expect(calculateImpactScore(0, 10)).toBe(0)
})
it("should return 0 when totalFiles is 0", () => {
expect(calculateImpactScore(5, 0)).toBe(0)
})
it("should return 0 when totalFiles is 1", () => {
expect(calculateImpactScore(0, 1)).toBe(0)
})
it("should calculate correct percentage", () => {
// 5 dependents out of 10 files (excluding itself = 9 possible)
// 5/9 * 100 = 55.56 → rounded to 56
expect(calculateImpactScore(5, 10)).toBe(56)
})
it("should return 100 when all other files depend on it", () => {
// 9 dependents out of 10 files (9 possible dependents)
expect(calculateImpactScore(9, 10)).toBe(100)
})
it("should cap at 100", () => {
// Edge case: more dependents than possible (shouldn't happen normally)
expect(calculateImpactScore(20, 10)).toBe(100)
})
it("should round the percentage", () => {
// 1 dependent out of 3 files (2 possible)
// 1/2 * 100 = 50
expect(calculateImpactScore(1, 3)).toBe(50)
})
it("should calculate impact for small projects", () => {
// 1 dependent out of 2 files (1 possible)
expect(calculateImpactScore(1, 2)).toBe(100)
})
it("should calculate impact for larger projects", () => {
// 50 dependents out of 100 files (99 possible)
// 50/99 * 100 = 50.51 → rounded to 51
expect(calculateImpactScore(50, 100)).toBe(51)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
truncateContext,
formatDependencyGraph,
formatCircularDeps,
formatHighImpactFiles,
type ProjectStructure,
} from "../../../../src/infrastructure/llm/prompts.js"
import type { FileAST } from "../../../../src/domain/value-objects/FileAST.js"
@@ -2395,6 +2396,345 @@ describe("prompts", () => {
})
})
describe("high impact files (0.29.0)", () => {
describe("formatHighImpactFiles", () => {
it("should return null for empty metas", () => {
const metas = new Map<string, FileMeta>()
const result = formatHighImpactFiles(metas)
expect(result).toBeNull()
})
it("should return null when no files have impact score >= minImpact", () => {
const metas = new Map<string, FileMeta>([
[
"src/low.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 2,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).toBeNull()
})
it("should format file with high impact score", () => {
const metas = new Map<string, FileMeta>([
[
"src/utils/validation.ts",
{
complexity: { loc: 50, nesting: 2, cyclomaticComplexity: 5, score: 30 },
dependencies: [],
dependents: [
"a.ts",
"b.ts",
"c.ts",
"d.ts",
"e.ts",
"f.ts",
"g.ts",
"h.ts",
"i.ts",
"j.ts",
"k.ts",
"l.ts",
],
isHub: true,
isEntryPoint: false,
fileType: "source",
impactScore: 67,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).toContain("## High Impact Files")
expect(result).toContain("| File | Impact | Dependents |")
expect(result).toContain("| utils/validation | 67% | 12 files |")
})
it("should sort by impact score descending", () => {
const metas = new Map<string, FileMeta>([
[
"src/low.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 10,
},
],
[
"src/high.ts",
{
complexity: { loc: 20, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts", "c.ts", "d.ts", "e.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 50,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).not.toBeNull()
const lines = result!.split("\n")
const highIndex = lines.findIndex((l) => l.includes("high"))
const lowIndex = lines.findIndex((l) => l.includes("low"))
expect(highIndex).toBeLessThan(lowIndex)
})
it("should limit to top N files", () => {
const metas = new Map<string, FileMeta>()
for (let i = 0; i < 20; i++) {
metas.set(`src/file${String(i)}.ts`, {
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 10 + i,
})
}
const result = formatHighImpactFiles(metas, 5)
expect(result).not.toBeNull()
const dataLines = result!
.split("\n")
.filter((l) => l.startsWith("| ") && l.includes("%"))
expect(dataLines).toHaveLength(5)
})
it("should filter by minImpact", () => {
const metas = new Map<string, FileMeta>([
[
"src/high.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts", "c.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 30,
},
],
[
"src/low.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 5,
},
],
])
const result = formatHighImpactFiles(metas, 10, 20)
expect(result).not.toBeNull()
expect(result).toContain("high")
expect(result).not.toContain("low")
})
it("should show singular 'file' for 1 dependent", () => {
const metas = new Map<string, FileMeta>([
[
"src/single.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 10,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).toContain("1 file")
expect(result).not.toContain("1 files")
})
it("should shorten src/ prefix", () => {
const metas = new Map<string, FileMeta>([
[
"src/services/user.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 20,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).toContain("services/user")
expect(result).not.toContain("src/")
})
it("should remove file extensions", () => {
const metas = new Map<string, FileMeta>([
[
"lib/utils.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts"],
isHub: false,
isEntryPoint: false,
fileType: "source",
impactScore: 20,
},
],
])
const result = formatHighImpactFiles(metas)
expect(result).toContain("lib/utils")
expect(result).not.toContain(".ts")
})
})
describe("buildInitialContext with includeHighImpactFiles", () => {
const structure: ProjectStructure = {
name: "test-project",
rootPath: "/test",
files: ["src/index.ts"],
directories: ["src"],
}
const asts = new Map<string, FileAST>([
[
"src/index.ts",
{
imports: [],
exports: [],
functions: [],
classes: [],
interfaces: [],
typeAliases: [],
parseError: false,
},
],
])
it("should include high impact files by default", () => {
const metas = new Map<string, FileMeta>([
[
"src/index.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts"],
isHub: false,
isEntryPoint: true,
fileType: "source",
impactScore: 20,
},
],
])
const context = buildInitialContext(structure, asts, metas)
expect(context).toContain("## High Impact Files")
})
it("should exclude high impact files when includeHighImpactFiles is false", () => {
const metas = new Map<string, FileMeta>([
[
"src/index.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: ["a.ts", "b.ts"],
isHub: false,
isEntryPoint: true,
fileType: "source",
impactScore: 20,
},
],
])
const context = buildInitialContext(structure, asts, metas, {
includeHighImpactFiles: false,
})
expect(context).not.toContain("## High Impact Files")
})
it("should not include high impact files when metas is undefined", () => {
const context = buildInitialContext(structure, asts, undefined, {
includeHighImpactFiles: true,
})
expect(context).not.toContain("## High Impact Files")
})
it("should not include high impact files when metas is empty", () => {
const emptyMetas = new Map<string, FileMeta>()
const context = buildInitialContext(structure, asts, emptyMetas, {
includeHighImpactFiles: true,
})
expect(context).not.toContain("## High Impact Files")
})
it("should not include high impact files when no files have high impact", () => {
const metas = new Map<string, FileMeta>([
[
"src/index.ts",
{
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
dependencies: [],
dependents: [],
isHub: false,
isEntryPoint: true,
fileType: "source",
impactScore: 0,
},
],
])
const context = buildInitialContext(structure, asts, metas, {
includeHighImpactFiles: true,
})
expect(context).not.toContain("## High Impact Files")
})
})
})
describe("circular dependencies (0.28.0)", () => {
describe("formatCircularDeps", () => {
it("should return null for empty array", () => {

View File

@@ -18,6 +18,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
})
})
@@ -32,6 +33,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
})
})
})
@@ -171,6 +173,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
})
})
@@ -187,6 +190,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
})
})
@@ -204,6 +208,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
})
})
})
@@ -218,6 +223,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: false,
includeDepsGraph: false,
includeCircularDeps: false,
includeHighImpactFiles: false,
}
const result = ContextConfigSchema.parse(config)
@@ -233,6 +239,7 @@ describe("ContextConfigSchema", () => {
includeSignatures: true,
includeDepsGraph: true,
includeCircularDeps: true,
includeHighImpactFiles: true,
}
const result = ContextConfigSchema.parse(config)
@@ -314,4 +321,29 @@ describe("ContextConfigSchema", () => {
expect(() => ContextConfigSchema.parse({ includeCircularDeps: 1 })).toThrow()
})
})
describe("includeHighImpactFiles", () => {
it("should accept true", () => {
const result = ContextConfigSchema.parse({ includeHighImpactFiles: true })
expect(result.includeHighImpactFiles).toBe(true)
})
it("should accept false", () => {
const result = ContextConfigSchema.parse({ includeHighImpactFiles: false })
expect(result.includeHighImpactFiles).toBe(false)
})
it("should default to true", () => {
const result = ContextConfigSchema.parse({})
expect(result.includeHighImpactFiles).toBe(true)
})
it("should reject non-boolean", () => {
expect(() => ContextConfigSchema.parse({ includeHighImpactFiles: "true" })).toThrow()
})
it("should reject number", () => {
expect(() => ContextConfigSchema.parse({ includeHighImpactFiles: 1 })).toThrow()
})
})
})