prjct-cli 1.16.0 → 1.18.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 +92 -1
- package/core/__tests__/domain/bm25.test.ts +225 -0
- package/core/__tests__/domain/change-propagator.test.ts +100 -0
- package/core/__tests__/domain/file-hasher.test.ts +146 -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/commands/analysis.ts +9 -2
- package/core/commands/command-data.ts +3 -1
- package/core/commands/commands.ts +1 -0
- package/core/domain/bm25.ts +525 -0
- package/core/domain/change-propagator.ts +162 -0
- package/core/domain/file-hasher.ts +296 -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/index.ts +1 -0
- package/core/services/sync-service.ts +133 -2
- package/core/services/watch-service.ts +1 -1
- package/core/types/index.ts +1 -0
- package/core/types/project-sync.ts +20 -0
- package/dist/bin/prjct.mjs +1238 -374
- package/package.json +1 -1
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for Import Graph Builder
|
|
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 { buildGraph, scoreFromSeeds } from '../../domain/import-graph'
|
|
10
|
+
|
|
11
|
+
describe('ImportGraph', () => {
|
|
12
|
+
let testDir: string
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
testDir = path.join(os.tmpdir(), `prjct-import-graph-test-${Date.now()}`)
|
|
16
|
+
await fs.mkdir(testDir, { recursive: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
try {
|
|
21
|
+
await fs.rm(testDir, { recursive: true, force: true })
|
|
22
|
+
} catch {
|
|
23
|
+
// Ignore cleanup errors
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('buildGraph', () => {
|
|
28
|
+
it('should build forward and reverse edges', async () => {
|
|
29
|
+
await fs.writeFile(
|
|
30
|
+
path.join(testDir, 'auth.ts'),
|
|
31
|
+
`import { validate } from './validator'\nexport class Auth {}`
|
|
32
|
+
)
|
|
33
|
+
await fs.writeFile(path.join(testDir, 'validator.ts'), `export function validate() {}`)
|
|
34
|
+
await fs.writeFile(
|
|
35
|
+
path.join(testDir, 'middleware.ts'),
|
|
36
|
+
`import { Auth } from './auth'\nexport function middleware() {}`
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const graph = await buildGraph(testDir)
|
|
40
|
+
|
|
41
|
+
// Forward: auth imports validator
|
|
42
|
+
expect(graph.forward['auth.ts']).toContain('validator.ts')
|
|
43
|
+
// Forward: middleware imports auth
|
|
44
|
+
expect(graph.forward['middleware.ts']).toContain('auth.ts')
|
|
45
|
+
|
|
46
|
+
// Reverse: validator is imported by auth
|
|
47
|
+
expect(graph.reverse['validator.ts']).toContain('auth.ts')
|
|
48
|
+
// Reverse: auth is imported by middleware
|
|
49
|
+
expect(graph.reverse['auth.ts']).toContain('middleware.ts')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should count files and edges', async () => {
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
path.join(testDir, 'a.ts'),
|
|
55
|
+
`import { b } from './b'\nimport { c } from './c'`
|
|
56
|
+
)
|
|
57
|
+
await fs.writeFile(path.join(testDir, 'b.ts'), `export const b = 1`)
|
|
58
|
+
await fs.writeFile(path.join(testDir, 'c.ts'), `export const c = 2`)
|
|
59
|
+
|
|
60
|
+
const graph = await buildGraph(testDir)
|
|
61
|
+
expect(graph.fileCount).toBe(3)
|
|
62
|
+
expect(graph.edgeCount).toBe(2)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should skip external imports', async () => {
|
|
66
|
+
await fs.writeFile(
|
|
67
|
+
path.join(testDir, 'app.ts'),
|
|
68
|
+
`import express from 'express'\nimport { helper } from './helper'`
|
|
69
|
+
)
|
|
70
|
+
await fs.writeFile(path.join(testDir, 'helper.ts'), `export function helper() {}`)
|
|
71
|
+
|
|
72
|
+
const graph = await buildGraph(testDir)
|
|
73
|
+
expect(graph.forward['app.ts']).toEqual(['helper.ts'])
|
|
74
|
+
// express should not appear
|
|
75
|
+
expect(graph.forward['app.ts']).not.toContain('express')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should skip node_modules', async () => {
|
|
79
|
+
await fs.mkdir(path.join(testDir, 'node_modules'), { recursive: true })
|
|
80
|
+
await fs.writeFile(path.join(testDir, 'node_modules', 'pkg.ts'), `export default {}`)
|
|
81
|
+
await fs.writeFile(path.join(testDir, 'main.ts'), `export function main() {}`)
|
|
82
|
+
|
|
83
|
+
const graph = await buildGraph(testDir)
|
|
84
|
+
expect(graph.fileCount).toBe(1)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('scoreFromSeeds', () => {
|
|
89
|
+
it('should score direct imports at 0.5', async () => {
|
|
90
|
+
await fs.writeFile(
|
|
91
|
+
path.join(testDir, 'auth.ts'),
|
|
92
|
+
`import { middleware } from './middleware'\nexport class Auth {}`
|
|
93
|
+
)
|
|
94
|
+
await fs.writeFile(path.join(testDir, 'middleware.ts'), `export function middleware() {}`)
|
|
95
|
+
await fs.writeFile(path.join(testDir, 'unrelated.ts'), `export function unrelated() {}`)
|
|
96
|
+
|
|
97
|
+
const graph = await buildGraph(testDir)
|
|
98
|
+
const scores = scoreFromSeeds(['auth.ts'], graph)
|
|
99
|
+
|
|
100
|
+
const middlewareScore = scores.find((s) => s.path === 'middleware.ts')
|
|
101
|
+
expect(middlewareScore).toBeDefined()
|
|
102
|
+
expect(middlewareScore!.score).toBe(0.5) // 1 / (1 + 1) = 0.5
|
|
103
|
+
expect(middlewareScore!.depth).toBe(1)
|
|
104
|
+
|
|
105
|
+
// unrelated.ts should not appear
|
|
106
|
+
const unrelatedScore = scores.find((s) => s.path === 'unrelated.ts')
|
|
107
|
+
expect(unrelatedScore).toBeUndefined()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should score 2nd-level imports at 0.33', async () => {
|
|
111
|
+
await fs.writeFile(path.join(testDir, 'auth.ts'), `import { session } from './session'`)
|
|
112
|
+
await fs.writeFile(
|
|
113
|
+
path.join(testDir, 'session.ts'),
|
|
114
|
+
`import { db } from './db'\nexport function session() {}`
|
|
115
|
+
)
|
|
116
|
+
await fs.writeFile(path.join(testDir, 'db.ts'), `export function db() {}`)
|
|
117
|
+
|
|
118
|
+
const graph = await buildGraph(testDir)
|
|
119
|
+
const scores = scoreFromSeeds(['auth.ts'], graph, 2)
|
|
120
|
+
|
|
121
|
+
const sessionScore = scores.find((s) => s.path === 'session.ts')
|
|
122
|
+
expect(sessionScore).toBeDefined()
|
|
123
|
+
expect(sessionScore!.depth).toBe(1)
|
|
124
|
+
|
|
125
|
+
const dbScore = scores.find((s) => s.path === 'db.ts')
|
|
126
|
+
expect(dbScore).toBeDefined()
|
|
127
|
+
expect(dbScore!.depth).toBe(2)
|
|
128
|
+
expect(dbScore!.score).toBeCloseTo(1 / 3, 2)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should follow reverse edges (imported-by)', async () => {
|
|
132
|
+
await fs.writeFile(path.join(testDir, 'auth.ts'), `export function auth() {}`)
|
|
133
|
+
await fs.writeFile(
|
|
134
|
+
path.join(testDir, 'middleware.ts'),
|
|
135
|
+
`import { auth } from './auth'\nexport function middleware() {}`
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const graph = await buildGraph(testDir)
|
|
139
|
+
// Seed is auth.ts, middleware imports auth → should appear via reverse edge
|
|
140
|
+
const scores = scoreFromSeeds(['auth.ts'], graph)
|
|
141
|
+
|
|
142
|
+
const middlewareScore = scores.find((s) => s.path === 'middleware.ts')
|
|
143
|
+
expect(middlewareScore).toBeDefined()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should not include seed files in results', async () => {
|
|
147
|
+
await fs.writeFile(path.join(testDir, 'a.ts'), `import { b } from './b'`)
|
|
148
|
+
await fs.writeFile(path.join(testDir, 'b.ts'), `export const b = 1`)
|
|
149
|
+
|
|
150
|
+
const graph = await buildGraph(testDir)
|
|
151
|
+
const scores = scoreFromSeeds(['a.ts'], graph)
|
|
152
|
+
|
|
153
|
+
expect(scores.find((s) => s.path === 'a.ts')).toBeUndefined()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { agentPerformanceTracker } from '../agents'
|
|
14
|
+
import { hasIndexes, rankFiles } from '../domain/file-ranker'
|
|
14
15
|
import { outcomeAnalyzer } from '../outcomes'
|
|
15
16
|
import type {
|
|
16
17
|
ContextDomain,
|
|
@@ -177,8 +178,13 @@ class SmartContext {
|
|
|
177
178
|
filteredStack.testing = fullContext.stack.testing
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
// Filter files
|
|
181
|
-
const filteredFiles = this.
|
|
181
|
+
// Filter files: use BM25 ranker if indexes exist, else fall back to domain patterns
|
|
182
|
+
const filteredFiles = this.rankOrFilterFiles(
|
|
183
|
+
fullContext.files,
|
|
184
|
+
taskDescription,
|
|
185
|
+
projectId,
|
|
186
|
+
taskDomain
|
|
187
|
+
)
|
|
182
188
|
|
|
183
189
|
// Calculate metrics
|
|
184
190
|
const originalSize = this.estimateSize(fullContext)
|
|
@@ -205,6 +211,31 @@ class SmartContext {
|
|
|
205
211
|
}
|
|
206
212
|
}
|
|
207
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Use BM25 + import graph + co-change ranking if indexes exist,
|
|
216
|
+
* otherwise fall back to regex-based domain filtering.
|
|
217
|
+
*/
|
|
218
|
+
private rankOrFilterFiles(
|
|
219
|
+
files: string[],
|
|
220
|
+
taskDescription: string,
|
|
221
|
+
projectId: string,
|
|
222
|
+
domain: ContextDomain
|
|
223
|
+
): string[] {
|
|
224
|
+
try {
|
|
225
|
+
const indexes = hasIndexes(projectId)
|
|
226
|
+
if (indexes.bm25) {
|
|
227
|
+
const ranked = rankFiles(projectId, taskDescription, { topN: 15 })
|
|
228
|
+
if (ranked.length > 0) {
|
|
229
|
+
return ranked.map((r) => r.path)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
// Index not available — fall through to regex filter
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return this.filterFiles(files, domain)
|
|
237
|
+
}
|
|
238
|
+
|
|
208
239
|
/**
|
|
209
240
|
* Filter files by domain.
|
|
210
241
|
*/
|
|
@@ -235,6 +235,7 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
235
235
|
yes?: boolean
|
|
236
236
|
json?: boolean
|
|
237
237
|
package?: string
|
|
238
|
+
full?: boolean
|
|
238
239
|
} = {}
|
|
239
240
|
): Promise<CommandResult> {
|
|
240
241
|
try {
|
|
@@ -308,7 +309,10 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
// Do a dry-run sync to see what would change
|
|
311
|
-
const result = await syncService.sync(projectPath, {
|
|
312
|
+
const result = await syncService.sync(projectPath, {
|
|
313
|
+
aiTools: options.aiTools,
|
|
314
|
+
full: options.full,
|
|
315
|
+
})
|
|
312
316
|
|
|
313
317
|
if (!result.success) {
|
|
314
318
|
if (isNonInteractive) {
|
|
@@ -453,7 +457,10 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
453
457
|
out.spin('Syncing project...')
|
|
454
458
|
|
|
455
459
|
// Use syncService to do EVERYTHING in one call
|
|
456
|
-
const result = await syncService.sync(projectPath, {
|
|
460
|
+
const result = await syncService.sync(projectPath, {
|
|
461
|
+
aiTools: options.aiTools,
|
|
462
|
+
full: options.full,
|
|
463
|
+
})
|
|
457
464
|
|
|
458
465
|
if (!result.success) {
|
|
459
466
|
out.fail(result.error || 'Sync failed')
|
|
@@ -166,11 +166,13 @@ export const COMMANDS: CommandMeta[] = [
|
|
|
166
166
|
name: 'sync',
|
|
167
167
|
group: 'core',
|
|
168
168
|
description: 'Sync project state and update workflow agents',
|
|
169
|
-
usage: { claude: '/p:sync', terminal: 'prjct sync [--package=<name>]' },
|
|
169
|
+
usage: { claude: '/p:sync', terminal: 'prjct sync [--package=<name>] [--full]' },
|
|
170
170
|
implemented: true,
|
|
171
171
|
hasTemplate: true,
|
|
172
172
|
requiresProject: true,
|
|
173
173
|
features: [
|
|
174
|
+
'Incremental sync: only re-analyzes changed files (default)',
|
|
175
|
+
'Force full sync: --full bypasses incremental cache',
|
|
174
176
|
'Monorepo support: --package=<name> for single package sync',
|
|
175
177
|
'Nested PRJCT.md inheritance',
|
|
176
178
|
'Per-package CLAUDE.md generation',
|