prjct-cli 0.9.1 → 0.10.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 +165 -0
- package/core/__tests__/agentic/agent-router.test.js +398 -0
- package/core/__tests__/agentic/context-filter.test.js +494 -0
- package/core/__tests__/agentic/prompt-builder.test.js +39 -47
- package/core/__tests__/domain/agent-generator.test.js +29 -36
- package/core/__tests__/domain/agent-loader.test.js +179 -0
- package/core/__tests__/domain/analyzer.test.js +324 -0
- package/core/__tests__/infrastructure/author-detector.test.js +103 -0
- package/core/__tests__/infrastructure/config-manager.test.js +454 -0
- package/core/__tests__/infrastructure/path-manager.test.js +412 -0
- package/core/__tests__/utils/jsonl-helper.test.js +387 -0
- package/core/agentic/agent-router.js +253 -186
- package/core/agentic/command-executor.js +61 -13
- package/core/agentic/context-filter.js +92 -88
- package/core/agentic/prompt-builder.js +51 -1
- package/core/commands.js +85 -59
- package/core/domain/agent-generator.js +77 -46
- package/core/domain/agent-loader.js +183 -0
- package/core/domain/agent-matcher.js +217 -0
- package/core/domain/agent-validator.js +217 -0
- package/core/domain/context-estimator.js +175 -0
- package/core/domain/product-standards.js +92 -0
- package/core/domain/smart-cache.js +157 -0
- package/core/domain/task-analyzer.js +353 -0
- package/core/domain/tech-detector.js +365 -0
- package/package.json +3 -2
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { createRequire } from 'module'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import fs from 'fs/promises'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
|
|
9
|
+
describe('JSONL Helper', () => {
|
|
10
|
+
let jsonlHelper
|
|
11
|
+
let testFilePath
|
|
12
|
+
let tempDir
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
jsonlHelper = require('../../utils/jsonl-helper.js')
|
|
16
|
+
|
|
17
|
+
// Create temporary test directory
|
|
18
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-test-'))
|
|
19
|
+
testFilePath = path.join(tempDir, 'test.jsonl')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
if (tempDir) {
|
|
24
|
+
try {
|
|
25
|
+
await fs.rm(tempDir, { recursive: true, force: true })
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// Ignore cleanup errors
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('parseJsonLines()', () => {
|
|
33
|
+
it('should parse valid JSONL content', () => {
|
|
34
|
+
const content = '{"ts":"2025-10-04T14:30:00Z","type":"test"}\n{"ts":"2025-10-04T15:00:00Z","type":"test2"}'
|
|
35
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
36
|
+
|
|
37
|
+
expect(parsed.length).toBe(2)
|
|
38
|
+
expect(parsed[0].type).toBe('test')
|
|
39
|
+
expect(parsed[1].type).toBe('test2')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should skip malformed lines', () => {
|
|
43
|
+
const content = '{"valid":true}\ninvalid json\n{"another":true}'
|
|
44
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
45
|
+
|
|
46
|
+
expect(parsed.length).toBe(2)
|
|
47
|
+
expect(parsed[0].valid).toBe(true)
|
|
48
|
+
expect(parsed[1].another).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should handle empty content', () => {
|
|
52
|
+
const parsed = jsonlHelper.parseJsonLines('')
|
|
53
|
+
expect(parsed).toEqual([])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should filter empty lines', () => {
|
|
57
|
+
const content = '{"a":1}\n\n{"b":2}\n \n{"c":3}'
|
|
58
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
59
|
+
|
|
60
|
+
expect(parsed.length).toBe(3)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('stringifyJsonLines()', () => {
|
|
65
|
+
it('should convert array to JSONL format', () => {
|
|
66
|
+
const objects = [
|
|
67
|
+
{ ts: '2025-10-04T14:30:00Z', type: 'test' },
|
|
68
|
+
{ ts: '2025-10-04T15:00:00Z', type: 'test2' }
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const jsonl = jsonlHelper.stringifyJsonLines(objects)
|
|
72
|
+
const lines = jsonl.trim().split('\n')
|
|
73
|
+
|
|
74
|
+
expect(lines.length).toBe(2)
|
|
75
|
+
expect(JSON.parse(lines[0]).type).toBe('test')
|
|
76
|
+
expect(JSON.parse(lines[1]).type).toBe('test2')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should end with newline', () => {
|
|
80
|
+
const objects = [{ a: 1 }]
|
|
81
|
+
const jsonl = jsonlHelper.stringifyJsonLines(objects)
|
|
82
|
+
|
|
83
|
+
expect(jsonl.endsWith('\n')).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should handle empty array', () => {
|
|
87
|
+
const jsonl = jsonlHelper.stringifyJsonLines([])
|
|
88
|
+
expect(jsonl).toBe('\n')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('readJsonLines()', () => {
|
|
93
|
+
it('should read and parse JSONL file', async () => {
|
|
94
|
+
const content = '{"a":1}\n{"b":2}'
|
|
95
|
+
await fs.writeFile(testFilePath, content)
|
|
96
|
+
|
|
97
|
+
const parsed = await jsonlHelper.readJsonLines(testFilePath)
|
|
98
|
+
|
|
99
|
+
expect(parsed.length).toBe(2)
|
|
100
|
+
expect(parsed[0].a).toBe(1)
|
|
101
|
+
expect(parsed[1].b).toBe(2)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should return empty array for non-existent file', async () => {
|
|
105
|
+
const parsed = await jsonlHelper.readJsonLines(path.join(tempDir, 'nonexistent.jsonl'))
|
|
106
|
+
|
|
107
|
+
expect(parsed).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should throw for other errors', async () => {
|
|
111
|
+
// Create directory with same name to cause error
|
|
112
|
+
await fs.mkdir(testFilePath, { recursive: true })
|
|
113
|
+
|
|
114
|
+
await expect(jsonlHelper.readJsonLines(testFilePath)).rejects.toThrow()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('writeJsonLines()', () => {
|
|
119
|
+
it('should write objects to JSONL file', async () => {
|
|
120
|
+
const objects = [
|
|
121
|
+
{ ts: '2025-10-04T14:30:00Z', type: 'test' },
|
|
122
|
+
{ ts: '2025-10-04T15:00:00Z', type: 'test2' }
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
await jsonlHelper.writeJsonLines(testFilePath, objects)
|
|
126
|
+
|
|
127
|
+
const content = await fs.readFile(testFilePath, 'utf-8')
|
|
128
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
129
|
+
|
|
130
|
+
expect(parsed.length).toBe(2)
|
|
131
|
+
expect(parsed[0].type).toBe('test')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should overwrite existing file', async () => {
|
|
135
|
+
await fs.writeFile(testFilePath, '{"old":true}')
|
|
136
|
+
|
|
137
|
+
await jsonlHelper.writeJsonLines(testFilePath, [{ new: true }])
|
|
138
|
+
|
|
139
|
+
const content = await fs.readFile(testFilePath, 'utf-8')
|
|
140
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
141
|
+
|
|
142
|
+
expect(parsed.length).toBe(1)
|
|
143
|
+
expect(parsed[0].old).toBeUndefined()
|
|
144
|
+
expect(parsed[0].new).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('appendJsonLine()', () => {
|
|
149
|
+
it('should append single object to file', async () => {
|
|
150
|
+
await fs.writeFile(testFilePath, '{"first":true}\n')
|
|
151
|
+
|
|
152
|
+
await jsonlHelper.appendJsonLine(testFilePath, { second: true })
|
|
153
|
+
|
|
154
|
+
const content = await fs.readFile(testFilePath, 'utf-8')
|
|
155
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
156
|
+
|
|
157
|
+
expect(parsed.length).toBe(2)
|
|
158
|
+
expect(parsed[0].first).toBe(true)
|
|
159
|
+
expect(parsed[1].second).toBe(true)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('should create file if it does not exist', async () => {
|
|
163
|
+
await jsonlHelper.appendJsonLine(testFilePath, { new: true })
|
|
164
|
+
|
|
165
|
+
const content = await fs.readFile(testFilePath, 'utf-8')
|
|
166
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
167
|
+
|
|
168
|
+
expect(parsed.length).toBe(1)
|
|
169
|
+
expect(parsed[0].new).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('appendJsonLines()', () => {
|
|
174
|
+
it('should append multiple objects', async () => {
|
|
175
|
+
await fs.writeFile(testFilePath, '{"first":true}\n')
|
|
176
|
+
|
|
177
|
+
await jsonlHelper.appendJsonLines(testFilePath, [
|
|
178
|
+
{ second: true },
|
|
179
|
+
{ third: true }
|
|
180
|
+
])
|
|
181
|
+
|
|
182
|
+
const content = await fs.readFile(testFilePath, 'utf-8')
|
|
183
|
+
const parsed = jsonlHelper.parseJsonLines(content)
|
|
184
|
+
|
|
185
|
+
expect(parsed.length).toBe(3)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('filterJsonLines()', () => {
|
|
190
|
+
it('should filter entries by predicate', async () => {
|
|
191
|
+
const objects = [
|
|
192
|
+
{ type: 'test', value: 1 },
|
|
193
|
+
{ type: 'other', value: 2 },
|
|
194
|
+
{ type: 'test', value: 3 }
|
|
195
|
+
]
|
|
196
|
+
await jsonlHelper.writeJsonLines(testFilePath, objects)
|
|
197
|
+
|
|
198
|
+
const filtered = await jsonlHelper.filterJsonLines(testFilePath, entry => entry.type === 'test')
|
|
199
|
+
|
|
200
|
+
expect(filtered.length).toBe(2)
|
|
201
|
+
expect(filtered.every(e => e.type === 'test')).toBe(true)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('should return empty array for non-existent file', async () => {
|
|
205
|
+
const filtered = await jsonlHelper.filterJsonLines(
|
|
206
|
+
path.join(tempDir, 'nonexistent.jsonl'),
|
|
207
|
+
() => true
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(filtered).toEqual([])
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
describe('countJsonLines()', () => {
|
|
215
|
+
it('should count valid lines in file', async () => {
|
|
216
|
+
await fs.writeFile(testFilePath, '{"a":1}\n{"b":2}\n{"c":3}')
|
|
217
|
+
|
|
218
|
+
const count = await jsonlHelper.countJsonLines(testFilePath)
|
|
219
|
+
|
|
220
|
+
expect(count).toBe(3)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should return 0 for non-existent file', async () => {
|
|
224
|
+
const count = await jsonlHelper.countJsonLines(path.join(tempDir, 'nonexistent.jsonl'))
|
|
225
|
+
|
|
226
|
+
expect(count).toBe(0)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('should ignore empty lines', async () => {
|
|
230
|
+
await fs.writeFile(testFilePath, '{"a":1}\n\n{"b":2}\n \n')
|
|
231
|
+
|
|
232
|
+
const count = await jsonlHelper.countJsonLines(testFilePath)
|
|
233
|
+
|
|
234
|
+
expect(count).toBe(2)
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('getLastJsonLines()', () => {
|
|
239
|
+
it('should return last N entries', async () => {
|
|
240
|
+
const objects = Array.from({ length: 10 }, (_, i) => ({ index: i }))
|
|
241
|
+
await jsonlHelper.writeJsonLines(testFilePath, objects)
|
|
242
|
+
|
|
243
|
+
const last3 = await jsonlHelper.getLastJsonLines(testFilePath, 3)
|
|
244
|
+
|
|
245
|
+
expect(last3.length).toBe(3)
|
|
246
|
+
expect(last3[0].index).toBe(7)
|
|
247
|
+
expect(last3[2].index).toBe(9)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should return all entries if N is larger than file', async () => {
|
|
251
|
+
const objects = [{ a: 1 }, { b: 2 }]
|
|
252
|
+
await jsonlHelper.writeJsonLines(testFilePath, objects)
|
|
253
|
+
|
|
254
|
+
const last = await jsonlHelper.getLastJsonLines(testFilePath, 10)
|
|
255
|
+
|
|
256
|
+
expect(last.length).toBe(2)
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
describe('getFirstJsonLines()', () => {
|
|
261
|
+
it('should return first N entries', async () => {
|
|
262
|
+
const objects = Array.from({ length: 10 }, (_, i) => ({ index: i }))
|
|
263
|
+
await jsonlHelper.writeJsonLines(testFilePath, objects)
|
|
264
|
+
|
|
265
|
+
const first3 = await jsonlHelper.getFirstJsonLines(testFilePath, 3)
|
|
266
|
+
|
|
267
|
+
expect(first3.length).toBe(3)
|
|
268
|
+
expect(first3[0].index).toBe(0)
|
|
269
|
+
expect(first3[2].index).toBe(2)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('mergeJsonLines()', () => {
|
|
274
|
+
it('should merge multiple files', async () => {
|
|
275
|
+
const file1 = path.join(tempDir, 'file1.jsonl')
|
|
276
|
+
const file2 = path.join(tempDir, 'file2.jsonl')
|
|
277
|
+
|
|
278
|
+
await jsonlHelper.writeJsonLines(file1, [{ file: 1 }])
|
|
279
|
+
await jsonlHelper.writeJsonLines(file2, [{ file: 2 }])
|
|
280
|
+
|
|
281
|
+
const merged = await jsonlHelper.mergeJsonLines([file1, file2])
|
|
282
|
+
|
|
283
|
+
expect(merged.length).toBe(2)
|
|
284
|
+
expect(merged[0].file).toBe(1)
|
|
285
|
+
expect(merged[1].file).toBe(2)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should handle non-existent files gracefully', async () => {
|
|
289
|
+
const file1 = path.join(tempDir, 'file1.jsonl')
|
|
290
|
+
await jsonlHelper.writeJsonLines(file1, [{ a: 1 }])
|
|
291
|
+
|
|
292
|
+
const merged = await jsonlHelper.mergeJsonLines([
|
|
293
|
+
file1,
|
|
294
|
+
path.join(tempDir, 'nonexistent.jsonl')
|
|
295
|
+
])
|
|
296
|
+
|
|
297
|
+
expect(merged.length).toBe(1)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('isJsonLinesEmpty()', () => {
|
|
302
|
+
it('should return true for empty file', async () => {
|
|
303
|
+
await fs.writeFile(testFilePath, '')
|
|
304
|
+
|
|
305
|
+
const isEmpty = await jsonlHelper.isJsonLinesEmpty(testFilePath)
|
|
306
|
+
|
|
307
|
+
expect(isEmpty).toBe(true)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('should return false for file with content', async () => {
|
|
311
|
+
await jsonlHelper.writeJsonLines(testFilePath, [{ a: 1 }])
|
|
312
|
+
|
|
313
|
+
const isEmpty = await jsonlHelper.isJsonLinesEmpty(testFilePath)
|
|
314
|
+
|
|
315
|
+
expect(isEmpty).toBe(false)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('should return true for non-existent file', async () => {
|
|
319
|
+
const isEmpty = await jsonlHelper.isJsonLinesEmpty(path.join(tempDir, 'nonexistent.jsonl'))
|
|
320
|
+
|
|
321
|
+
expect(isEmpty).toBe(true)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
describe('getFileSizeMB()', () => {
|
|
326
|
+
it('should return file size in MB', async () => {
|
|
327
|
+
const content = 'x'.repeat(1024 * 1024) // 1MB
|
|
328
|
+
await fs.writeFile(testFilePath, content)
|
|
329
|
+
|
|
330
|
+
const sizeMB = await jsonlHelper.getFileSizeMB(testFilePath)
|
|
331
|
+
|
|
332
|
+
expect(sizeMB).toBeGreaterThan(0.9)
|
|
333
|
+
expect(sizeMB).toBeLessThan(1.1)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should return 0 for non-existent file', async () => {
|
|
337
|
+
const sizeMB = await jsonlHelper.getFileSizeMB(path.join(tempDir, 'nonexistent.jsonl'))
|
|
338
|
+
|
|
339
|
+
expect(sizeMB).toBe(0)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('rotateJsonLinesIfNeeded()', () => {
|
|
344
|
+
it('should not rotate if file is small', async () => {
|
|
345
|
+
await jsonlHelper.writeJsonLines(testFilePath, [{ a: 1 }])
|
|
346
|
+
|
|
347
|
+
const rotated = await jsonlHelper.rotateJsonLinesIfNeeded(testFilePath, 10)
|
|
348
|
+
|
|
349
|
+
expect(rotated).toBe(false)
|
|
350
|
+
const exists = await fs.access(testFilePath).then(() => true).catch(() => false)
|
|
351
|
+
expect(exists).toBe(true)
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('should rotate if file exceeds size limit', async () => {
|
|
355
|
+
// Create a large file (simulate with many entries)
|
|
356
|
+
const largeContent = Array.from({ length: 10000 }, (_, i) => ({
|
|
357
|
+
index: i,
|
|
358
|
+
data: 'x'.repeat(100)
|
|
359
|
+
}))
|
|
360
|
+
await jsonlHelper.writeJsonLines(testFilePath, largeContent)
|
|
361
|
+
|
|
362
|
+
// Use very small limit to force rotation
|
|
363
|
+
const rotated = await jsonlHelper.rotateJsonLinesIfNeeded(testFilePath, 0.001)
|
|
364
|
+
|
|
365
|
+
// File should be rotated (moved to archive)
|
|
366
|
+
expect(rotated).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('appendJsonLineWithRotation()', () => {
|
|
371
|
+
it('should append and rotate if needed', async () => {
|
|
372
|
+
// Create large file
|
|
373
|
+
const largeContent = Array.from({ length: 10000 }, (_, i) => ({
|
|
374
|
+
index: i,
|
|
375
|
+
data: 'x'.repeat(100)
|
|
376
|
+
}))
|
|
377
|
+
await jsonlHelper.writeJsonLines(testFilePath, largeContent)
|
|
378
|
+
|
|
379
|
+
await jsonlHelper.appendJsonLineWithRotation(testFilePath, { new: true }, 0.001)
|
|
380
|
+
|
|
381
|
+
// File should exist (either original or after rotation)
|
|
382
|
+
const files = await fs.readdir(tempDir)
|
|
383
|
+
expect(files.length).toBeGreaterThan(0)
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
})
|
|
387
|
+
|