prjct-cli 0.58.0 ā 0.59.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 +7 -0
- package/core/__tests__/services/staleness-checker.test.ts +204 -0
- package/core/__tests__/storage/storage-manager.test.ts +379 -0
- package/core/commands/analysis.ts +59 -1
- package/core/commands/analytics.ts +9 -0
- package/core/commands/command-data.ts +16 -0
- package/core/commands/commands.ts +7 -0
- package/core/commands/register.ts +1 -0
- package/core/index.ts +4 -0
- package/core/schemas/project.ts +3 -0
- package/core/services/index.ts +3 -0
- package/core/services/staleness-checker.ts +262 -0
- package/core/services/sync-service.ts +3 -0
- package/dist/bin/prjct.mjs +642 -364
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness Checker Tests (PRJ-120)
|
|
3
|
+
*
|
|
4
|
+
* Tests for the StalenessChecker service:
|
|
5
|
+
* - Fresh context detection
|
|
6
|
+
* - Stale context detection (commits, days, significant files)
|
|
7
|
+
* - No sync history handling
|
|
8
|
+
* - Output formatting
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
12
|
+
import fs from 'node:fs/promises'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import pathManager from '../../infrastructure/path-manager'
|
|
16
|
+
import { createStalenessChecker, type StalenessStatus } from '../../services/staleness-checker'
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// Test Setup
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
let tmpRoot: string | null = null
|
|
23
|
+
let testProjectId: string
|
|
24
|
+
const originalGetGlobalProjectPath = pathManager.getGlobalProjectPath.bind(pathManager)
|
|
25
|
+
|
|
26
|
+
describe('StalenessChecker', () => {
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-staleness-test-'))
|
|
29
|
+
testProjectId = 'staleness-test-project'
|
|
30
|
+
|
|
31
|
+
// Mock pathManager to use temp directory
|
|
32
|
+
pathManager.getGlobalProjectPath = (projectId: string) => {
|
|
33
|
+
return path.join(tmpRoot!, projectId)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
pathManager.getGlobalProjectPath = originalGetGlobalProjectPath
|
|
39
|
+
|
|
40
|
+
if (tmpRoot) {
|
|
41
|
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
42
|
+
tmpRoot = null
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// ===========================================================================
|
|
47
|
+
// No Sync History Tests
|
|
48
|
+
// ===========================================================================
|
|
49
|
+
|
|
50
|
+
describe('no sync history', () => {
|
|
51
|
+
it('should report stale when no project.json exists', async () => {
|
|
52
|
+
const checker = createStalenessChecker(process.cwd())
|
|
53
|
+
const status = await checker.check(testProjectId)
|
|
54
|
+
|
|
55
|
+
expect(status.isStale).toBe(true)
|
|
56
|
+
expect(status.reason).toContain('No sync history found')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should report stale when no lastSyncCommit in project.json', async () => {
|
|
60
|
+
// Create project.json without lastSyncCommit
|
|
61
|
+
const projectPath = path.join(tmpRoot!, testProjectId)
|
|
62
|
+
await fs.mkdir(projectPath, { recursive: true })
|
|
63
|
+
await fs.writeFile(
|
|
64
|
+
path.join(projectPath, 'project.json'),
|
|
65
|
+
JSON.stringify({ name: 'test', lastSync: new Date().toISOString() })
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const checker = createStalenessChecker(process.cwd())
|
|
69
|
+
const status = await checker.check(testProjectId)
|
|
70
|
+
|
|
71
|
+
expect(status.isStale).toBe(true)
|
|
72
|
+
expect(status.reason).toContain('No sync commit recorded')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ===========================================================================
|
|
77
|
+
// Status Formatting Tests
|
|
78
|
+
// ===========================================================================
|
|
79
|
+
|
|
80
|
+
describe('formatStatus', () => {
|
|
81
|
+
it('should format fresh status correctly', () => {
|
|
82
|
+
const checker = createStalenessChecker(process.cwd())
|
|
83
|
+
const status: StalenessStatus = {
|
|
84
|
+
isStale: false,
|
|
85
|
+
reason: 'Context is up to date',
|
|
86
|
+
lastSyncCommit: 'abc123',
|
|
87
|
+
currentCommit: 'abc123',
|
|
88
|
+
commitsSinceSync: 0,
|
|
89
|
+
daysSinceSync: 0,
|
|
90
|
+
changedFiles: [],
|
|
91
|
+
significantChanges: [],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const formatted = checker.formatStatus(status)
|
|
95
|
+
|
|
96
|
+
expect(formatted).toContain('ā Fresh')
|
|
97
|
+
expect(formatted).toContain('Last sync:')
|
|
98
|
+
expect(formatted).toContain('abc123')
|
|
99
|
+
expect(formatted).not.toContain('Run `prjct sync`')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should format stale status with sync prompt', () => {
|
|
103
|
+
const checker = createStalenessChecker(process.cwd())
|
|
104
|
+
const status: StalenessStatus = {
|
|
105
|
+
isStale: true,
|
|
106
|
+
reason: '15 commits since last sync (threshold: 10)',
|
|
107
|
+
lastSyncCommit: 'abc123',
|
|
108
|
+
currentCommit: 'def456',
|
|
109
|
+
commitsSinceSync: 15,
|
|
110
|
+
daysSinceSync: 2,
|
|
111
|
+
changedFiles: ['src/index.ts', 'package.json'],
|
|
112
|
+
significantChanges: ['package.json'],
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const formatted = checker.formatStatus(status)
|
|
116
|
+
|
|
117
|
+
expect(formatted).toContain('ā ļø STALE')
|
|
118
|
+
expect(formatted).toContain('15')
|
|
119
|
+
expect(formatted).toContain('Significant changes')
|
|
120
|
+
expect(formatted).toContain('package.json')
|
|
121
|
+
expect(formatted).toContain('Run `prjct sync`')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ===========================================================================
|
|
126
|
+
// Warning Message Tests
|
|
127
|
+
// ===========================================================================
|
|
128
|
+
|
|
129
|
+
describe('getWarning', () => {
|
|
130
|
+
it('should return null for fresh status', () => {
|
|
131
|
+
const checker = createStalenessChecker(process.cwd())
|
|
132
|
+
const status: StalenessStatus = {
|
|
133
|
+
isStale: false,
|
|
134
|
+
reason: 'Context is up to date',
|
|
135
|
+
lastSyncCommit: 'abc123',
|
|
136
|
+
currentCommit: 'abc123',
|
|
137
|
+
commitsSinceSync: 0,
|
|
138
|
+
daysSinceSync: 0,
|
|
139
|
+
changedFiles: [],
|
|
140
|
+
significantChanges: [],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const warning = checker.getWarning(status)
|
|
144
|
+
expect(warning).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should return commit-based warning', () => {
|
|
148
|
+
const checker = createStalenessChecker(process.cwd())
|
|
149
|
+
const status: StalenessStatus = {
|
|
150
|
+
isStale: true,
|
|
151
|
+
reason: '15 commits since last sync',
|
|
152
|
+
lastSyncCommit: 'abc123',
|
|
153
|
+
currentCommit: 'def456',
|
|
154
|
+
commitsSinceSync: 15,
|
|
155
|
+
daysSinceSync: 0,
|
|
156
|
+
changedFiles: [],
|
|
157
|
+
significantChanges: [],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const warning = checker.getWarning(status)
|
|
161
|
+
expect(warning).toContain('15 commits behind')
|
|
162
|
+
expect(warning).toContain('prjct sync')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('should return days-based warning', () => {
|
|
166
|
+
const checker = createStalenessChecker(process.cwd())
|
|
167
|
+
const status: StalenessStatus = {
|
|
168
|
+
isStale: true,
|
|
169
|
+
reason: '5 days since last sync',
|
|
170
|
+
lastSyncCommit: 'abc123',
|
|
171
|
+
currentCommit: 'abc123',
|
|
172
|
+
commitsSinceSync: 0,
|
|
173
|
+
daysSinceSync: 5,
|
|
174
|
+
changedFiles: [],
|
|
175
|
+
significantChanges: [],
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const warning = checker.getWarning(status)
|
|
179
|
+
expect(warning).toContain('5 days old')
|
|
180
|
+
expect(warning).toContain('prjct sync')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
// Configuration Tests
|
|
186
|
+
// ===========================================================================
|
|
187
|
+
|
|
188
|
+
describe('configuration', () => {
|
|
189
|
+
it('should use default thresholds', () => {
|
|
190
|
+
const checker = createStalenessChecker(process.cwd())
|
|
191
|
+
// Default commitThreshold is 10, dayThreshold is 3
|
|
192
|
+
expect(checker).toBeDefined()
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should accept custom thresholds', () => {
|
|
196
|
+
const checker = createStalenessChecker(process.cwd(), {
|
|
197
|
+
commitThreshold: 5,
|
|
198
|
+
dayThreshold: 1,
|
|
199
|
+
significantFiles: ['custom.config.js'],
|
|
200
|
+
})
|
|
201
|
+
expect(checker).toBeDefined()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
})
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Manager Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the base StorageManager class:
|
|
5
|
+
* - Read/write JSON operations
|
|
6
|
+
* - Missing file handling
|
|
7
|
+
* - Directory creation
|
|
8
|
+
* - Cache behavior
|
|
9
|
+
* - State consistency
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
13
|
+
import fs from 'node:fs/promises'
|
|
14
|
+
import os from 'node:os'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import pathManager from '../../infrastructure/path-manager'
|
|
17
|
+
import { StorageManager } from '../../storage/storage-manager'
|
|
18
|
+
|
|
19
|
+
// =============================================================================
|
|
20
|
+
// Test Implementation
|
|
21
|
+
// =============================================================================
|
|
22
|
+
|
|
23
|
+
interface TestData {
|
|
24
|
+
value: string
|
|
25
|
+
count: number
|
|
26
|
+
items: string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Concrete implementation for testing the abstract StorageManager
|
|
31
|
+
*/
|
|
32
|
+
class TestStorageManager extends StorageManager<TestData> {
|
|
33
|
+
constructor() {
|
|
34
|
+
super('test-data.json')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
protected getLayer(): string {
|
|
38
|
+
return 'context'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
protected getDefault(): TestData {
|
|
42
|
+
return { value: '', count: 0, items: [] }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
protected toMarkdown(data: TestData): string {
|
|
46
|
+
return `# Test Data\n\nValue: ${data.value}\nCount: ${data.count}\nItems: ${data.items.join(', ')}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
protected getMdFilename(): string {
|
|
50
|
+
return 'test-data.md'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
protected getEventType(action: 'update' | 'create' | 'delete'): string {
|
|
54
|
+
return `test.${action}`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Test Setup
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
let tmpRoot: string | null = null
|
|
63
|
+
let testProjectId: string
|
|
64
|
+
let manager: TestStorageManager
|
|
65
|
+
|
|
66
|
+
// Mock pathManager to use temp directory
|
|
67
|
+
const originalGetGlobalProjectPath = pathManager.getGlobalProjectPath.bind(pathManager)
|
|
68
|
+
const originalGetStoragePath = pathManager.getStoragePath.bind(pathManager)
|
|
69
|
+
const originalGetFilePath = pathManager.getFilePath.bind(pathManager)
|
|
70
|
+
|
|
71
|
+
describe('StorageManager', () => {
|
|
72
|
+
beforeEach(async () => {
|
|
73
|
+
// Create temp directory for test isolation
|
|
74
|
+
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-storage-test-'))
|
|
75
|
+
testProjectId = 'test-project-123'
|
|
76
|
+
|
|
77
|
+
// Mock pathManager to use temp directory
|
|
78
|
+
pathManager.getGlobalProjectPath = (projectId: string) => {
|
|
79
|
+
return path.join(tmpRoot!, projectId)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pathManager.getStoragePath = (projectId: string, filename: string) => {
|
|
83
|
+
return path.join(tmpRoot!, projectId, 'storage', filename)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
pathManager.getFilePath = (projectId: string, layer: string, filename: string) => {
|
|
87
|
+
return path.join(tmpRoot!, projectId, layer, filename)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create fresh manager instance
|
|
91
|
+
manager = new TestStorageManager()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
afterEach(async () => {
|
|
95
|
+
// Restore original pathManager methods
|
|
96
|
+
pathManager.getGlobalProjectPath = originalGetGlobalProjectPath
|
|
97
|
+
pathManager.getStoragePath = originalGetStoragePath
|
|
98
|
+
pathManager.getFilePath = originalGetFilePath
|
|
99
|
+
|
|
100
|
+
// Clean up temp directory
|
|
101
|
+
if (tmpRoot) {
|
|
102
|
+
await fs.rm(tmpRoot, { recursive: true, force: true })
|
|
103
|
+
tmpRoot = null
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
// ===========================================================================
|
|
108
|
+
// Read/Write Tests
|
|
109
|
+
// ===========================================================================
|
|
110
|
+
|
|
111
|
+
describe('read/write', () => {
|
|
112
|
+
it('should write and read JSON correctly', async () => {
|
|
113
|
+
const testData: TestData = {
|
|
114
|
+
value: 'hello',
|
|
115
|
+
count: 42,
|
|
116
|
+
items: ['a', 'b', 'c'],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await manager.write(testProjectId, testData)
|
|
120
|
+
const result = await manager.read(testProjectId)
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual(testData)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should create storage file with correct JSON format', async () => {
|
|
126
|
+
const testData: TestData = {
|
|
127
|
+
value: 'test',
|
|
128
|
+
count: 1,
|
|
129
|
+
items: ['item1'],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await manager.write(testProjectId, testData)
|
|
133
|
+
|
|
134
|
+
// Verify file exists and has correct content
|
|
135
|
+
const storagePath = path.join(tmpRoot!, testProjectId, 'storage', 'test-data.json')
|
|
136
|
+
const content = await fs.readFile(storagePath, 'utf-8')
|
|
137
|
+
const parsed = JSON.parse(content)
|
|
138
|
+
|
|
139
|
+
expect(parsed).toEqual(testData)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('should create context markdown file', async () => {
|
|
143
|
+
const testData: TestData = {
|
|
144
|
+
value: 'markdown-test',
|
|
145
|
+
count: 5,
|
|
146
|
+
items: ['x', 'y'],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await manager.write(testProjectId, testData)
|
|
150
|
+
|
|
151
|
+
// Verify markdown file exists
|
|
152
|
+
const contextPath = path.join(tmpRoot!, testProjectId, 'context', 'test-data.md')
|
|
153
|
+
const content = await fs.readFile(contextPath, 'utf-8')
|
|
154
|
+
|
|
155
|
+
expect(content).toContain('# Test Data')
|
|
156
|
+
expect(content).toContain('Value: markdown-test')
|
|
157
|
+
expect(content).toContain('Count: 5')
|
|
158
|
+
expect(content).toContain('Items: x, y')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should overwrite existing data', async () => {
|
|
162
|
+
const data1: TestData = { value: 'first', count: 1, items: [] }
|
|
163
|
+
const data2: TestData = { value: 'second', count: 2, items: ['new'] }
|
|
164
|
+
|
|
165
|
+
await manager.write(testProjectId, data1)
|
|
166
|
+
await manager.write(testProjectId, data2)
|
|
167
|
+
|
|
168
|
+
const result = await manager.read(testProjectId)
|
|
169
|
+
expect(result).toEqual(data2)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// ===========================================================================
|
|
174
|
+
// Missing File Handling
|
|
175
|
+
// ===========================================================================
|
|
176
|
+
|
|
177
|
+
describe('missing file handling', () => {
|
|
178
|
+
it('should return default when file does not exist', async () => {
|
|
179
|
+
const result = await manager.read('non-existent-project')
|
|
180
|
+
|
|
181
|
+
expect(result).toEqual({ value: '', count: 0, items: [] })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should return default for invalid JSON', async () => {
|
|
185
|
+
// Create invalid JSON file
|
|
186
|
+
const storagePath = path.join(tmpRoot!, testProjectId, 'storage', 'test-data.json')
|
|
187
|
+
await fs.mkdir(path.dirname(storagePath), { recursive: true })
|
|
188
|
+
await fs.writeFile(storagePath, 'not valid json {{{', 'utf-8')
|
|
189
|
+
|
|
190
|
+
const result = await manager.read(testProjectId)
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({ value: '', count: 0, items: [] })
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('should report exists=false for missing file', async () => {
|
|
196
|
+
const exists = await manager.exists('non-existent-project')
|
|
197
|
+
expect(exists).toBe(false)
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should report exists=true after write', async () => {
|
|
201
|
+
await manager.write(testProjectId, { value: 'test', count: 1, items: [] })
|
|
202
|
+
|
|
203
|
+
const exists = await manager.exists(testProjectId)
|
|
204
|
+
expect(exists).toBe(true)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
// ===========================================================================
|
|
209
|
+
// Directory Creation
|
|
210
|
+
// ===========================================================================
|
|
211
|
+
|
|
212
|
+
describe('directory creation', () => {
|
|
213
|
+
it('should create storage directory if it does not exist', async () => {
|
|
214
|
+
const testData: TestData = { value: 'dir-test', count: 1, items: [] }
|
|
215
|
+
|
|
216
|
+
// Directory shouldn't exist yet
|
|
217
|
+
const storageDir = path.join(tmpRoot!, testProjectId, 'storage')
|
|
218
|
+
await expect(fs.access(storageDir)).rejects.toThrow()
|
|
219
|
+
|
|
220
|
+
// Write should create it
|
|
221
|
+
await manager.write(testProjectId, testData)
|
|
222
|
+
|
|
223
|
+
// Now it should exist
|
|
224
|
+
const stat = await fs.stat(storageDir)
|
|
225
|
+
expect(stat.isDirectory()).toBe(true)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should create context directory if it does not exist', async () => {
|
|
229
|
+
const testData: TestData = { value: 'ctx-test', count: 1, items: [] }
|
|
230
|
+
|
|
231
|
+
// Directory shouldn't exist yet
|
|
232
|
+
const contextDir = path.join(tmpRoot!, testProjectId, 'context')
|
|
233
|
+
await expect(fs.access(contextDir)).rejects.toThrow()
|
|
234
|
+
|
|
235
|
+
// Write should create it
|
|
236
|
+
await manager.write(testProjectId, testData)
|
|
237
|
+
|
|
238
|
+
// Now it should exist
|
|
239
|
+
const stat = await fs.stat(contextDir)
|
|
240
|
+
expect(stat.isDirectory()).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should create nested directories', async () => {
|
|
244
|
+
const deepProjectId = 'deep/nested/project'
|
|
245
|
+
const testData: TestData = { value: 'nested', count: 1, items: [] }
|
|
246
|
+
|
|
247
|
+
await manager.write(deepProjectId, testData)
|
|
248
|
+
|
|
249
|
+
const result = await manager.read(deepProjectId)
|
|
250
|
+
expect(result).toEqual(testData)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ===========================================================================
|
|
255
|
+
// Cache Behavior
|
|
256
|
+
// ===========================================================================
|
|
257
|
+
|
|
258
|
+
describe('cache behavior', () => {
|
|
259
|
+
it('should cache read results', async () => {
|
|
260
|
+
const testData: TestData = { value: 'cached', count: 1, items: [] }
|
|
261
|
+
await manager.write(testProjectId, testData)
|
|
262
|
+
|
|
263
|
+
// First read
|
|
264
|
+
const result1 = await manager.read(testProjectId)
|
|
265
|
+
|
|
266
|
+
// Modify file directly (bypass manager)
|
|
267
|
+
const storagePath = path.join(tmpRoot!, testProjectId, 'storage', 'test-data.json')
|
|
268
|
+
await fs.writeFile(
|
|
269
|
+
storagePath,
|
|
270
|
+
JSON.stringify({ value: 'modified', count: 2, items: [] }),
|
|
271
|
+
'utf-8'
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
// Second read should return cached value
|
|
275
|
+
const result2 = await manager.read(testProjectId)
|
|
276
|
+
expect(result2).toEqual(result1)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('should clear cache for specific project', async () => {
|
|
280
|
+
const testData: TestData = { value: 'to-clear', count: 1, items: [] }
|
|
281
|
+
await manager.write(testProjectId, testData)
|
|
282
|
+
|
|
283
|
+
// Read to populate cache
|
|
284
|
+
await manager.read(testProjectId)
|
|
285
|
+
|
|
286
|
+
// Modify file directly
|
|
287
|
+
const storagePath = path.join(tmpRoot!, testProjectId, 'storage', 'test-data.json')
|
|
288
|
+
const newData = { value: 'updated', count: 99, items: ['new'] }
|
|
289
|
+
await fs.writeFile(storagePath, JSON.stringify(newData), 'utf-8')
|
|
290
|
+
|
|
291
|
+
// Clear cache
|
|
292
|
+
manager.clearCache(testProjectId)
|
|
293
|
+
|
|
294
|
+
// Now read should get fresh data
|
|
295
|
+
const result = await manager.read(testProjectId)
|
|
296
|
+
expect(result).toEqual(newData)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('should clear all cache', async () => {
|
|
300
|
+
// Write to multiple projects
|
|
301
|
+
await manager.write('project-a', { value: 'a', count: 1, items: [] })
|
|
302
|
+
await manager.write('project-b', { value: 'b', count: 2, items: [] })
|
|
303
|
+
|
|
304
|
+
// Read to populate cache
|
|
305
|
+
await manager.read('project-a')
|
|
306
|
+
await manager.read('project-b')
|
|
307
|
+
|
|
308
|
+
// Clear all cache
|
|
309
|
+
manager.clearCache()
|
|
310
|
+
|
|
311
|
+
// Verify cache stats
|
|
312
|
+
const stats = manager.getCacheStats()
|
|
313
|
+
expect(stats.size).toBe(0)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('should return cache stats', async () => {
|
|
317
|
+
const stats = manager.getCacheStats()
|
|
318
|
+
|
|
319
|
+
expect(stats).toHaveProperty('size')
|
|
320
|
+
expect(stats).toHaveProperty('maxSize')
|
|
321
|
+
expect(stats).toHaveProperty('ttl')
|
|
322
|
+
expect(typeof stats.size).toBe('number')
|
|
323
|
+
expect(typeof stats.maxSize).toBe('number')
|
|
324
|
+
expect(typeof stats.ttl).toBe('number')
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// ===========================================================================
|
|
329
|
+
// State Consistency (Update Operations)
|
|
330
|
+
// ===========================================================================
|
|
331
|
+
|
|
332
|
+
describe('state consistency', () => {
|
|
333
|
+
it('should update data atomically with updater function', async () => {
|
|
334
|
+
const initial: TestData = { value: 'initial', count: 0, items: [] }
|
|
335
|
+
await manager.write(testProjectId, initial)
|
|
336
|
+
|
|
337
|
+
const result = await manager.update(testProjectId, (current) => ({
|
|
338
|
+
...current,
|
|
339
|
+
count: current.count + 1,
|
|
340
|
+
items: [...current.items, 'new-item'],
|
|
341
|
+
}))
|
|
342
|
+
|
|
343
|
+
expect(result.count).toBe(1)
|
|
344
|
+
expect(result.items).toEqual(['new-item'])
|
|
345
|
+
|
|
346
|
+
// Verify persisted
|
|
347
|
+
manager.clearCache(testProjectId)
|
|
348
|
+
const persisted = await manager.read(testProjectId)
|
|
349
|
+
expect(persisted).toEqual(result)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('should handle multiple sequential updates', async () => {
|
|
353
|
+
await manager.write(testProjectId, { value: 'start', count: 0, items: [] })
|
|
354
|
+
|
|
355
|
+
// Multiple updates
|
|
356
|
+
for (let i = 0; i < 5; i++) {
|
|
357
|
+
await manager.update(testProjectId, (current) => ({
|
|
358
|
+
...current,
|
|
359
|
+
count: current.count + 1,
|
|
360
|
+
}))
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const result = await manager.read(testProjectId)
|
|
364
|
+
expect(result.count).toBe(5)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('should maintain data integrity after failed read during update', async () => {
|
|
368
|
+
// Start with no file (will use default)
|
|
369
|
+
const result = await manager.update(testProjectId, (current) => ({
|
|
370
|
+
...current,
|
|
371
|
+
value: 'from-default',
|
|
372
|
+
count: 100,
|
|
373
|
+
}))
|
|
374
|
+
|
|
375
|
+
expect(result.value).toBe('from-default')
|
|
376
|
+
expect(result.count).toBe(100)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
})
|
|
@@ -10,7 +10,7 @@ import { generateContext } from '../context/generator'
|
|
|
10
10
|
import analyzer from '../domain/analyzer'
|
|
11
11
|
import commandInstaller from '../infrastructure/command-installer'
|
|
12
12
|
import { formatCost } from '../schemas/metrics'
|
|
13
|
-
import { memoryService, syncService } from '../services'
|
|
13
|
+
import { createStalenessChecker, memoryService, syncService } from '../services'
|
|
14
14
|
import { formatDiffPreview, formatFullDiff, generateSyncDiff } from '../services/diff-generator'
|
|
15
15
|
import { metricsStorage } from '../storage/metrics-storage'
|
|
16
16
|
import type { AnalyzeOptions, CommandResult, ProjectContext } from '../types'
|
|
@@ -735,6 +735,64 @@ export class AnalysisCommands extends PrjctCommandsBase {
|
|
|
735
735
|
}
|
|
736
736
|
}
|
|
737
737
|
|
|
738
|
+
/**
|
|
739
|
+
* /p:status - Check if CLAUDE.md context is stale
|
|
740
|
+
*
|
|
741
|
+
* Uses git commit history to detect when significant changes
|
|
742
|
+
* have occurred since the last sync.
|
|
743
|
+
*
|
|
744
|
+
* @see PRJ-120
|
|
745
|
+
*/
|
|
746
|
+
async status(
|
|
747
|
+
projectPath: string = process.cwd(),
|
|
748
|
+
options: { json?: boolean } = {}
|
|
749
|
+
): Promise<CommandResult> {
|
|
750
|
+
try {
|
|
751
|
+
const initResult = await this.ensureProjectInit(projectPath)
|
|
752
|
+
if (!initResult.success) return initResult
|
|
753
|
+
|
|
754
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
755
|
+
if (!projectId) {
|
|
756
|
+
if (options.json) {
|
|
757
|
+
console.log(JSON.stringify({ success: false, error: 'No project ID found' }))
|
|
758
|
+
} else {
|
|
759
|
+
out.fail('No project ID found')
|
|
760
|
+
}
|
|
761
|
+
return { success: false, error: 'No project ID found' }
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Create staleness checker and run check
|
|
765
|
+
const checker = createStalenessChecker(projectPath)
|
|
766
|
+
const status = await checker.check(projectId)
|
|
767
|
+
|
|
768
|
+
// JSON output mode
|
|
769
|
+
if (options.json) {
|
|
770
|
+
console.log(
|
|
771
|
+
JSON.stringify({
|
|
772
|
+
success: true,
|
|
773
|
+
...status,
|
|
774
|
+
})
|
|
775
|
+
)
|
|
776
|
+
return { success: true, data: status }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Human-readable output
|
|
780
|
+
console.log('')
|
|
781
|
+
console.log(checker.formatStatus(status))
|
|
782
|
+
console.log('')
|
|
783
|
+
|
|
784
|
+
return { success: true, data: status }
|
|
785
|
+
} catch (error) {
|
|
786
|
+
const errMsg = (error as Error).message
|
|
787
|
+
if (options.json) {
|
|
788
|
+
console.log(JSON.stringify({ success: false, error: errMsg }))
|
|
789
|
+
} else {
|
|
790
|
+
out.fail(errMsg)
|
|
791
|
+
}
|
|
792
|
+
return { success: false, error: errMsg }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
738
796
|
/**
|
|
739
797
|
* Get session activity stats from today's events
|
|
740
798
|
* @see PRJ-89
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
|
+
import { createStalenessChecker } from '../services'
|
|
7
8
|
import { ideasStorage, queueStorage, shippedStorage, stateStorage } from '../storage'
|
|
8
9
|
import type { CommandResult, ProjectContext } from '../types'
|
|
9
10
|
import {
|
|
@@ -135,6 +136,14 @@ export class AnalyticsCommands extends PrjctCommandsBase {
|
|
|
135
136
|
console.log(`\nš DASHBOARD - ${projectName}\n`)
|
|
136
137
|
console.log('ā'.repeat(50))
|
|
137
138
|
|
|
139
|
+
// Check staleness (PRJ-120)
|
|
140
|
+
const checker = createStalenessChecker(projectPath)
|
|
141
|
+
const stalenessStatus = await checker.check(projectId)
|
|
142
|
+
const stalenessWarning = checker.getWarning(stalenessStatus)
|
|
143
|
+
if (stalenessWarning) {
|
|
144
|
+
console.log(`\n${stalenessWarning}`)
|
|
145
|
+
}
|
|
146
|
+
|
|
138
147
|
// Current task
|
|
139
148
|
console.log('\nšÆ CURRENT FOCUS')
|
|
140
149
|
if (currentTask) {
|