ai-xray 1.2.0 → 2.0.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/PRD.md +421 -280
- package/README.md +2 -2
- package/dist/cli.js +771 -0
- package/dist/cli.js.map +1 -0
- package/package.json +36 -24
- package/src/cli.ts +155 -118
- package/src/client.ts +203 -0
- package/src/commands/bench.ts +99 -0
- package/src/commands/compare.ts +76 -0
- package/src/commands/id.ts +139 -0
- package/src/commands/ping.ts +55 -0
- package/src/commands/probe.ts +136 -0
- package/src/commands/tokenize.ts +96 -0
- package/src/utils/http.ts +86 -0
- package/src/utils/output.ts +36 -123
- package/src/utils/timer.ts +75 -0
- package/tests/bench.test.ts +13 -0
- package/tests/client.test.ts +37 -0
- package/tests/compare.test.ts +24 -0
- package/tests/http.test.ts +12 -0
- package/tests/id.test.ts +13 -0
- package/tests/ping.test.ts +12 -0
- package/tests/probe.test.ts +13 -0
- package/tests/tokenize.test.ts +32 -0
- package/tsup.config.ts +11 -11
- package/vitest.config.ts +13 -0
- package/ana-suggestions.md +0 -105
- package/tests/cli.test.ts +0 -172
- package/tests/diff.test.ts +0 -169
- package/tests/env.test.ts +0 -69
- package/tests/init.test.ts +0 -164
- package/tests/output.test.ts +0 -49
- package/tests/read.test.ts +0 -169
- package/tests/scout.test.ts +0 -248
- package/tests/tree.test.ts +0 -222
package/tests/output.test.ts
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { estimateTokens, applyBudget } from '../src/utils/output';
|
|
3
|
-
|
|
4
|
-
describe('Output & Budget Manager', () => {
|
|
5
|
-
it('should estimate tokens roughly correctly', () => {
|
|
6
|
-
// 4 chars ~ 1 token
|
|
7
|
-
expect(estimateTokens('1234')).toBe(1);
|
|
8
|
-
expect(estimateTokens('12345678')).toBe(2);
|
|
9
|
-
expect(estimateTokens('')).toBe(1); // min 1
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('should pass through data when budget is undefined or unlimited', () => {
|
|
13
|
-
const data = { hello: 'world' };
|
|
14
|
-
const res = applyBudget(data, 0);
|
|
15
|
-
expect(res).toEqual({ hello: 'world' });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('should actually truncate data when budget is exceeded', () => {
|
|
19
|
-
const data = { a: 'b', c: 'a very long string that will exceed a tiny budget' };
|
|
20
|
-
// estimated tokens for this stringified JSON is roughly ~15-20
|
|
21
|
-
const res = applyBudget(data, 5);
|
|
22
|
-
|
|
23
|
-
// With actual truncation, data should be hard-truncated since budget is very small (5 tokens = 20 chars)
|
|
24
|
-
expect(res._meta.truncated).toBe(true);
|
|
25
|
-
expect(res._meta.budget).toBe(5);
|
|
26
|
-
expect(res._meta.hint).toContain('exceeded budget');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('should prune large fields when moderately over budget', () => {
|
|
30
|
-
const data = { name: 'test', items: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'] };
|
|
31
|
-
// Give a budget that's smaller than full data but large enough to keep some
|
|
32
|
-
const serialized = JSON.stringify(data);
|
|
33
|
-
const fullTokens = Math.ceil(serialized.length / 4);
|
|
34
|
-
const res = applyBudget(data, Math.floor(fullTokens * 0.6));
|
|
35
|
-
|
|
36
|
-
expect(res._meta.truncated).toBe(true);
|
|
37
|
-
expect(res._meta.hint).toContain('exceeded budget');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('should attach standard meta when budget is met', () => {
|
|
41
|
-
const data = { a: 'b' };
|
|
42
|
-
const res = applyBudget(data, 100);
|
|
43
|
-
|
|
44
|
-
expect(res.a).toBe('b');
|
|
45
|
-
expect(res._meta.truncated).toBe(false);
|
|
46
|
-
expect(res._meta.budget).toBe(100);
|
|
47
|
-
expect(res._meta.tokensEstimate).toBeGreaterThan(0);
|
|
48
|
-
});
|
|
49
|
-
});
|
package/tests/read.test.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { handleReadCommand } from '../src/commands/read';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
|
|
5
|
-
vi.mock('fs', async () => {
|
|
6
|
-
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
7
|
-
return {
|
|
8
|
-
...actual,
|
|
9
|
-
existsSync: vi.fn(),
|
|
10
|
-
readFileSync: vi.fn(),
|
|
11
|
-
statSync: vi.fn()
|
|
12
|
-
};
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
const mockExistsSync = fs.existsSync as any;
|
|
16
|
-
const mockReadFileSync = fs.readFileSync as any;
|
|
17
|
-
const mockStatSync = fs.statSync as any;
|
|
18
|
-
|
|
19
|
-
describe('Read Command', () => {
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.clearAllMocks();
|
|
22
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should throw when no files are provided', () => {
|
|
26
|
-
expect(() => handleReadCommand([])).toThrow('No files provided to read.');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('should read a single file and return content', () => {
|
|
30
|
-
mockExistsSync.mockReturnValue(true);
|
|
31
|
-
mockReadFileSync.mockReturnValue('line1\nline2\nline3\n');
|
|
32
|
-
|
|
33
|
-
const result = handleReadCommand(['test.ts']);
|
|
34
|
-
expect(result.file).toBe('test.ts');
|
|
35
|
-
expect(result.content).toBe('line1\nline2\nline3\n');
|
|
36
|
-
expect(result.lines).toBe(4); // 3 lines + trailing newline = 4 elements from split
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('should handle missing files gracefully', () => {
|
|
40
|
-
mockExistsSync.mockReturnValue(false);
|
|
41
|
-
|
|
42
|
-
const result = handleReadCommand(['missing.ts']);
|
|
43
|
-
expect(result.file).toBe('missing.ts');
|
|
44
|
-
expect(result.error).toBe('File not found');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('should handle directory targets', () => {
|
|
48
|
-
mockExistsSync.mockReturnValue(true);
|
|
49
|
-
mockStatSync.mockReturnValue({ isDirectory: () => true });
|
|
50
|
-
|
|
51
|
-
const result = handleReadCommand(['somedir']);
|
|
52
|
-
expect(result.error).toBe('Target is a directory, not a file');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should extract line ranges with --lines flag', () => {
|
|
56
|
-
mockExistsSync.mockReturnValue(true);
|
|
57
|
-
mockReadFileSync.mockReturnValue('line1\nline2\nline3\nline4\nline5\n');
|
|
58
|
-
|
|
59
|
-
const result = handleReadCommand(['file.ts', '--lines=2-4']);
|
|
60
|
-
expect(result.content).toBe('line2\nline3\nline4');
|
|
61
|
-
expect(result.context.range).toEqual([2, 4]);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('should extract JSON keys with --keys flag', () => {
|
|
65
|
-
mockExistsSync.mockReturnValue(true);
|
|
66
|
-
const jsonContent = JSON.stringify({
|
|
67
|
-
name: 'test',
|
|
68
|
-
version: '1.0.0',
|
|
69
|
-
dependencies: { react: '18.0.0' },
|
|
70
|
-
devDependencies: { vitest: '1.0.0' }
|
|
71
|
-
});
|
|
72
|
-
mockReadFileSync.mockReturnValue(jsonContent);
|
|
73
|
-
|
|
74
|
-
const result = handleReadCommand(['package.json', '--keys=name,dependencies']);
|
|
75
|
-
expect(result.content).toEqual({
|
|
76
|
-
name: 'test',
|
|
77
|
-
dependencies: { react: '18.0.0' }
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('should fallback to raw text for --keys on invalid JSON', () => {
|
|
82
|
-
mockExistsSync.mockReturnValue(true);
|
|
83
|
-
mockReadFileSync.mockReturnValue('not valid json {{{');
|
|
84
|
-
|
|
85
|
-
const result = handleReadCommand(['data.json', '--keys=name']);
|
|
86
|
-
// Should fallback to raw content string since JSON.parse fails
|
|
87
|
-
expect(typeof result.content).toBe('string');
|
|
88
|
-
expect(result.content).toBe('not valid json {{{');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should read multiple files and return array', () => {
|
|
92
|
-
mockExistsSync.mockReturnValue(true);
|
|
93
|
-
mockReadFileSync.mockImplementation((path: string) => {
|
|
94
|
-
if (path === 'a.ts') return 'content a';
|
|
95
|
-
if (path === 'b.ts') return 'content b';
|
|
96
|
-
return '';
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const result = handleReadCommand(['a.ts', 'b.ts']);
|
|
100
|
-
expect(result.files).toHaveLength(2);
|
|
101
|
-
expect(result.files[0].file).toBe('a.ts');
|
|
102
|
-
expect(result.files[1].file).toBe('b.ts');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should handle mixed existing and missing files', () => {
|
|
106
|
-
mockExistsSync.mockImplementation((p: string) => p === 'exists.ts');
|
|
107
|
-
mockReadFileSync.mockReturnValue('content');
|
|
108
|
-
|
|
109
|
-
const result = handleReadCommand(['exists.ts', 'missing.ts']);
|
|
110
|
-
expect(result.files).toHaveLength(2);
|
|
111
|
-
expect(result.files[0].content).toBe('content');
|
|
112
|
-
expect(result.files[1].error).toBe('File not found');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('should truncate content when budget is exceeded', () => {
|
|
116
|
-
mockExistsSync.mockReturnValue(true);
|
|
117
|
-
// Create a large content string (over budget)
|
|
118
|
-
const bigContent = 'x'.repeat(1000);
|
|
119
|
-
mockReadFileSync.mockReturnValue(bigContent);
|
|
120
|
-
|
|
121
|
-
// Budget of 50 tokens = ~200 chars
|
|
122
|
-
const result = handleReadCommand(['big.ts', '--budget=50'], 50);
|
|
123
|
-
// The content should be truncated
|
|
124
|
-
if (result.truncated) {
|
|
125
|
-
expect(result.content.length).toBeLessThan(bigContent.length);
|
|
126
|
-
expect(result.content).toContain('[TRUNCATED BY BUDGET]');
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('should skip file entirely if budget is exhausted', () => {
|
|
131
|
-
mockExistsSync.mockReturnValue(true);
|
|
132
|
-
const bigContent = 'x'.repeat(2000);
|
|
133
|
-
mockReadFileSync.mockReturnValue(bigContent);
|
|
134
|
-
|
|
135
|
-
// Very tiny budget
|
|
136
|
-
const result = handleReadCommand(['file1.ts', 'file2.ts'], 5);
|
|
137
|
-
// With a budget of 5 tokens (~20 chars), first file gets truncated or error,
|
|
138
|
-
// second file should be skipped
|
|
139
|
-
expect(result.files).toBeDefined();
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it('should not apply --keys extraction to non-JSON files', () => {
|
|
143
|
-
mockExistsSync.mockReturnValue(true);
|
|
144
|
-
mockReadFileSync.mockReturnValue('const x = 1;');
|
|
145
|
-
|
|
146
|
-
// --keys only works on .json files
|
|
147
|
-
const result = handleReadCommand(['code.ts', '--keys=name']);
|
|
148
|
-
expect(typeof result.content).toBe('string');
|
|
149
|
-
expect(result.content).toBe('const x = 1;');
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('should handle line range that exceeds file length', () => {
|
|
153
|
-
mockExistsSync.mockReturnValue(true);
|
|
154
|
-
mockReadFileSync.mockReturnValue('line1\nline2\nline3');
|
|
155
|
-
|
|
156
|
-
const result = handleReadCommand(['file.ts', '--lines=1-100']);
|
|
157
|
-
expect(result.content).toBe('line1\nline2\nline3');
|
|
158
|
-
expect(result.context.range[1]).toBe(3); // capped to actual line count
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('should handle file read errors gracefully', () => {
|
|
162
|
-
mockExistsSync.mockReturnValue(true);
|
|
163
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
164
|
-
mockReadFileSync.mockImplementation(() => { throw new Error('Permission denied'); });
|
|
165
|
-
|
|
166
|
-
const result = handleReadCommand(['protected.ts']);
|
|
167
|
-
expect(result.error).toBe('Permission denied');
|
|
168
|
-
});
|
|
169
|
-
});
|
package/tests/scout.test.ts
DELETED
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { handleScoutCommand } from '../src/commands/scout';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
import * as child_process from 'child_process';
|
|
5
|
-
|
|
6
|
-
vi.mock('fs', async () => {
|
|
7
|
-
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
8
|
-
return {
|
|
9
|
-
...actual,
|
|
10
|
-
existsSync: vi.fn(),
|
|
11
|
-
readFileSync: vi.fn(),
|
|
12
|
-
readdirSync: vi.fn(),
|
|
13
|
-
statSync: vi.fn()
|
|
14
|
-
};
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
vi.mock('child_process', () => ({
|
|
18
|
-
execFileSync: vi.fn()
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const mockExistsSync = fs.existsSync as any;
|
|
22
|
-
const mockReadFileSync = fs.readFileSync as any;
|
|
23
|
-
const mockReaddirSync = fs.readdirSync as any;
|
|
24
|
-
const mockStatSync = fs.statSync as any;
|
|
25
|
-
const mockExecFileSync = child_process.execFileSync as any;
|
|
26
|
-
|
|
27
|
-
function setupBasicProject() {
|
|
28
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
29
|
-
const norm = p.replace(/\\/g, '/');
|
|
30
|
-
if (norm.endsWith('package.json')) return true;
|
|
31
|
-
if (norm.endsWith('.git')) return true;
|
|
32
|
-
if (norm.endsWith('README.md')) return true;
|
|
33
|
-
if (norm.endsWith('.gitignore')) return false;
|
|
34
|
-
if (norm.endsWith('.cursorrules')) return false;
|
|
35
|
-
if (norm.endsWith('CLAUDE.md')) return false;
|
|
36
|
-
if (norm.endsWith('AGENTS.md')) return false;
|
|
37
|
-
if (norm.endsWith('copilot-instructions.md')) return false;
|
|
38
|
-
if (norm.endsWith('Readme.md')) return false;
|
|
39
|
-
if (norm.endsWith('readme.md')) return false;
|
|
40
|
-
return true;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
44
|
-
const norm = p.replace(/\\/g, '/');
|
|
45
|
-
if (norm.endsWith('package.json')) {
|
|
46
|
-
return JSON.stringify({
|
|
47
|
-
name: 'test-project',
|
|
48
|
-
dependencies: { react: '18.0.0' },
|
|
49
|
-
devDependencies: { vitest: '1.0.0' }
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
if (norm.endsWith('README.md')) return '# Test Project\n\nA test project.';
|
|
53
|
-
return '';
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
|
|
57
|
-
if (args.includes('--version')) return '9.0.0\n';
|
|
58
|
-
if (args.includes('--abbrev-ref')) return 'main\n';
|
|
59
|
-
if (args.includes('--porcelain')) return '';
|
|
60
|
-
if (args.includes('remote.origin.url')) return 'https://github.com/test/test.git\n';
|
|
61
|
-
if (args.includes('--left-right')) return '0\t0\n';
|
|
62
|
-
return '';
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
mockReaddirSync.mockReturnValue(['src', 'package.json', 'README.md']);
|
|
66
|
-
mockStatSync.mockImplementation((p: string, opts?: any) => {
|
|
67
|
-
const norm = p.replace(/\\/g, '/');
|
|
68
|
-
if (norm.endsWith('src')) return { isDirectory: () => true };
|
|
69
|
-
return { isDirectory: () => false };
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
describe('Scout Command', () => {
|
|
74
|
-
beforeEach(() => {
|
|
75
|
-
vi.clearAllMocks();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should return a combined result with env, tree, readme, diff sections', () => {
|
|
79
|
-
setupBasicProject();
|
|
80
|
-
|
|
81
|
-
const result = handleScoutCommand();
|
|
82
|
-
expect(result.env).toBeDefined();
|
|
83
|
-
expect(result.tree).toBeDefined();
|
|
84
|
-
expect(result.readme).toBeDefined();
|
|
85
|
-
expect(result.diff).toBeDefined();
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should include readme content (truncated to 60 lines)', () => {
|
|
89
|
-
setupBasicProject();
|
|
90
|
-
|
|
91
|
-
const result = handleScoutCommand();
|
|
92
|
-
expect(result.readme).toContain('# Test Project');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('should truncate long readmes to 60 lines', () => {
|
|
96
|
-
setupBasicProject();
|
|
97
|
-
// Override README to be very long
|
|
98
|
-
const longReadme = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`).join('\n');
|
|
99
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
100
|
-
const norm = p.replace(/\\/g, '/');
|
|
101
|
-
if (norm.endsWith('package.json')) {
|
|
102
|
-
return JSON.stringify({ name: 'test', dependencies: {} });
|
|
103
|
-
}
|
|
104
|
-
if (norm.endsWith('README.md')) return longReadme;
|
|
105
|
-
return '';
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
const result = handleScoutCommand();
|
|
109
|
-
expect(result.readme).toContain('[TRUNCATED TO 60 LINES]');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('should handle missing env gracefully (error in env)', () => {
|
|
113
|
-
// No package.json, no git
|
|
114
|
-
mockExistsSync.mockReturnValue(false);
|
|
115
|
-
mockReadFileSync.mockReturnValue('');
|
|
116
|
-
mockReaddirSync.mockReturnValue([]);
|
|
117
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
118
|
-
|
|
119
|
-
const result = handleScoutCommand();
|
|
120
|
-
// env should still be present (returns a "not node project" result)
|
|
121
|
-
expect(result.env).toBeDefined();
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('should handle missing readme', () => {
|
|
125
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
126
|
-
const norm = p.replace(/\\/g, '/');
|
|
127
|
-
if (norm.endsWith('README.md') || norm.endsWith('Readme.md') || norm.endsWith('readme.md')) return false;
|
|
128
|
-
if (norm.endsWith('package.json')) return true;
|
|
129
|
-
if (norm.endsWith('.git')) return false;
|
|
130
|
-
if (norm.endsWith('.gitignore')) return false;
|
|
131
|
-
if (norm.endsWith('.cursorrules')) return false;
|
|
132
|
-
if (norm.endsWith('CLAUDE.md')) return false;
|
|
133
|
-
if (norm.endsWith('AGENTS.md')) return false;
|
|
134
|
-
if (norm.endsWith('copilot-instructions.md')) return false;
|
|
135
|
-
return true;
|
|
136
|
-
});
|
|
137
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
138
|
-
if (p.includes('package.json')) return JSON.stringify({ name: 'test' });
|
|
139
|
-
return '';
|
|
140
|
-
});
|
|
141
|
-
mockReaddirSync.mockReturnValue([]);
|
|
142
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
143
|
-
|
|
144
|
-
const result = handleScoutCommand();
|
|
145
|
-
expect(result.readme).toBeNull();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('should set diff to null when not a git repo', () => {
|
|
149
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
150
|
-
const norm = p.replace(/\\/g, '/');
|
|
151
|
-
if (norm.endsWith('.git')) return false;
|
|
152
|
-
if (norm.endsWith('package.json')) return true;
|
|
153
|
-
if (norm.endsWith('.gitignore')) return false;
|
|
154
|
-
if (norm.endsWith('.cursorrules')) return false;
|
|
155
|
-
if (norm.endsWith('CLAUDE.md')) return false;
|
|
156
|
-
if (norm.endsWith('AGENTS.md')) return false;
|
|
157
|
-
if (norm.endsWith('copilot-instructions.md')) return false;
|
|
158
|
-
if (norm.endsWith('README.md') || norm.endsWith('Readme.md') || norm.endsWith('readme.md')) return false;
|
|
159
|
-
return true;
|
|
160
|
-
});
|
|
161
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
162
|
-
if (p.includes('package.json')) return JSON.stringify({ name: 'test' });
|
|
163
|
-
return '';
|
|
164
|
-
});
|
|
165
|
-
mockReaddirSync.mockReturnValue([]);
|
|
166
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
167
|
-
|
|
168
|
-
const result = handleScoutCommand();
|
|
169
|
-
expect(result.diff).toBeNull();
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('should strip full diffs from scout diff output (stagedDiff/unstagedDiff removed)', () => {
|
|
173
|
-
setupBasicProject();
|
|
174
|
-
// Even if handleDiffCommand returned these, scout should delete them
|
|
175
|
-
const result = handleScoutCommand();
|
|
176
|
-
if (result.diff) {
|
|
177
|
-
expect(result.diff.stagedDiff).toBeUndefined();
|
|
178
|
-
expect(result.diff.unstagedDiff).toBeUndefined();
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('should set agentRules to null when no rule files exist', () => {
|
|
183
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
184
|
-
const norm = p.replace(/\\/g, '/');
|
|
185
|
-
if (norm.endsWith('.cursorrules')) return false;
|
|
186
|
-
if (norm.endsWith('CLAUDE.md')) return false;
|
|
187
|
-
if (norm.endsWith('AGENTS.md')) return false;
|
|
188
|
-
if (norm.endsWith('copilot-instructions.md')) return false;
|
|
189
|
-
if (norm.endsWith('README.md') || norm.endsWith('Readme.md') || norm.endsWith('readme.md')) return false;
|
|
190
|
-
if (norm.endsWith('package.json')) return true;
|
|
191
|
-
if (norm.endsWith('.git')) return false;
|
|
192
|
-
if (norm.endsWith('.gitignore')) return false;
|
|
193
|
-
return true;
|
|
194
|
-
});
|
|
195
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
196
|
-
if (p.includes('package.json')) return JSON.stringify({ name: 'test' });
|
|
197
|
-
return '';
|
|
198
|
-
});
|
|
199
|
-
mockReaddirSync.mockReturnValue([]);
|
|
200
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
201
|
-
|
|
202
|
-
const result = handleScoutCommand();
|
|
203
|
-
expect(result.agentRules).toBeNull();
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('should detect agent rules from .cursorrules', () => {
|
|
207
|
-
setupBasicProject();
|
|
208
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
209
|
-
const norm = p.replace(/\\/g, '/');
|
|
210
|
-
if (norm.endsWith('.cursorrules')) return true;
|
|
211
|
-
if (norm.endsWith('package.json')) return true;
|
|
212
|
-
if (norm.endsWith('.git')) return true;
|
|
213
|
-
if (norm.endsWith('README.md')) return true;
|
|
214
|
-
if (norm.endsWith('.gitignore')) return false;
|
|
215
|
-
if (norm.endsWith('CLAUDE.md')) return false;
|
|
216
|
-
if (norm.endsWith('AGENTS.md')) return false;
|
|
217
|
-
if (norm.endsWith('copilot-instructions.md')) return false;
|
|
218
|
-
if (norm.endsWith('Readme.md') || norm.endsWith('readme.md')) return false;
|
|
219
|
-
return true;
|
|
220
|
-
});
|
|
221
|
-
mockReadFileSync.mockImplementation((p: string) => {
|
|
222
|
-
const norm = p.replace(/\\/g, '/');
|
|
223
|
-
if (norm.endsWith('.cursorrules')) return 'Use TypeScript strict mode';
|
|
224
|
-
if (norm.endsWith('package.json')) return JSON.stringify({ name: 'test', dependencies: {} });
|
|
225
|
-
if (norm.endsWith('README.md')) return '# Test';
|
|
226
|
-
return '';
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const result = handleScoutCommand();
|
|
230
|
-
expect(result.agentRules).toBeDefined();
|
|
231
|
-
expect(result.agentRules.source).toBe('.cursorrules');
|
|
232
|
-
expect(result.agentRules.content).toContain('TypeScript strict mode');
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it('should pass budget allocation to tree (approx 50%)', () => {
|
|
236
|
-
setupBasicProject();
|
|
237
|
-
// We verify that tree gets called with a budget argument
|
|
238
|
-
// by checking the result completes without error with a budget
|
|
239
|
-
const result = handleScoutCommand(1000);
|
|
240
|
-
expect(result.tree).toBeDefined();
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('should set workspaces to null when not present', () => {
|
|
244
|
-
setupBasicProject();
|
|
245
|
-
const result = handleScoutCommand();
|
|
246
|
-
expect(result.workspaces).toBeNull();
|
|
247
|
-
});
|
|
248
|
-
});
|
package/tests/tree.test.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { handleTreeCommand } from '../src/commands/tree';
|
|
3
|
-
import * as fs from 'fs';
|
|
4
|
-
|
|
5
|
-
vi.mock('fs', async () => {
|
|
6
|
-
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
7
|
-
return {
|
|
8
|
-
...actual,
|
|
9
|
-
existsSync: vi.fn(),
|
|
10
|
-
readFileSync: vi.fn(),
|
|
11
|
-
readdirSync: vi.fn(),
|
|
12
|
-
statSync: vi.fn()
|
|
13
|
-
};
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const mockExistsSync = fs.existsSync as any;
|
|
17
|
-
const mockReadFileSync = fs.readFileSync as any;
|
|
18
|
-
const mockReaddirSync = fs.readdirSync as any;
|
|
19
|
-
const mockStatSync = fs.statSync as any;
|
|
20
|
-
|
|
21
|
-
// Helper to build a virtual filesystem
|
|
22
|
-
function setupVirtualFS(tree: Record<string, 'dir' | 'file' | null>) {
|
|
23
|
-
mockExistsSync.mockImplementation((p: string) => {
|
|
24
|
-
// Normalize path separators for matching
|
|
25
|
-
const normalized = p.replace(/\\/g, '/');
|
|
26
|
-
// Check .gitignore existence
|
|
27
|
-
if (normalized.endsWith('.gitignore')) {
|
|
28
|
-
return tree['.gitignore'] !== undefined;
|
|
29
|
-
}
|
|
30
|
-
return true; // default: exists
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
mockReaddirSync.mockImplementation((dirPath: string) => {
|
|
34
|
-
const normalized = dirPath.replace(/\\/g, '/');
|
|
35
|
-
const entries: string[] = [];
|
|
36
|
-
for (const [key, type] of Object.entries(tree)) {
|
|
37
|
-
// Get entries that are direct children of dirPath
|
|
38
|
-
const parent = key.substring(0, key.lastIndexOf('/'));
|
|
39
|
-
const name = key.substring(key.lastIndexOf('/') + 1);
|
|
40
|
-
if (!key.includes('/') && type !== null) {
|
|
41
|
-
// Root-level entries
|
|
42
|
-
entries.push(key);
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return entries;
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
mockStatSync.mockImplementation((p: string, opts?: any) => {
|
|
49
|
-
const normalized = p.replace(/\\/g, '/');
|
|
50
|
-
const name = normalized.substring(normalized.lastIndexOf('/') + 1);
|
|
51
|
-
const entry = tree[name];
|
|
52
|
-
if (entry === 'dir') return { isDirectory: () => true };
|
|
53
|
-
if (entry === 'file') return { isDirectory: () => false };
|
|
54
|
-
// Default: file
|
|
55
|
-
return { isDirectory: () => false };
|
|
56
|
-
});
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe('Tree Command', () => {
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
vi.clearAllMocks();
|
|
62
|
-
// Default: no .gitignore
|
|
63
|
-
mockReadFileSync.mockReturnValue('');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('should throw when target directory does not exist', () => {
|
|
67
|
-
mockExistsSync.mockReturnValue(false);
|
|
68
|
-
expect(() => handleTreeCommand(['nonexistent'])).toThrow('Directory not found: nonexistent');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should return tree structure with files and directories', () => {
|
|
72
|
-
mockExistsSync.mockReturnValue(true);
|
|
73
|
-
mockReadFileSync.mockReturnValue(''); // empty .gitignore
|
|
74
|
-
|
|
75
|
-
// Root has: src/ (dir), README.md (file), package.json (file)
|
|
76
|
-
mockReaddirSync.mockImplementation((dirPath: string) => {
|
|
77
|
-
const normalized = dirPath.replace(/\\/g, '/');
|
|
78
|
-
if (normalized.endsWith('src')) return ['index.ts', 'utils.ts'];
|
|
79
|
-
return ['src', 'README.md', 'package.json'];
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
mockStatSync.mockImplementation((p: string, opts?: any) => {
|
|
83
|
-
const normalized = p.replace(/\\/g, '/');
|
|
84
|
-
if (normalized.endsWith('src')) return { isDirectory: () => true };
|
|
85
|
-
return { isDirectory: () => false };
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const result = handleTreeCommand([]);
|
|
89
|
-
expect(result.root).toBe('.');
|
|
90
|
-
expect(result.depth).toBe(3);
|
|
91
|
-
expect(result.structure).toBeDefined();
|
|
92
|
-
expect(result.stats.totalFiles).toBeGreaterThan(0);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('should respect --depth flag', () => {
|
|
96
|
-
mockExistsSync.mockReturnValue(true);
|
|
97
|
-
mockReadFileSync.mockReturnValue('');
|
|
98
|
-
|
|
99
|
-
mockReaddirSync.mockImplementation((dirPath: string) => {
|
|
100
|
-
const norm = dirPath.replace(/\\/g, '/');
|
|
101
|
-
if (norm.endsWith('deep')) return ['deeper'];
|
|
102
|
-
if (norm.endsWith('deeper')) return ['deepest.txt'];
|
|
103
|
-
return ['deep'];
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
mockStatSync.mockImplementation((p: string) => {
|
|
107
|
-
const norm = p.replace(/\\/g, '/');
|
|
108
|
-
if (norm.endsWith('deep') || norm.endsWith('deeper')) return { isDirectory: () => true };
|
|
109
|
-
return { isDirectory: () => false };
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// depth=1 should not traverse into subdirs beyond first level
|
|
113
|
-
const result = handleTreeCommand(['--depth=1']);
|
|
114
|
-
expect(result.depth).toBe(1);
|
|
115
|
-
// 'deep/' at depth 1, its children are at depth 2 which exceeds limit
|
|
116
|
-
expect(result.structure['deep/']).toEqual({ _more: true });
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('should ignore default directories (node_modules, .git, dist)', () => {
|
|
120
|
-
mockExistsSync.mockReturnValue(true);
|
|
121
|
-
mockReadFileSync.mockReturnValue('');
|
|
122
|
-
|
|
123
|
-
mockReaddirSync.mockReturnValue(['src', 'node_modules', '.git', 'dist', 'index.ts']);
|
|
124
|
-
|
|
125
|
-
mockStatSync.mockImplementation((p: string) => {
|
|
126
|
-
const name = p.replace(/\\/g, '/').split('/').pop();
|
|
127
|
-
if (['src', 'node_modules', '.git', 'dist'].includes(name!)) {
|
|
128
|
-
return { isDirectory: () => true };
|
|
129
|
-
}
|
|
130
|
-
return { isDirectory: () => false };
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
const result = handleTreeCommand([]);
|
|
134
|
-
// node_modules, .git, dist should be in ignored list
|
|
135
|
-
expect(result.stats.ignored).toContain('node_modules');
|
|
136
|
-
expect(result.stats.ignored).toContain('.git');
|
|
137
|
-
expect(result.stats.ignored).toContain('dist');
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('should respect .gitignore patterns', () => {
|
|
141
|
-
mockExistsSync.mockReturnValue(true);
|
|
142
|
-
// .gitignore ignores "*.log" and "build/"
|
|
143
|
-
mockReadFileSync.mockReturnValue('*.log\nbuild/\n');
|
|
144
|
-
|
|
145
|
-
mockReaddirSync.mockReturnValue(['src', 'build', 'app.ts', 'debug.log']);
|
|
146
|
-
|
|
147
|
-
mockStatSync.mockImplementation((p: string) => {
|
|
148
|
-
const name = p.replace(/\\/g, '/').split('/').pop();
|
|
149
|
-
if (['src', 'build'].includes(name!)) return { isDirectory: () => true };
|
|
150
|
-
return { isDirectory: () => false };
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
const result = handleTreeCommand([]);
|
|
154
|
-
// build should be ignored (from .gitignore), debug.log should be ignored (*.log pattern)
|
|
155
|
-
expect(result.stats.ignored).toContain('build');
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it('should use _files compression for directories with only files', () => {
|
|
159
|
-
mockExistsSync.mockReturnValue(true);
|
|
160
|
-
mockReadFileSync.mockReturnValue('');
|
|
161
|
-
|
|
162
|
-
// Root has only files
|
|
163
|
-
mockReaddirSync.mockReturnValue(['a.ts', 'b.ts', 'c.ts']);
|
|
164
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
165
|
-
|
|
166
|
-
const result = handleTreeCommand([]);
|
|
167
|
-
// When a dir has only files, it uses the _files array optimization
|
|
168
|
-
expect(result.structure._files).toEqual(['a.ts', 'b.ts', 'c.ts']);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('should handle empty directories', () => {
|
|
172
|
-
mockExistsSync.mockReturnValue(true);
|
|
173
|
-
mockReadFileSync.mockReturnValue('');
|
|
174
|
-
mockReaddirSync.mockReturnValue([]);
|
|
175
|
-
|
|
176
|
-
const result = handleTreeCommand([]);
|
|
177
|
-
expect(result.structure).toEqual({});
|
|
178
|
-
expect(result.stats.totalFiles).toBe(0);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('should accept custom target directory', () => {
|
|
182
|
-
mockExistsSync.mockReturnValue(true);
|
|
183
|
-
mockReadFileSync.mockReturnValue('');
|
|
184
|
-
mockReaddirSync.mockReturnValue(['app.ts']);
|
|
185
|
-
mockStatSync.mockReturnValue({ isDirectory: () => false });
|
|
186
|
-
|
|
187
|
-
const result = handleTreeCommand(['src']);
|
|
188
|
-
expect(result.root).toBe('src');
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should handle permission errors on directories', () => {
|
|
192
|
-
mockExistsSync.mockReturnValue(true);
|
|
193
|
-
mockReadFileSync.mockReturnValue('');
|
|
194
|
-
mockReaddirSync.mockImplementation(() => { throw new Error('EACCES'); });
|
|
195
|
-
|
|
196
|
-
const result = handleTreeCommand([]);
|
|
197
|
-
// Should return empty structure, not throw
|
|
198
|
-
expect(result.structure).toEqual({});
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it('should handle mixed directories and files', () => {
|
|
202
|
-
mockExistsSync.mockReturnValue(true);
|
|
203
|
-
mockReadFileSync.mockReturnValue('');
|
|
204
|
-
|
|
205
|
-
mockReaddirSync.mockImplementation((dirPath: string) => {
|
|
206
|
-
const norm = dirPath.replace(/\\/g, '/');
|
|
207
|
-
if (norm.endsWith('utils')) return ['helper.ts'];
|
|
208
|
-
return ['utils', 'index.ts', 'config.ts'];
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
mockStatSync.mockImplementation((p: string) => {
|
|
212
|
-
const norm = p.replace(/\\/g, '/');
|
|
213
|
-
if (norm.endsWith('utils')) return { isDirectory: () => true };
|
|
214
|
-
return { isDirectory: () => false };
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const result = handleTreeCommand([]);
|
|
218
|
-
// Mixed dir: files should be individual keys, not in _files
|
|
219
|
-
expect(result.structure['utils/']).toBeDefined();
|
|
220
|
-
expect(result.structure['index.ts']).toBeDefined();
|
|
221
|
-
});
|
|
222
|
-
});
|