aiseerr 1.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/AGENTS.md +14 -0
- package/CODE-REVIEW.md +333 -0
- package/PRD.md +397 -0
- package/README.md +80 -0
- package/ana-suggestions.md +105 -0
- package/dist/cli.js +37 -0
- package/package.json +37 -0
- package/src/cli.ts +118 -0
- package/src/commands/diff.ts +128 -0
- package/src/commands/env.ts +234 -0
- package/src/commands/init.ts +82 -0
- package/src/commands/read.ts +113 -0
- package/src/commands/scout.ts +93 -0
- package/src/commands/tree.ts +133 -0
- package/src/utils/output.ts +123 -0
- package/tests/cli.test.ts +172 -0
- package/tests/diff.test.ts +169 -0
- package/tests/env.test.ts +69 -0
- package/tests/init.test.ts +164 -0
- package/tests/output.test.ts +49 -0
- package/tests/read.test.ts +169 -0
- package/tests/scout.test.ts +248 -0
- package/tests/tree.test.ts +222 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +11 -0
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
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
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "CommonJS",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "./dist"
|
|
11
|
+
},
|
|
12
|
+
"include": [
|
|
13
|
+
"src/**/*"
|
|
14
|
+
]
|
|
15
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/cli.ts'],
|
|
5
|
+
format: ['cjs'],
|
|
6
|
+
target: 'node18',
|
|
7
|
+
clean: true,
|
|
8
|
+
minify: true,
|
|
9
|
+
// We bundle everything so there are zero dependencies at runtime
|
|
10
|
+
noExternal: [/(.*)/],
|
|
11
|
+
});
|