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.
@@ -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 by domain patterns
181
- const filteredFiles = this.filterFiles(fullContext.files, taskDomain)
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, { aiTools: options.aiTools })
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, { aiTools: options.aiTools })
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',
@@ -211,6 +211,7 @@ class PrjctCommands {
211
211
  yes?: boolean
212
212
  json?: boolean
213
213
  package?: string
214
+ full?: boolean
214
215
  } = {}
215
216
  ): Promise<CommandResult> {
216
217
  return this.analysis.sync(projectPath, options)