prjct-cli 1.16.0 → 1.17.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/CHANGELOG.md +41 -1
- package/core/__tests__/domain/bm25.test.ts +225 -0
- package/core/__tests__/domain/file-ranker.test.ts +169 -0
- package/core/__tests__/domain/git-cochange.test.ts +121 -0
- package/core/__tests__/domain/import-graph.test.ts +156 -0
- package/core/agentic/smart-context.ts +33 -2
- package/core/domain/bm25.ts +525 -0
- package/core/domain/file-ranker.ts +151 -0
- package/core/domain/git-cochange.ts +250 -0
- package/core/domain/import-graph.ts +315 -0
- package/core/services/sync-service.ts +17 -0
- package/dist/bin/prjct.mjs +890 -366
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.17.0] - 2026-02-09
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- implement BM25 + import graph + git co-change for zero-cost file selection (PRJ-304) (#159)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.17.0] - 2026-02-08
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
- **BM25 + import graph + git co-change file selection** (PRJ-304): Zero-cost file selection using three mathematical signals combined into a weighted ranker. Replaces keyword matching in smart-context with precision that matches LLM-based classification — at zero API cost.
|
|
14
|
+
|
|
15
|
+
### Implementation Details
|
|
16
|
+
- `core/domain/bm25.ts` — BM25 indexer: tokenizes files (exports, functions, imports, comments, path segments), builds inverted index, scores queries using Okapi BM25 (k1=1.2, b=0.75). Stores in SQLite kv_store.
|
|
17
|
+
- `core/domain/import-graph.ts` — Import graph builder: parses TS/JS imports to build directed adjacency list, follows chains 2 levels deep, scores by proximity (1/(depth+1)).
|
|
18
|
+
- `core/domain/git-cochange.ts` — Git co-change analyzer: parses last 100 commits, builds Jaccard similarity matrix for file pairs that change together.
|
|
19
|
+
- `core/domain/file-ranker.ts` — Combined ranker: `BM25 × 0.5 + imports × 0.3 + cochange × 0.2`, normalizes each signal to [0,1], returns top 15 files.
|
|
20
|
+
- `core/agentic/smart-context.ts` — Uses ranker when indexes exist, graceful fallback to regex-based domain filtering.
|
|
21
|
+
- `core/services/sync-service.ts` — Builds all 3 indexes in parallel during `prjct sync`.
|
|
22
|
+
|
|
23
|
+
### Learnings
|
|
24
|
+
- `tokenizeQuery` must split camelCase BEFORE lowercasing — otherwise "getUserById" becomes "getuserbyid" and doesn't split
|
|
25
|
+
- Jaccard similarity > cosine for co-change because data is binary (file present or not in commit)
|
|
26
|
+
- Batch file reads (50 at a time) needed for indexing performance on large projects
|
|
27
|
+
- Stop words list must include code keywords (import, export, const) to reduce noise in scoring
|
|
28
|
+
|
|
29
|
+
### Test Plan
|
|
30
|
+
|
|
31
|
+
#### For QA
|
|
32
|
+
1. Run `prjct sync` — verify BM25, import graph, and co-change indexes build without errors
|
|
33
|
+
2. Query "Fix auth middleware" — verify auth-related files rank higher than unrelated files
|
|
34
|
+
3. Query "Build responsive dashboard" — verify frontend files rank higher than backend files
|
|
35
|
+
4. Verify index rebuild time <5 seconds on 300+ file project
|
|
36
|
+
5. Verify query time <50ms
|
|
37
|
+
6. Verify zero API calls during file selection
|
|
38
|
+
|
|
39
|
+
#### For Users
|
|
40
|
+
**What changed:** File selection during task context is now powered by BM25 text search, import graph proximity, and git co-change analysis instead of keyword matching.
|
|
41
|
+
**How to use:** Run `p. sync` to build indexes — file selection is automatic and more accurate.
|
|
42
|
+
**Breaking changes:** None. Falls back to previous filtering if indexes don't exist.
|
|
43
|
+
|
|
3
44
|
## [1.16.0] - 2026-02-09
|
|
4
45
|
|
|
5
46
|
### Features
|
|
6
47
|
|
|
7
48
|
- remove JSON storage redundancy, SQLite-only backend (PRJ-303) (#158)
|
|
8
49
|
|
|
9
|
-
|
|
10
50
|
## [1.16.0] - 2026-02-08
|
|
11
51
|
|
|
12
52
|
### Features
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for BM25 Text Search Index
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
6
|
+
import fs from 'node:fs/promises'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { buildIndex, score, tokenizeFile, tokenizeQuery } from '../../domain/bm25'
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Tokenization Tests
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
describe('BM25', () => {
|
|
16
|
+
describe('tokenizeFile', () => {
|
|
17
|
+
it('should extract path segments', () => {
|
|
18
|
+
const tokens = tokenizeFile('', 'core/domain/bm25.ts')
|
|
19
|
+
expect(tokens).toContain('core')
|
|
20
|
+
expect(tokens).toContain('domain')
|
|
21
|
+
expect(tokens).toContain('bm25')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should extract function names and split camelCase', () => {
|
|
25
|
+
const content = 'export function getUserById(id: string) { return id }'
|
|
26
|
+
const tokens = tokenizeFile(content, 'user-service.ts')
|
|
27
|
+
expect(tokens).toContain('get')
|
|
28
|
+
expect(tokens).toContain('user')
|
|
29
|
+
// 'by' and 'id' are filtered (stop word / too short)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should extract class names', () => {
|
|
33
|
+
const content = 'export class AuthMiddleware { handle() {} }'
|
|
34
|
+
const tokens = tokenizeFile(content, 'middleware.ts')
|
|
35
|
+
expect(tokens).toContain('auth')
|
|
36
|
+
expect(tokens).toContain('middleware')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('should extract interface names', () => {
|
|
40
|
+
const content = 'export interface JwtPayload { sub: string }'
|
|
41
|
+
const tokens = tokenizeFile(content, 'types.ts')
|
|
42
|
+
expect(tokens).toContain('jwt')
|
|
43
|
+
expect(tokens).toContain('payload')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('should extract import sources', () => {
|
|
47
|
+
const content = `import { Router } from './router'\nimport express from 'express'`
|
|
48
|
+
const tokens = tokenizeFile(content, 'app.ts')
|
|
49
|
+
expect(tokens).toContain('router')
|
|
50
|
+
expect(tokens).toContain('express')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should extract words from comments', () => {
|
|
54
|
+
const content = '// Handle authentication for JWT tokens'
|
|
55
|
+
const tokens = tokenizeFile(content, 'auth.ts')
|
|
56
|
+
expect(tokens).toContain('handle')
|
|
57
|
+
expect(tokens).toContain('authentication')
|
|
58
|
+
expect(tokens).toContain('jwt')
|
|
59
|
+
expect(tokens).toContain('tokens')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should extract words from JSDoc comments', () => {
|
|
63
|
+
const content = '/** Validates user session and refreshes token */'
|
|
64
|
+
const tokens = tokenizeFile(content, 'session.ts')
|
|
65
|
+
expect(tokens).toContain('validates')
|
|
66
|
+
expect(tokens).toContain('session')
|
|
67
|
+
expect(tokens).toContain('refreshes')
|
|
68
|
+
expect(tokens).toContain('token')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should filter out stop words', () => {
|
|
72
|
+
const content = 'export function getTheData() {}'
|
|
73
|
+
const tokens = tokenizeFile(content, 'data.ts')
|
|
74
|
+
expect(tokens).not.toContain('the')
|
|
75
|
+
expect(tokens).not.toContain('export')
|
|
76
|
+
expect(tokens).not.toContain('function')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should handle empty content', () => {
|
|
80
|
+
const tokens = tokenizeFile('', 'empty.ts')
|
|
81
|
+
// Should still have path segments
|
|
82
|
+
expect(tokens).toContain('empty')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('tokenizeQuery', () => {
|
|
87
|
+
it('should tokenize a task description', () => {
|
|
88
|
+
const tokens = tokenizeQuery('Fix the auth middleware for JWT validation')
|
|
89
|
+
expect(tokens).toContain('fix')
|
|
90
|
+
expect(tokens).toContain('auth')
|
|
91
|
+
expect(tokens).toContain('middleware')
|
|
92
|
+
expect(tokens).toContain('jwt')
|
|
93
|
+
expect(tokens).toContain('validation')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should split camelCase in queries', () => {
|
|
97
|
+
const tokens = tokenizeQuery('update getUserById function')
|
|
98
|
+
expect(tokens).toContain('update')
|
|
99
|
+
expect(tokens).toContain('get')
|
|
100
|
+
expect(tokens).toContain('user')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should remove stop words from queries', () => {
|
|
104
|
+
const tokens = tokenizeQuery('Fix the bug in the login')
|
|
105
|
+
expect(tokens).not.toContain('the')
|
|
106
|
+
expect(tokens).not.toContain('in')
|
|
107
|
+
expect(tokens).toContain('fix')
|
|
108
|
+
expect(tokens).toContain('bug')
|
|
109
|
+
expect(tokens).toContain('login')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// =============================================================================
|
|
114
|
+
// Index Building & Scoring Tests
|
|
115
|
+
// =============================================================================
|
|
116
|
+
|
|
117
|
+
describe('buildIndex + score', () => {
|
|
118
|
+
let testDir: string
|
|
119
|
+
|
|
120
|
+
beforeEach(async () => {
|
|
121
|
+
testDir = path.join(os.tmpdir(), `prjct-bm25-test-${Date.now()}`)
|
|
122
|
+
await fs.mkdir(testDir, { recursive: true })
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
afterEach(async () => {
|
|
126
|
+
try {
|
|
127
|
+
await fs.rm(testDir, { recursive: true, force: true })
|
|
128
|
+
} catch {
|
|
129
|
+
// Ignore cleanup errors
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should build an index from project files', async () => {
|
|
134
|
+
// Create test files
|
|
135
|
+
await fs.writeFile(
|
|
136
|
+
path.join(testDir, 'auth.ts'),
|
|
137
|
+
`export class AuthService {\n validateJwt(token: string) {}\n refreshSession() {}\n}`
|
|
138
|
+
)
|
|
139
|
+
await fs.writeFile(
|
|
140
|
+
path.join(testDir, 'middleware.ts'),
|
|
141
|
+
`import { AuthService } from './auth'\nexport function authMiddleware(req: any) {}`
|
|
142
|
+
)
|
|
143
|
+
await fs.writeFile(
|
|
144
|
+
path.join(testDir, 'button.tsx'),
|
|
145
|
+
`export function Button({ label }: { label: string }) {\n return <button>{label}</button>\n}`
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
const index = await buildIndex(testDir)
|
|
149
|
+
|
|
150
|
+
expect(index.totalDocs).toBe(3)
|
|
151
|
+
expect(index.avgDocLength).toBeGreaterThan(0)
|
|
152
|
+
expect(Object.keys(index.documents)).toContain('auth.ts')
|
|
153
|
+
expect(Object.keys(index.documents)).toContain('middleware.ts')
|
|
154
|
+
expect(Object.keys(index.documents)).toContain('button.tsx')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should rank auth files higher for auth query', async () => {
|
|
158
|
+
await fs.writeFile(
|
|
159
|
+
path.join(testDir, 'auth.ts'),
|
|
160
|
+
`export class AuthService {\n // Authenticate user with JWT\n validateJwt(token: string) {}\n refreshSession() {}\n}`
|
|
161
|
+
)
|
|
162
|
+
await fs.writeFile(
|
|
163
|
+
path.join(testDir, 'middleware.ts'),
|
|
164
|
+
`import { AuthService } from './auth'\n// Auth middleware for JWT validation\nexport function authMiddleware(req: any) {}`
|
|
165
|
+
)
|
|
166
|
+
await fs.writeFile(
|
|
167
|
+
path.join(testDir, 'button.tsx'),
|
|
168
|
+
`// Render a UI button component\nexport function Button({ label }: { label: string }) {\n return <button>{label}</button>\n}`
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const index = await buildIndex(testDir)
|
|
172
|
+
const results = score('Fix the auth middleware for JWT validation', index)
|
|
173
|
+
|
|
174
|
+
// Auth and middleware should rank higher than button
|
|
175
|
+
expect(results.length).toBeGreaterThan(0)
|
|
176
|
+
const authIndex = results.findIndex((r) => r.path === 'auth.ts')
|
|
177
|
+
const middlewareIndex = results.findIndex((r) => r.path === 'middleware.ts')
|
|
178
|
+
const buttonIndex = results.findIndex((r) => r.path === 'button.tsx')
|
|
179
|
+
|
|
180
|
+
expect(authIndex).toBeLessThan(buttonIndex === -1 ? Infinity : buttonIndex)
|
|
181
|
+
expect(middlewareIndex).toBeLessThan(buttonIndex === -1 ? Infinity : buttonIndex)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should rank frontend files higher for UI query', async () => {
|
|
185
|
+
await fs.writeFile(
|
|
186
|
+
path.join(testDir, 'dashboard.tsx'),
|
|
187
|
+
`// Responsive dashboard with charts and data grid\nexport function Dashboard() {\n return <div className="dashboard">Charts here</div>\n}`
|
|
188
|
+
)
|
|
189
|
+
await fs.writeFile(
|
|
190
|
+
path.join(testDir, 'api-handler.ts'),
|
|
191
|
+
`// Handle API requests for user data\nexport function handleRequest(req: any) { return {} }`
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const index = await buildIndex(testDir)
|
|
195
|
+
const results = score('Build responsive dashboard', index)
|
|
196
|
+
|
|
197
|
+
expect(results.length).toBeGreaterThan(0)
|
|
198
|
+
expect(results[0].path).toBe('dashboard.tsx')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should skip node_modules', async () => {
|
|
202
|
+
await fs.mkdir(path.join(testDir, 'node_modules', 'pkg'), { recursive: true })
|
|
203
|
+
await fs.writeFile(path.join(testDir, 'node_modules', 'pkg', 'index.ts'), 'export default {}')
|
|
204
|
+
await fs.writeFile(path.join(testDir, 'app.ts'), 'export function main() {}')
|
|
205
|
+
|
|
206
|
+
const index = await buildIndex(testDir)
|
|
207
|
+
expect(index.totalDocs).toBe(1)
|
|
208
|
+
expect(Object.keys(index.documents)).toContain('app.ts')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should handle empty query', async () => {
|
|
212
|
+
await fs.writeFile(path.join(testDir, 'app.ts'), 'export function main() {}')
|
|
213
|
+
|
|
214
|
+
const index = await buildIndex(testDir)
|
|
215
|
+
const results = score('', index)
|
|
216
|
+
expect(results).toEqual([])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should handle empty project', async () => {
|
|
220
|
+
const index = await buildIndex(testDir)
|
|
221
|
+
expect(index.totalDocs).toBe(0)
|
|
222
|
+
expect(score('anything', index)).toEqual([])
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Combined File Ranker
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
6
|
+
import { exec as execCallback } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs/promises'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
import { indexProject } from '../../domain/bm25'
|
|
12
|
+
import { hasIndexes, rankFiles } from '../../domain/file-ranker'
|
|
13
|
+
import { indexCoChanges } from '../../domain/git-cochange'
|
|
14
|
+
import { indexImports } from '../../domain/import-graph'
|
|
15
|
+
import pathManager from '../../infrastructure/path-manager'
|
|
16
|
+
import prjctDb from '../../storage/database'
|
|
17
|
+
|
|
18
|
+
const exec = promisify(execCallback)
|
|
19
|
+
|
|
20
|
+
describe('FileRanker', () => {
|
|
21
|
+
let testDir: string
|
|
22
|
+
let testProjectId: string
|
|
23
|
+
const originalGetGlobalProjectPath = pathManager.getGlobalProjectPath.bind(pathManager)
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
testDir = path.join(os.tmpdir(), `prjct-ranker-test-${Date.now()}`)
|
|
27
|
+
testProjectId = `test-ranker-${Date.now()}`
|
|
28
|
+
await fs.mkdir(testDir, { recursive: true })
|
|
29
|
+
|
|
30
|
+
// Mock path manager to use temp dir
|
|
31
|
+
pathManager.getGlobalProjectPath = () => testDir
|
|
32
|
+
|
|
33
|
+
// Initialize git repo
|
|
34
|
+
await exec('git init', { cwd: testDir })
|
|
35
|
+
await exec('git config user.email "test@test.com"', { cwd: testDir })
|
|
36
|
+
await exec('git config user.name "Test"', { cwd: testDir })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
pathManager.getGlobalProjectPath = originalGetGlobalProjectPath
|
|
41
|
+
prjctDb.close()
|
|
42
|
+
try {
|
|
43
|
+
await fs.rm(testDir, { recursive: true, force: true })
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('hasIndexes', () => {
|
|
50
|
+
it('should return false when no indexes exist', () => {
|
|
51
|
+
const result = hasIndexes(testProjectId)
|
|
52
|
+
expect(result.bm25).toBe(false)
|
|
53
|
+
expect(result.imports).toBe(false)
|
|
54
|
+
expect(result.cochange).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should return true after building indexes', async () => {
|
|
58
|
+
// Create a test file
|
|
59
|
+
await fs.writeFile(path.join(testDir, 'app.ts'), 'export function main() {}')
|
|
60
|
+
await exec('git add -A && git commit -m "init"', { cwd: testDir })
|
|
61
|
+
|
|
62
|
+
await indexProject(testDir, testProjectId)
|
|
63
|
+
await indexImports(testDir, testProjectId)
|
|
64
|
+
await indexCoChanges(testDir, testProjectId)
|
|
65
|
+
|
|
66
|
+
const result = hasIndexes(testProjectId)
|
|
67
|
+
expect(result.bm25).toBe(true)
|
|
68
|
+
expect(result.imports).toBe(true)
|
|
69
|
+
expect(result.cochange).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('rankFiles', () => {
|
|
74
|
+
it('should return empty array when no indexes exist', () => {
|
|
75
|
+
const result = rankFiles(testProjectId, 'anything')
|
|
76
|
+
expect(result).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should rank relevant files higher', async () => {
|
|
80
|
+
// Create auth-related files
|
|
81
|
+
await fs.writeFile(
|
|
82
|
+
path.join(testDir, 'auth.ts'),
|
|
83
|
+
`// Authentication service for JWT handling\nexport class AuthService {\n validateJwt(token: string) { return true }\n}`
|
|
84
|
+
)
|
|
85
|
+
await fs.writeFile(
|
|
86
|
+
path.join(testDir, 'middleware.ts'),
|
|
87
|
+
`import { AuthService } from './auth'\n// Auth middleware\nexport function authMiddleware() {}`
|
|
88
|
+
)
|
|
89
|
+
await fs.writeFile(
|
|
90
|
+
path.join(testDir, 'session.ts'),
|
|
91
|
+
`import { AuthService } from './auth'\n// Session management\nexport function refreshSession() {}`
|
|
92
|
+
)
|
|
93
|
+
// Create unrelated file
|
|
94
|
+
await fs.writeFile(
|
|
95
|
+
path.join(testDir, 'button.tsx'),
|
|
96
|
+
`// UI button component\nexport function Button() { return null }`
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
// Create git history with co-changes
|
|
100
|
+
await exec('git add -A && git commit -m "init"', { cwd: testDir })
|
|
101
|
+
await fs.writeFile(path.join(testDir, 'auth.ts'), `export class AuthService { v2() {} }`)
|
|
102
|
+
await fs.writeFile(
|
|
103
|
+
path.join(testDir, 'middleware.ts'),
|
|
104
|
+
`export function authMiddleware() { v2 }`
|
|
105
|
+
)
|
|
106
|
+
await exec('git add -A && git commit -m "update auth"', { cwd: testDir })
|
|
107
|
+
|
|
108
|
+
// Build all indexes
|
|
109
|
+
await indexProject(testDir, testProjectId)
|
|
110
|
+
await indexImports(testDir, testProjectId)
|
|
111
|
+
await indexCoChanges(testDir, testProjectId)
|
|
112
|
+
|
|
113
|
+
const results = rankFiles(testProjectId, 'Fix auth middleware for JWT validation')
|
|
114
|
+
|
|
115
|
+
expect(results.length).toBeGreaterThan(0)
|
|
116
|
+
|
|
117
|
+
// Auth and middleware should be in results
|
|
118
|
+
const authResult = results.find((r) => r.path === 'auth.ts')
|
|
119
|
+
const middlewareResult = results.find((r) => r.path === 'middleware.ts')
|
|
120
|
+
|
|
121
|
+
expect(authResult).toBeDefined()
|
|
122
|
+
expect(middlewareResult).toBeDefined()
|
|
123
|
+
|
|
124
|
+
// Auth should rank higher than button
|
|
125
|
+
const buttonResult = results.find((r) => r.path === 'button.tsx')
|
|
126
|
+
if (authResult && buttonResult) {
|
|
127
|
+
expect(authResult.finalScore).toBeGreaterThan(buttonResult.finalScore)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should include signal breakdown', async () => {
|
|
132
|
+
await fs.writeFile(
|
|
133
|
+
path.join(testDir, 'auth.ts'),
|
|
134
|
+
`// Authentication\nexport class AuthService {}`
|
|
135
|
+
)
|
|
136
|
+
await exec('git add -A && git commit -m "init"', { cwd: testDir })
|
|
137
|
+
|
|
138
|
+
await indexProject(testDir, testProjectId)
|
|
139
|
+
await indexImports(testDir, testProjectId)
|
|
140
|
+
await indexCoChanges(testDir, testProjectId)
|
|
141
|
+
|
|
142
|
+
const results = rankFiles(testProjectId, 'authentication')
|
|
143
|
+
|
|
144
|
+
if (results.length > 0) {
|
|
145
|
+
const first = results[0]
|
|
146
|
+
expect(first.signals).toBeDefined()
|
|
147
|
+
expect(typeof first.signals.bm25).toBe('number')
|
|
148
|
+
expect(typeof first.signals.imports).toBe('number')
|
|
149
|
+
expect(typeof first.signals.cochange).toBe('number')
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('should respect topN config', async () => {
|
|
154
|
+
// Create many files
|
|
155
|
+
for (let i = 0; i < 20; i++) {
|
|
156
|
+
await fs.writeFile(
|
|
157
|
+
path.join(testDir, `service-${i}.ts`),
|
|
158
|
+
`// Service module ${i}\nexport function service${i}() {}`
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
await exec('git add -A && git commit -m "init"', { cwd: testDir })
|
|
162
|
+
|
|
163
|
+
await indexProject(testDir, testProjectId)
|
|
164
|
+
|
|
165
|
+
const results = rankFiles(testProjectId, 'service module', { topN: 5 })
|
|
166
|
+
expect(results.length).toBeLessThanOrEqual(5)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Git Co-Change Analyzer
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
6
|
+
import { exec as execCallback } from 'node:child_process'
|
|
7
|
+
import fs from 'node:fs/promises'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
import { buildMatrix, scoreFromSeeds } from '../../domain/git-cochange'
|
|
12
|
+
|
|
13
|
+
const exec = promisify(execCallback)
|
|
14
|
+
|
|
15
|
+
describe('GitCoChange', () => {
|
|
16
|
+
let testDir: string
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
testDir = path.join(os.tmpdir(), `prjct-cochange-test-${Date.now()}`)
|
|
20
|
+
await fs.mkdir(testDir, { recursive: true })
|
|
21
|
+
|
|
22
|
+
// Initialize a git repo
|
|
23
|
+
await exec('git init', { cwd: testDir })
|
|
24
|
+
await exec('git config user.email "test@test.com"', { cwd: testDir })
|
|
25
|
+
await exec('git config user.name "Test"', { cwd: testDir })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
afterEach(async () => {
|
|
29
|
+
try {
|
|
30
|
+
await fs.rm(testDir, { recursive: true, force: true })
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore cleanup errors
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('buildMatrix', () => {
|
|
37
|
+
it('should detect co-changed files', async () => {
|
|
38
|
+
// Create files that change together
|
|
39
|
+
for (let i = 0; i < 5; i++) {
|
|
40
|
+
await fs.writeFile(path.join(testDir, 'auth.ts'), `export const v${i} = ${i}`)
|
|
41
|
+
await fs.writeFile(path.join(testDir, 'middleware.ts'), `export const v${i} = ${i}`)
|
|
42
|
+
await exec('git add -A', { cwd: testDir })
|
|
43
|
+
await exec(`git commit -m "commit ${i}"`, { cwd: testDir })
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create a file that changes independently
|
|
47
|
+
await fs.writeFile(path.join(testDir, 'unrelated.ts'), 'export const x = 1')
|
|
48
|
+
await exec('git add -A', { cwd: testDir })
|
|
49
|
+
await exec('git commit -m "unrelated"', { cwd: testDir })
|
|
50
|
+
|
|
51
|
+
const index = await buildMatrix(testDir, 100)
|
|
52
|
+
|
|
53
|
+
expect(index.commitsAnalyzed).toBeGreaterThan(0)
|
|
54
|
+
expect(index.matrix['auth.ts']).toBeDefined()
|
|
55
|
+
expect(index.matrix['auth.ts']['middleware.ts']).toBeGreaterThan(0)
|
|
56
|
+
|
|
57
|
+
// Auth and middleware should have high co-change
|
|
58
|
+
const similarity = index.matrix['auth.ts']['middleware.ts']
|
|
59
|
+
expect(similarity).toBeGreaterThan(0.5)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('should be symmetric', async () => {
|
|
63
|
+
for (let i = 0; i < 3; i++) {
|
|
64
|
+
await fs.writeFile(path.join(testDir, 'a.ts'), `const v${i} = ${i}`)
|
|
65
|
+
await fs.writeFile(path.join(testDir, 'b.ts'), `const v${i} = ${i}`)
|
|
66
|
+
await exec('git add -A', { cwd: testDir })
|
|
67
|
+
await exec(`git commit -m "commit ${i}"`, { cwd: testDir })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const index = await buildMatrix(testDir, 100)
|
|
71
|
+
|
|
72
|
+
if (index.matrix['a.ts'] && index.matrix['b.ts']) {
|
|
73
|
+
expect(index.matrix['a.ts']['b.ts']).toBe(index.matrix['b.ts']['a.ts'])
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle no git history', async () => {
|
|
78
|
+
const emptyDir = path.join(os.tmpdir(), `prjct-cochange-empty-${Date.now()}`)
|
|
79
|
+
await fs.mkdir(emptyDir, { recursive: true })
|
|
80
|
+
|
|
81
|
+
const index = await buildMatrix(emptyDir, 100)
|
|
82
|
+
expect(index.commitsAnalyzed).toBe(0)
|
|
83
|
+
expect(Object.keys(index.matrix)).toHaveLength(0)
|
|
84
|
+
|
|
85
|
+
await fs.rm(emptyDir, { recursive: true, force: true })
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('scoreFromSeeds', () => {
|
|
90
|
+
it('should score co-changed files', async () => {
|
|
91
|
+
// Create co-change history
|
|
92
|
+
for (let i = 0; i < 5; i++) {
|
|
93
|
+
await fs.writeFile(path.join(testDir, 'auth.ts'), `v${i}`)
|
|
94
|
+
await fs.writeFile(path.join(testDir, 'session.ts'), `v${i}`)
|
|
95
|
+
await exec('git add -A', { cwd: testDir })
|
|
96
|
+
await exec(`git commit -m "commit ${i}"`, { cwd: testDir })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const index = await buildMatrix(testDir, 100)
|
|
100
|
+
const scores = scoreFromSeeds(['auth.ts'], index)
|
|
101
|
+
|
|
102
|
+
const sessionScore = scores.find((s) => s.path === 'session.ts')
|
|
103
|
+
expect(sessionScore).toBeDefined()
|
|
104
|
+
expect(sessionScore!.score).toBeGreaterThan(0)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('should not include seed files in results', async () => {
|
|
108
|
+
for (let i = 0; i < 3; i++) {
|
|
109
|
+
await fs.writeFile(path.join(testDir, 'a.ts'), `v${i}`)
|
|
110
|
+
await fs.writeFile(path.join(testDir, 'b.ts'), `v${i}`)
|
|
111
|
+
await exec('git add -A', { cwd: testDir })
|
|
112
|
+
await exec(`git commit -m "commit ${i}"`, { cwd: testDir })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const index = await buildMatrix(testDir, 100)
|
|
116
|
+
const scores = scoreFromSeeds(['a.ts'], index)
|
|
117
|
+
|
|
118
|
+
expect(scores.find((s) => s.path === 'a.ts')).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
})
|