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.
@@ -0,0 +1,93 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { handleEnvCommand } from './env';
4
+ import { handleTreeCommand } from './tree';
5
+ import { handleDiffCommand } from './diff';
6
+
7
+ export function handleScoutCommand(budget?: number): any {
8
+ const result: any = {};
9
+ const cwd = process.cwd();
10
+
11
+ // 1. Env (Always)
12
+ try {
13
+ result.env = handleEnvCommand();
14
+ } catch (e: any) {
15
+ result.env = { error: e.message };
16
+ }
17
+
18
+ // 2. Tree (Always, depth=2)
19
+ try {
20
+ // If a global budget is set, allocate approximately half of it to the tree
21
+ // since tree and diff are typically the largest components.
22
+ const treeBudget = budget ? Math.floor(budget * 0.5) : undefined;
23
+ result.tree = handleTreeCommand(['--depth=2'], treeBudget);
24
+ } catch (e: any) {
25
+ result.tree = { error: e.message };
26
+ }
27
+
28
+ // 3. Readme
29
+ const readmePaths = ['README.md', 'Readme.md', 'readme.md'];
30
+ let readmeFound = false;
31
+ for (const p of readmePaths) {
32
+ const fullPath = join(cwd, p);
33
+ if (existsSync(fullPath)) {
34
+ try {
35
+ const content = readFileSync(fullPath, 'utf-8');
36
+ const lines = content.split('\n');
37
+ // Truncate to first 60 lines for a quick summary
38
+ if (lines.length > 60) {
39
+ result.readme = lines.slice(0, 60).join('\n') + '\n...[TRUNCATED TO 60 LINES]';
40
+ } else {
41
+ result.readme = content;
42
+ }
43
+ readmeFound = true;
44
+ break;
45
+ } catch {
46
+ // ignore
47
+ }
48
+ }
49
+ }
50
+ if (!readmeFound) result.readme = null;
51
+
52
+ // 4. Agent Rules
53
+ const rulePaths = ['.cursorrules', 'CLAUDE.md', 'AGENTS.md', '.github/copilot-instructions.md'];
54
+ let rulesFound = false;
55
+ for (const p of rulePaths) {
56
+ const fullPath = join(cwd, p);
57
+ if (existsSync(fullPath)) {
58
+ try {
59
+ result.agentRules = {
60
+ source: p,
61
+ // Limit rules to 500 lines to prevent runaway tokens, though usually they are small
62
+ content: readFileSync(fullPath, 'utf-8').split('\n').slice(0, 500).join('\n')
63
+ };
64
+ rulesFound = true;
65
+ break;
66
+ } catch {
67
+ // ignore
68
+ }
69
+ }
70
+ }
71
+ if (!rulesFound) result.agentRules = null;
72
+
73
+ // 5. Diff (Stat only, no full diff payload to save tokens in a generic scout)
74
+ try {
75
+ const diffRes = handleDiffCommand([], undefined);
76
+ // Remove the full diffs if they somehow got in
77
+ delete diffRes.stagedDiff;
78
+ delete diffRes.unstagedDiff;
79
+ result.diff = diffRes;
80
+ } catch (e: any) {
81
+ // Expected to fail cleanly if not a git repository
82
+ result.diff = null;
83
+ }
84
+
85
+ // 6. Workspaces
86
+ if (result.env && result.env.workspaces) {
87
+ result.workspaces = result.env.workspaces;
88
+ } else {
89
+ result.workspaces = null;
90
+ }
91
+
92
+ return result;
93
+ }
@@ -0,0 +1,133 @@
1
+ import { readdirSync, statSync, existsSync, readFileSync } from 'fs';
2
+ import { join, basename } from 'path';
3
+
4
+ function buildIgnoreMatcher(cwd: string): (path: string) => boolean {
5
+ const ignoreFile = join(cwd, '.gitignore');
6
+ const defaults = ['node_modules', '.git', 'dist', '.next', 'build', 'out', 'coverage'];
7
+
8
+ let patterns: string[] = [];
9
+ if (existsSync(ignoreFile)) {
10
+ const lines = readFileSync(ignoreFile, 'utf-8').split('\n');
11
+ patterns = lines
12
+ .map(l => l.trim())
13
+ .filter(l => l && !l.startsWith('#'))
14
+ .map(l => l.replace(/\/$/, '')); // normalize dir ignores
15
+ }
16
+
17
+ const allIgnores = new Set([...defaults, ...patterns]);
18
+
19
+ return (path: string) => {
20
+ const name = basename(path);
21
+ // Rough match for v1.0, sufficient for 95% of use cases
22
+ if (allIgnores.has(name)) return true;
23
+ for (const pattern of allIgnores) {
24
+ if (pattern.startsWith('*') && name.endsWith(pattern.slice(1))) return true;
25
+ }
26
+ return false;
27
+ };
28
+ }
29
+
30
+ export function handleTreeCommand(args: string[], budget?: number): any {
31
+ let targetDir = '.';
32
+ let maxDepth = 3;
33
+
34
+ for (const arg of args) {
35
+ if (arg.startsWith('--depth=')) {
36
+ maxDepth = parseInt(arg.split('=')[1], 10) || 3;
37
+ } else if (!arg.startsWith('--')) {
38
+ targetDir = arg;
39
+ }
40
+ }
41
+
42
+ const fullTarget = join(process.cwd(), targetDir);
43
+ if (!existsSync(fullTarget)) {
44
+ throw new Error(`Directory not found: ${targetDir}`);
45
+ }
46
+
47
+ const isIgnored = buildIgnoreMatcher(process.cwd());
48
+ const stats = {
49
+ totalFiles: 0,
50
+ displayed: 0,
51
+ ignoredDirs: new Set<string>()
52
+ };
53
+
54
+ function traverse(currentPath: string, currentDepth: number): any {
55
+ if (currentDepth > maxDepth) {
56
+ return { _more: true }; // Hit depth limit
57
+ }
58
+
59
+ let entries: string[] = [];
60
+ try {
61
+ entries = readdirSync(currentPath);
62
+ } catch {
63
+ return {}; // Permission issues
64
+ }
65
+
66
+ const result: Record<string, any> = {};
67
+ const filesOnly: string[] = [];
68
+ let hiddenCount = 0;
69
+
70
+ for (const entry of entries) {
71
+ const entryPath = join(currentPath, entry);
72
+
73
+ if (isIgnored(entryPath) || entry.startsWith('.DS_Store')) {
74
+ const stat = statSync(entryPath, { throwIfNoEntry: false });
75
+ if (stat?.isDirectory()) {
76
+ stats.ignoredDirs.add(entry);
77
+ }
78
+ continue;
79
+ }
80
+
81
+ const stat = statSync(entryPath, { throwIfNoEntry: false });
82
+ if (!stat) continue;
83
+
84
+ if (stat.isDirectory()) {
85
+ const sub = traverse(entryPath, currentDepth + 1);
86
+ if (sub && Object.keys(sub).length > 0) {
87
+ result[entry + '/'] = sub;
88
+ } else if (sub && sub._more) {
89
+ result[entry + '/'] = { _more: true };
90
+ }
91
+ } else {
92
+ stats.totalFiles++;
93
+
94
+ // Basic budget pruning threshold logic: if we are displaying too many files, start summarizing
95
+ // to stay within reasonable bounds even before the global output filter kicks in.
96
+ if (stats.displayed > 200) {
97
+ hiddenCount++;
98
+ } else {
99
+ filesOnly.push(entry);
100
+ stats.displayed++;
101
+ }
102
+ }
103
+ }
104
+
105
+ if (filesOnly.length > 0) {
106
+ if (Object.keys(result).length === 0) {
107
+ // It's a directory with ONLY files. Use the flat array optimization.
108
+ const res: any = { _files: filesOnly };
109
+ if (hiddenCount > 0) res._more = hiddenCount;
110
+ return res;
111
+ } else {
112
+ // Mixed dir
113
+ for (const f of filesOnly) result[f] = {};
114
+ if (hiddenCount > 0) result['_moreReplacedFiles'] = hiddenCount;
115
+ }
116
+ }
117
+
118
+ return result;
119
+ }
120
+
121
+ const structure = traverse(fullTarget, 1);
122
+
123
+ return {
124
+ root: targetDir,
125
+ depth: maxDepth,
126
+ structure,
127
+ stats: {
128
+ totalFiles: stats.totalFiles,
129
+ displayed: stats.displayed,
130
+ ignored: Array.from(stats.ignoredDirs)
131
+ }
132
+ };
133
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Core output management for aiseerr.
3
+ * Guarantees Machine-First protocol: Pure JSON on stdout, structured error on stderr.
4
+ */
5
+
6
+ // A very rough token estimator (approximately 4 characters per token for English/code)
7
+ export function estimateTokens(text: string): number {
8
+ return Math.max(1, Math.ceil(text.length / 4));
9
+ }
10
+
11
+ // Ensure the final object doesn't exceed the token budget if one is provided
12
+ export function applyBudget(data: any, budget?: number): any {
13
+ if (!budget || budget <= 0) return data;
14
+
15
+ const serialized = JSON.stringify(data);
16
+ const estimated = estimateTokens(serialized);
17
+
18
+ if (estimated > budget) {
19
+ // Actually truncate: serialize to JSON, cut to budget char limit, close cleanly
20
+ const budgetChars = budget * 4;
21
+
22
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
23
+ // Try pruning array values first (largest arrays get trimmed)
24
+ const pruned = { ...data };
25
+ const keys = Object.keys(pruned);
26
+
27
+ // Sort keys by serialized size descending to prune largest first
28
+ const keySizes = keys
29
+ .filter(k => k !== '_meta')
30
+ .map(k => ({ key: k, size: JSON.stringify(pruned[k]).length }))
31
+ .sort((a, b) => b.size - a.size);
32
+
33
+ for (const { key } of keySizes) {
34
+ const currentSize = JSON.stringify(pruned).length;
35
+ if (currentSize <= budgetChars) break;
36
+
37
+ if (Array.isArray(pruned[key]) && pruned[key].length > 0) {
38
+ // Trim array items from the end until under budget
39
+ const arr = [...pruned[key]];
40
+ while (arr.length > 0 && JSON.stringify({ ...pruned, [key]: arr }).length > budgetChars) {
41
+ arr.pop();
42
+ }
43
+ pruned[key] = arr.length > 0 ? arr : `[TRUNCATED: ${pruned[key].length} items removed]`;
44
+ } else if (typeof pruned[key] === 'string' && pruned[key].length > 200) {
45
+ // Truncate long string values
46
+ const allowedLen = Math.max(100, pruned[key].length - (currentSize - budgetChars));
47
+ pruned[key] = pruned[key].slice(0, allowedLen) + '...[TRUNCATED]';
48
+ }
49
+ }
50
+
51
+ // Final fallback: if still over budget, hard-truncate the JSON string
52
+ const prunedJson = JSON.stringify(pruned);
53
+ if (prunedJson.length > budgetChars) {
54
+ const truncatedStr = prunedJson.slice(0, budgetChars);
55
+ // Try to parse back; if not valid, wrap as raw truncated output
56
+ return {
57
+ _truncated_raw: truncatedStr,
58
+ _meta: {
59
+ tokensEstimate: estimated,
60
+ budget,
61
+ truncated: true,
62
+ hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
63
+ }
64
+ };
65
+ }
66
+
67
+ return {
68
+ ...pruned,
69
+ _meta: {
70
+ ...(pruned._meta || {}),
71
+ tokensEstimate: estimateTokens(JSON.stringify(pruned)),
72
+ budget,
73
+ truncated: true,
74
+ hint: `Output exceeded budget of ${budget} tokens. Some fields were pruned.`
75
+ }
76
+ };
77
+ }
78
+
79
+ // Non-object data: hard truncate the serialized string
80
+ const truncated = serialized.slice(0, budgetChars);
81
+ return {
82
+ _truncated_raw: truncated,
83
+ _meta: {
84
+ tokensEstimate: estimated,
85
+ budget,
86
+ truncated: true,
87
+ hint: `Output exceeded budget of ${budget} tokens. Data was hard-truncated.`
88
+ }
89
+ };
90
+ }
91
+
92
+ // Under budget — attach meta info
93
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
94
+ return {
95
+ ...data,
96
+ _meta: {
97
+ ...(data._meta || {}),
98
+ tokensEstimate: estimated,
99
+ budget,
100
+ truncated: false
101
+ }
102
+ };
103
+ }
104
+
105
+ return data;
106
+ }
107
+
108
+ export function outputSuccess(data: any, budget?: number) {
109
+ const finalData = applyBudget(data, budget);
110
+ const output = JSON.stringify(finalData, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n';
111
+ process.stdout.write(output, () => process.exit(0));
112
+ }
113
+
114
+ export function outputError(error: unknown, code = 1) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ const errorObj = {
117
+ error: message,
118
+ code: code
119
+ };
120
+
121
+ process.stderr.write(JSON.stringify(errorObj, null, process.argv.includes('--pretty') ? 2 : undefined) + '\n');
122
+ process.exit(code);
123
+ }
@@ -0,0 +1,172 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // We test the CLI routing logic by extracting the core routing behavior.
4
+ // Since cli.ts calls main() on import and uses process.argv / process.exit,
5
+ // we test the command routing indirectly through the individual handlers,
6
+ // and verify the arg-parsing logic directly.
7
+
8
+ // Mock all command handlers
9
+ vi.mock('../src/commands/env', () => ({ handleEnvCommand: vi.fn(() => ({ mock: 'env' })) }));
10
+ vi.mock('../src/commands/tree', () => ({ handleTreeCommand: vi.fn(() => ({ mock: 'tree' })) }));
11
+ vi.mock('../src/commands/read', () => ({ handleReadCommand: vi.fn(() => ({ mock: 'read' })) }));
12
+ vi.mock('../src/commands/diff', () => ({ handleDiffCommand: vi.fn(() => ({ mock: 'diff' })) }));
13
+ vi.mock('../src/commands/scout', () => ({ handleScoutCommand: vi.fn(() => ({ mock: 'scout' })) }));
14
+ vi.mock('../src/commands/init', () => ({ handleInitCommand: vi.fn(() => ({ mock: 'init' })) }));
15
+
16
+ // Mock output to prevent process.exit calls
17
+ vi.mock('../src/utils/output', () => ({
18
+ outputSuccess: vi.fn(),
19
+ outputError: vi.fn(),
20
+ }));
21
+
22
+ import { handleEnvCommand } from '../src/commands/env';
23
+ import { handleTreeCommand } from '../src/commands/tree';
24
+ import { handleReadCommand } from '../src/commands/read';
25
+ import { handleDiffCommand } from '../src/commands/diff';
26
+ import { handleScoutCommand } from '../src/commands/scout';
27
+ import { handleInitCommand } from '../src/commands/init';
28
+ import { outputSuccess, outputError } from '../src/utils/output';
29
+
30
+ /**
31
+ * Re-implements the CLI arg parsing and routing logic from src/cli.ts
32
+ * to test it in isolation without triggering process.exit or auto-execution.
33
+ */
34
+ function routeCommand(args: string[]): { command: string; result?: any; error?: string; isHelp?: boolean } {
35
+ if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
36
+ return { command: 'help', isHelp: true };
37
+ }
38
+
39
+ const command = args[0];
40
+ const originalParams = args.slice(1);
41
+
42
+ let budget: number | undefined;
43
+ const commandParams: string[] = [];
44
+
45
+ for (const p of originalParams) {
46
+ if (p.startsWith('--budget=')) {
47
+ budget = parseInt(p.split('=')[1], 10);
48
+ if (isNaN(budget)) {
49
+ return { command, error: 'Invalid --budget value. Must be a number.' };
50
+ }
51
+ } else if (p === '--pretty') {
52
+ // handled globally
53
+ } else {
54
+ commandParams.push(p);
55
+ }
56
+ }
57
+
58
+ let result: any;
59
+ switch (command) {
60
+ case 'scout':
61
+ result = (handleScoutCommand as any)(budget);
62
+ break;
63
+ case 'env':
64
+ result = (handleEnvCommand as any)();
65
+ break;
66
+ case 'tree':
67
+ result = (handleTreeCommand as any)(commandParams, budget);
68
+ break;
69
+ case 'read':
70
+ result = (handleReadCommand as any)(commandParams, budget);
71
+ break;
72
+ case 'diff':
73
+ result = (handleDiffCommand as any)(commandParams, budget);
74
+ break;
75
+ case 'init':
76
+ result = (handleInitCommand as any)(commandParams);
77
+ break;
78
+ default:
79
+ return { command, error: `Unknown command: ${command}` };
80
+ }
81
+
82
+ return { command, result };
83
+ }
84
+
85
+ describe('CLI Routing', () => {
86
+ beforeEach(() => {
87
+ vi.clearAllMocks();
88
+ });
89
+
90
+ it('should show help when no arguments provided', () => {
91
+ const res = routeCommand([]);
92
+ expect(res.isHelp).toBe(true);
93
+ expect(res.command).toBe('help');
94
+ });
95
+
96
+ it('should show help when "help" is the first argument (args[0] === "help" fix)', () => {
97
+ const res = routeCommand(['help']);
98
+ expect(res.isHelp).toBe(true);
99
+ });
100
+
101
+ it('should show help when --help flag is present anywhere', () => {
102
+ const res = routeCommand(['env', '--help']);
103
+ expect(res.isHelp).toBe(true);
104
+ });
105
+
106
+ it('should show help when -h flag is present', () => {
107
+ const res = routeCommand(['-h']);
108
+ expect(res.isHelp).toBe(true);
109
+ });
110
+
111
+ it('should route to env command', () => {
112
+ const res = routeCommand(['env']);
113
+ expect(res.command).toBe('env');
114
+ expect(handleEnvCommand).toHaveBeenCalled();
115
+ });
116
+
117
+ it('should route to tree command with params', () => {
118
+ routeCommand(['tree', 'src/', '--depth=5']);
119
+ expect(handleTreeCommand).toHaveBeenCalledWith(['src/', '--depth=5'], undefined);
120
+ });
121
+
122
+ it('should route to read command', () => {
123
+ routeCommand(['read', 'file.ts']);
124
+ expect(handleReadCommand).toHaveBeenCalledWith(['file.ts'], undefined);
125
+ });
126
+
127
+ it('should route to diff command', () => {
128
+ routeCommand(['diff', '--full']);
129
+ expect(handleDiffCommand).toHaveBeenCalledWith(['--full'], undefined);
130
+ });
131
+
132
+ it('should route to scout command', () => {
133
+ routeCommand(['scout']);
134
+ expect(handleScoutCommand).toHaveBeenCalledWith(undefined);
135
+ });
136
+
137
+ it('should route to init command', () => {
138
+ routeCommand(['init', '--format=claude']);
139
+ expect(handleInitCommand).toHaveBeenCalledWith(['--format=claude']);
140
+ });
141
+
142
+ it('should return error for unknown commands', () => {
143
+ const res = routeCommand(['foobar']);
144
+ expect(res.error).toBe('Unknown command: foobar');
145
+ });
146
+
147
+ it('should extract --budget flag and pass as number', () => {
148
+ routeCommand(['scout', '--budget=500']);
149
+ expect(handleScoutCommand).toHaveBeenCalledWith(500);
150
+ });
151
+
152
+ it('should pass budget to tree command', () => {
153
+ routeCommand(['tree', 'src/', '--budget=1000', '--depth=2']);
154
+ expect(handleTreeCommand).toHaveBeenCalledWith(['src/', '--depth=2'], 1000);
155
+ });
156
+
157
+ it('should error on invalid budget value (NaN)', () => {
158
+ const res = routeCommand(['env', '--budget=abc']);
159
+ expect(res.error).toContain('Invalid --budget value');
160
+ });
161
+
162
+ it('should strip --pretty from command params', () => {
163
+ routeCommand(['tree', 'src/', '--pretty']);
164
+ // --pretty should not be passed to the command handler
165
+ expect(handleTreeCommand).toHaveBeenCalledWith(['src/'], undefined);
166
+ });
167
+
168
+ it('should handle multiple flags together', () => {
169
+ routeCommand(['read', 'file.ts', '--lines=1-10', '--budget=200', '--pretty']);
170
+ expect(handleReadCommand).toHaveBeenCalledWith(['file.ts', '--lines=1-10'], 200);
171
+ });
172
+ });
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { handleDiffCommand } from '../src/commands/diff';
3
+ import * as child_process from 'child_process';
4
+ import * as fs from 'fs';
5
+
6
+ vi.mock('fs', async () => {
7
+ const actual = await vi.importActual<typeof import('fs')>('fs');
8
+ return { ...actual, existsSync: vi.fn() };
9
+ });
10
+
11
+ vi.mock('child_process', () => ({
12
+ execFileSync: vi.fn()
13
+ }));
14
+
15
+ const mockExistsSync = fs.existsSync as any;
16
+ const mockExecFileSync = child_process.execFileSync as any;
17
+
18
+ describe('Diff Command', () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ it('should throw when not a git repository', () => {
24
+ mockExistsSync.mockReturnValue(false);
25
+ expect(() => handleDiffCommand([])).toThrow('Not a git repository');
26
+ });
27
+
28
+ it('should return branch, ahead/behind counts, staged/unstaged/untracked', () => {
29
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
30
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
31
+ if (args.includes('--abbrev-ref')) return 'feature-x\n';
32
+ if (args.includes('--left-right')) return '3\t1\n';
33
+ if (args.includes('--porcelain')) return 'M staged.ts\n M unstaged.ts\n?? new.ts\n';
34
+ if (args.includes('--numstat') && args.includes('--cached')) return '10\t2\tstaged.ts\n';
35
+ if (args.includes('--numstat')) return '5\t3\tunstaged.ts\n';
36
+ return '';
37
+ });
38
+
39
+ const result = handleDiffCommand([]);
40
+ expect(result.branch).toBe('feature-x');
41
+ expect(result.ahead).toBe(3);
42
+ expect(result.behind).toBe(1);
43
+ expect(result.staged).toEqual([{ file: 'staged.ts', status: 'M', insertions: 10, deletions: 2 }]);
44
+ expect(result.unstaged).toEqual([{ file: 'unstaged.ts', status: 'M', insertions: 5, deletions: 3 }]);
45
+ expect(result.untracked).toEqual(['new.ts']);
46
+ });
47
+
48
+ it('should handle --full flag and attach raw diff output', () => {
49
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
50
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
51
+ if (args.includes('--abbrev-ref')) return 'main\n';
52
+ if (args.includes('--left-right')) return null;
53
+ if (args.includes('--porcelain')) return '';
54
+ if (args[0] === 'diff' && args.includes('--cached')) return 'staged diff content\n';
55
+ if (args[0] === 'diff' && !args.includes('--cached') && !args.includes('--numstat')) return 'unstaged diff content\n';
56
+ return '';
57
+ });
58
+
59
+ const result = handleDiffCommand(['--full']);
60
+ expect(result.stagedDiff).toBe('staged diff content');
61
+ expect(result.unstagedDiff).toBe('unstaged diff content');
62
+ });
63
+
64
+ it('should filter by --file flag', () => {
65
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
66
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
67
+ if (args.includes('--abbrev-ref')) return 'main\n';
68
+ if (args.includes('--left-right')) return '';
69
+ if (args.includes('--porcelain')) return 'M target.ts\nM other.ts\n';
70
+ return '';
71
+ });
72
+
73
+ const result = handleDiffCommand(['--file=target.ts']);
74
+ expect(result.staged.length).toBe(1);
75
+ expect(result.staged[0].file).toBe('target.ts');
76
+ // other.ts should be filtered out
77
+ expect(result.unstaged.length).toBe(0);
78
+ });
79
+
80
+ it('should use execFileSync (not execSync) to prevent shell injection', () => {
81
+ // Verify the module uses execFileSync by checking the import
82
+ // execFileSync takes command and args array separately — no shell interpretation
83
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
84
+ mockExecFileSync.mockImplementation(() => '');
85
+
86
+ handleDiffCommand([]);
87
+
88
+ // All calls should be to execFileSync (our mock), not execSync
89
+ expect(mockExecFileSync).toHaveBeenCalled();
90
+ // Verify args are passed as arrays (safe), not concatenated strings
91
+ for (const call of mockExecFileSync.mock.calls) {
92
+ expect(call[0]).toBe('git');
93
+ expect(Array.isArray(call[1])).toBe(true);
94
+ }
95
+ });
96
+
97
+ it('should handle no tracking info gracefully (ahead/behind = 0)', () => {
98
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
99
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
100
+ if (args.includes('--abbrev-ref')) return 'main\n';
101
+ if (args.includes('--left-right')) return null; // no upstream
102
+ if (args.includes('--porcelain')) return '';
103
+ return '';
104
+ });
105
+
106
+ const result = handleDiffCommand([]);
107
+ expect(result.ahead).toBe(0);
108
+ expect(result.behind).toBe(0);
109
+ });
110
+
111
+ it('should handle empty git status (clean working directory)', () => {
112
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
113
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
114
+ if (args.includes('--abbrev-ref')) return 'main\n';
115
+ if (args.includes('--left-right')) return '0\t0\n';
116
+ if (args.includes('--porcelain')) return '';
117
+ return null;
118
+ });
119
+
120
+ const result = handleDiffCommand([]);
121
+ expect(result.staged).toEqual([]);
122
+ expect(result.unstaged).toEqual([]);
123
+ expect(result.untracked).toEqual([]);
124
+ });
125
+
126
+ it('should not attach stagedDiff/unstagedDiff without --full flag', () => {
127
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
128
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
129
+ if (args.includes('--abbrev-ref')) return 'main\n';
130
+ if (args.includes('--left-right')) return '';
131
+ if (args.includes('--porcelain')) return '';
132
+ return '';
133
+ });
134
+
135
+ const result = handleDiffCommand([]);
136
+ expect(result.stagedDiff).toBeUndefined();
137
+ expect(result.unstagedDiff).toBeUndefined();
138
+ });
139
+
140
+ it('should handle --full with --file to scope diff', () => {
141
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
142
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
143
+ if (args.includes('--abbrev-ref')) return 'main\n';
144
+ if (args.includes('--left-right')) return '';
145
+ if (args.includes('--porcelain')) return '';
146
+ // When --full + --file=target.ts, diff args should include the file
147
+ if (args[0] === 'diff' && args.includes('target.ts')) return 'targeted diff\n';
148
+ if (args[0] === 'diff') return '';
149
+ return '';
150
+ });
151
+
152
+ const result = handleDiffCommand(['--full', '--file=target.ts']);
153
+ // The --file flag should cause diff to be scoped to target.ts
154
+ expect(result).toBeDefined();
155
+ });
156
+
157
+ it('should handle branch detection failure', () => {
158
+ mockExistsSync.mockImplementation((p: string) => p.endsWith('.git'));
159
+ mockExecFileSync.mockImplementation((cmd: string, args: string[]) => {
160
+ if (args.includes('--abbrev-ref')) { throw new Error('git failed'); }
161
+ if (args.includes('--left-right')) return null;
162
+ if (args.includes('--porcelain')) return '';
163
+ return null;
164
+ });
165
+
166
+ const result = handleDiffCommand([]);
167
+ expect(result.branch).toBe('unknown');
168
+ });
169
+ });