@cod3vil/kount-cli 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/README.md +375 -0
- package/bin/kount.js +2 -0
- package/package.json +65 -0
- package/src/cli/.gitkeep +0 -0
- package/src/cli/config-resolver.ts +175 -0
- package/src/cli/parser.ts +52 -0
- package/src/core/.gitkeep +0 -0
- package/src/core/aggregator.ts +204 -0
- package/src/core/cache.ts +130 -0
- package/src/index.tsx +167 -0
- package/src/plugins/.gitkeep +0 -0
- package/src/plugins/built-in/blank-lines.ts +26 -0
- package/src/plugins/built-in/comment-lines.ts +90 -0
- package/src/plugins/built-in/file-size.ts +20 -0
- package/src/plugins/built-in/language-distribution.ts +95 -0
- package/src/plugins/built-in/largest-files.ts +41 -0
- package/src/plugins/built-in/total-files.ts +18 -0
- package/src/plugins/built-in/total-lines.ts +21 -0
- package/src/plugins/index.ts +10 -0
- package/src/plugins/types.ts +58 -0
- package/src/reporters/.gitkeep +0 -0
- package/src/reporters/html.ts +385 -0
- package/src/reporters/markdown.ts +129 -0
- package/src/reporters/terminal/Progress.tsx +39 -0
- package/src/reporters/terminal/Splash.tsx +32 -0
- package/src/reporters/terminal/Summary.tsx +135 -0
- package/src/reporters/terminal/Wizard.tsx +125 -0
- package/src/reporters/terminal/index.ts +6 -0
- package/src/scanner/.gitkeep +0 -0
- package/src/scanner/ignore-parser.ts +168 -0
- package/src/scanner/stream-reader.ts +99 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/language-map.ts +79 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { ProjectStats } from '../plugins/types.js';
|
|
4
|
+
|
|
5
|
+
const KOUNT_HEADER = '<!-- KOUNT:START -->';
|
|
6
|
+
const KOUNT_FOOTER = '<!-- KOUNT:END -->';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates a Markdown report string from ProjectStats.
|
|
10
|
+
*/
|
|
11
|
+
function generateMarkdownReport(stats: ProjectStats): string {
|
|
12
|
+
const totalLines = stats.pluginResults.get('TotalLines')?.summaryValue ?? 0;
|
|
13
|
+
const blankLines = stats.pluginResults.get('BlankLines')?.summaryValue ?? 0;
|
|
14
|
+
const commentLines = stats.pluginResults.get('CommentLines')?.summaryValue ?? 0;
|
|
15
|
+
const totalBytes = stats.pluginResults.get('FileSize')?.summaryValue ?? 0;
|
|
16
|
+
const codeLines = totalLines - blankLines - commentLines;
|
|
17
|
+
const codeRatio = totalLines > 0 ? ((codeLines / totalLines) * 100).toFixed(1) : '0.0';
|
|
18
|
+
|
|
19
|
+
const formatSize = (bytes: number): string => {
|
|
20
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
21
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
22
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const lines: string[] = [];
|
|
26
|
+
lines.push(KOUNT_HEADER);
|
|
27
|
+
lines.push('');
|
|
28
|
+
lines.push('## Codebase Statistics');
|
|
29
|
+
lines.push('');
|
|
30
|
+
lines.push(`> Generated by [kount](https://github.com/kount) on ${stats.scannedAt.toLocaleDateString()}`);
|
|
31
|
+
lines.push('');
|
|
32
|
+
|
|
33
|
+
// Summary table
|
|
34
|
+
lines.push('### Summary');
|
|
35
|
+
lines.push('');
|
|
36
|
+
lines.push('| Metric | Value |');
|
|
37
|
+
lines.push('|--------|-------|');
|
|
38
|
+
lines.push(`| Files | ${stats.totalFiles.toLocaleString()} |`);
|
|
39
|
+
lines.push(`| Total Lines | ${totalLines.toLocaleString()} |`);
|
|
40
|
+
lines.push(`| Code Lines | ${codeLines.toLocaleString()} |`);
|
|
41
|
+
lines.push(`| Comment Lines | ${commentLines.toLocaleString()} |`);
|
|
42
|
+
lines.push(`| Blank Lines | ${blankLines.toLocaleString()} |`);
|
|
43
|
+
lines.push(`| Code Ratio | ${codeRatio}% |`);
|
|
44
|
+
lines.push(`| Total Size | ${formatSize(totalBytes)} |`);
|
|
45
|
+
lines.push('');
|
|
46
|
+
|
|
47
|
+
// Language distribution (descending %)
|
|
48
|
+
if (stats.languageDistribution.size > 0) {
|
|
49
|
+
const sortedLangs = [...stats.languageDistribution.entries()]
|
|
50
|
+
.sort((a, b) => b[1] - a[1]);
|
|
51
|
+
|
|
52
|
+
lines.push('### Language Distribution');
|
|
53
|
+
lines.push('');
|
|
54
|
+
lines.push('| Language | Files | % |');
|
|
55
|
+
lines.push('|----------|-------|---|');
|
|
56
|
+
|
|
57
|
+
for (const [lang, count] of sortedLangs) {
|
|
58
|
+
const pct = ((count / stats.totalFiles) * 100).toFixed(1);
|
|
59
|
+
lines.push(`| ${lang} | ${count} | ${pct}% |`);
|
|
60
|
+
}
|
|
61
|
+
lines.push('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Top 10 largest files
|
|
65
|
+
if (stats.largestFiles.length > 0) {
|
|
66
|
+
lines.push('### Top 10 Largest Files');
|
|
67
|
+
lines.push('');
|
|
68
|
+
lines.push('| # | File | Size |');
|
|
69
|
+
lines.push('|---|------|------|');
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < stats.largestFiles.length; i++) {
|
|
72
|
+
const file = stats.largestFiles[i];
|
|
73
|
+
const relPath = path.relative(stats.rootDir, file.filePath);
|
|
74
|
+
lines.push(`| ${i + 1} | \`${relPath}\` | ${formatSize(file.size)} |`);
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
lines.push(KOUNT_FOOTER);
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Writes the Markdown report to a file.
|
|
85
|
+
* - By default, if the file exists, appends/replaces the KOUNT section.
|
|
86
|
+
* - If `force` is true, overwrites the entire file.
|
|
87
|
+
*
|
|
88
|
+
* @param stats The aggregated project statistics.
|
|
89
|
+
* @param outputPath The target markdown file path (defaults to README.md in rootDir).
|
|
90
|
+
* @param force If true, overwrite the entire file instead of appending.
|
|
91
|
+
*/
|
|
92
|
+
export async function writeMarkdownReport(
|
|
93
|
+
stats: ProjectStats,
|
|
94
|
+
outputPath?: string,
|
|
95
|
+
force: boolean = false
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
const targetPath = outputPath ?? path.join(stats.rootDir, 'README.md');
|
|
98
|
+
const report = generateMarkdownReport(stats);
|
|
99
|
+
|
|
100
|
+
if (force) {
|
|
101
|
+
await fsp.writeFile(targetPath, report, 'utf8');
|
|
102
|
+
return targetPath;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Try to read existing file and replace KOUNT section, or append
|
|
106
|
+
try {
|
|
107
|
+
const existing = await fsp.readFile(targetPath, 'utf8');
|
|
108
|
+
|
|
109
|
+
const startIdx = existing.indexOf(KOUNT_HEADER);
|
|
110
|
+
const endIdx = existing.indexOf(KOUNT_FOOTER);
|
|
111
|
+
|
|
112
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
113
|
+
// Replace existing KOUNT section
|
|
114
|
+
const before = existing.substring(0, startIdx);
|
|
115
|
+
const after = existing.substring(endIdx + KOUNT_FOOTER.length);
|
|
116
|
+
await fsp.writeFile(targetPath, before + report + after, 'utf8');
|
|
117
|
+
} else {
|
|
118
|
+
// Append to the end
|
|
119
|
+
await fsp.writeFile(targetPath, existing + '\n\n' + report, 'utf8');
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// File doesn't exist — create new
|
|
123
|
+
await fsp.writeFile(targetPath, report, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return targetPath;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { generateMarkdownReport };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
interface ProgressProps {
|
|
5
|
+
current: number;
|
|
6
|
+
total: number;
|
|
7
|
+
currentFile: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Real-time progress indicator for the streaming scan.
|
|
12
|
+
* Shows a progress bar, percentage, and current file being processed.
|
|
13
|
+
* Follows ux-progress-indicators and render-partial-updates guidelines.
|
|
14
|
+
*/
|
|
15
|
+
export function Progress({ current, total, currentFile }: ProgressProps): React.ReactElement {
|
|
16
|
+
const percentage = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
17
|
+
const barWidth = 30;
|
|
18
|
+
const filled = Math.round((percentage / 100) * barWidth);
|
|
19
|
+
const empty = barWidth - filled;
|
|
20
|
+
|
|
21
|
+
const bar = '█'.repeat(filled) + '░'.repeat(empty);
|
|
22
|
+
|
|
23
|
+
// Color transitions: red < 33%, yellow < 66%, green >= 66%
|
|
24
|
+
const barColor = percentage < 33 ? 'red' : percentage < 66 ? 'yellow' : 'green';
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box flexDirection="column" marginY={1}>
|
|
28
|
+
<Box>
|
|
29
|
+
<Text color="white" bold>Scanning: </Text>
|
|
30
|
+
<Text color={barColor}>{bar}</Text>
|
|
31
|
+
<Text color="white"> {percentage}%</Text>
|
|
32
|
+
<Text color="gray"> ({current}/{total})</Text>
|
|
33
|
+
</Box>
|
|
34
|
+
<Box marginTop={0}>
|
|
35
|
+
<Text color="gray" wrap="truncate-end"> {currentFile}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
</Box>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
const LOGO = `
|
|
5
|
+
██╗ ██╗ ██████╗ ██╗ ██╗███╗ ██╗████████╗
|
|
6
|
+
██║ ██╔╝██╔═══██╗██║ ██║████╗ ██║╚══██╔══╝
|
|
7
|
+
█████╔╝ ██║ ██║██║ ██║██╔██╗ ██║ ██║
|
|
8
|
+
██╔═██╗ ██║ ██║██║ ██║██║╚██╗██║ ██║
|
|
9
|
+
██║ ██╗╚██████╔╝╚██████╔╝██║ ╚████║ ██║
|
|
10
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Splash screen component that displays the KOUNT ASCII logo.
|
|
14
|
+
* Uses Ink's Box + Text for structured terminal rendering.
|
|
15
|
+
* Follows render-single-write and tuicomp-border-styles guidelines.
|
|
16
|
+
*/
|
|
17
|
+
export function Splash(): React.ReactElement {
|
|
18
|
+
return (
|
|
19
|
+
<Box
|
|
20
|
+
flexDirection="column"
|
|
21
|
+
borderStyle="round"
|
|
22
|
+
borderColor="cyan"
|
|
23
|
+
paddingX={2}
|
|
24
|
+
paddingY={1}
|
|
25
|
+
>
|
|
26
|
+
<Text color="cyan" bold>{LOGO}</Text>
|
|
27
|
+
<Box marginTop={1}>
|
|
28
|
+
<Text color="white" dimColor> Project Intelligence for Codebases</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Box, Text } from 'ink';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import type { ProjectStats } from '../../plugins/types.js';
|
|
5
|
+
|
|
6
|
+
interface SummaryProps {
|
|
7
|
+
stats: ProjectStats;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Final summary display after scan completes.
|
|
12
|
+
* Strict color-coding: green for success-like values, no emojis.
|
|
13
|
+
* Follows ux-color-semantics and ux-next-steps guidelines.
|
|
14
|
+
*/
|
|
15
|
+
export function Summary({ stats }: SummaryProps): React.ReactElement {
|
|
16
|
+
const totalLines = stats.pluginResults.get('TotalLines')?.summaryValue ?? 0;
|
|
17
|
+
const blankLines = stats.pluginResults.get('BlankLines')?.summaryValue ?? 0;
|
|
18
|
+
const commentLines = stats.pluginResults.get('CommentLines')?.summaryValue ?? 0;
|
|
19
|
+
const totalBytes = stats.pluginResults.get('FileSize')?.summaryValue ?? 0;
|
|
20
|
+
const codeLines = totalLines - blankLines - commentLines;
|
|
21
|
+
const codeRatio = totalLines > 0 ? ((codeLines / totalLines) * 100).toFixed(1) : '0.0';
|
|
22
|
+
|
|
23
|
+
// Format bytes
|
|
24
|
+
const formatSize = (bytes: number): string => {
|
|
25
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
26
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
27
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Sort language distribution descending
|
|
31
|
+
const sortedLangs = [...stats.languageDistribution.entries()]
|
|
32
|
+
.sort((a, b) => b[1] - a[1]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Box flexDirection="column" marginY={1}>
|
|
36
|
+
{/* Summary Section */}
|
|
37
|
+
<Box
|
|
38
|
+
flexDirection="column"
|
|
39
|
+
borderStyle="single"
|
|
40
|
+
borderColor="green"
|
|
41
|
+
paddingX={2}
|
|
42
|
+
paddingY={1}
|
|
43
|
+
>
|
|
44
|
+
<Text color="green" bold> SCAN RESULTS</Text>
|
|
45
|
+
<Box marginTop={1} flexDirection="column">
|
|
46
|
+
<Box>
|
|
47
|
+
<Box width={20}><Text color="white">Files</Text></Box>
|
|
48
|
+
<Text color="cyan" bold>{stats.totalFiles.toLocaleString()}</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
<Box>
|
|
51
|
+
<Box width={20}><Text color="white">Total Lines</Text></Box>
|
|
52
|
+
<Text color="cyan" bold>{totalLines.toLocaleString()}</Text>
|
|
53
|
+
</Box>
|
|
54
|
+
<Box>
|
|
55
|
+
<Box width={20}><Text color="white">Code Lines</Text></Box>
|
|
56
|
+
<Text color="green" bold>{codeLines.toLocaleString()}</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
<Box>
|
|
59
|
+
<Box width={20}><Text color="white">Comment Lines</Text></Box>
|
|
60
|
+
<Text color="yellow" bold>{commentLines.toLocaleString()}</Text>
|
|
61
|
+
</Box>
|
|
62
|
+
<Box>
|
|
63
|
+
<Box width={20}><Text color="white">Blank Lines</Text></Box>
|
|
64
|
+
<Text color="gray" bold>{blankLines.toLocaleString()}</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
<Box>
|
|
67
|
+
<Box width={20}><Text color="white">Code Ratio</Text></Box>
|
|
68
|
+
<Text color="green" bold>{codeRatio}%</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
<Box>
|
|
71
|
+
<Box width={20}><Text color="white">Total Size</Text></Box>
|
|
72
|
+
<Text color="cyan" bold>{formatSize(totalBytes)}</Text>
|
|
73
|
+
</Box>
|
|
74
|
+
</Box>
|
|
75
|
+
</Box>
|
|
76
|
+
|
|
77
|
+
{/* Language Distribution */}
|
|
78
|
+
{sortedLangs.length > 0 && (
|
|
79
|
+
<Box
|
|
80
|
+
flexDirection="column"
|
|
81
|
+
borderStyle="single"
|
|
82
|
+
borderColor="blue"
|
|
83
|
+
paddingX={2}
|
|
84
|
+
paddingY={1}
|
|
85
|
+
marginTop={1}
|
|
86
|
+
>
|
|
87
|
+
<Text color="blue" bold> LANGUAGE DISTRIBUTION</Text>
|
|
88
|
+
<Box marginTop={1} flexDirection="column">
|
|
89
|
+
{sortedLangs.map(([lang, count]) => {
|
|
90
|
+
const pct = ((count / stats.totalFiles) * 100).toFixed(1);
|
|
91
|
+
return (
|
|
92
|
+
<Box key={lang}>
|
|
93
|
+
<Box width={22}><Text color="white">{lang}</Text></Box>
|
|
94
|
+
<Box width={8}><Text color="cyan">{count} files</Text></Box>
|
|
95
|
+
<Text color="gray"> ({pct}%)</Text>
|
|
96
|
+
</Box>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</Box>
|
|
100
|
+
</Box>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Largest Files */}
|
|
104
|
+
{stats.largestFiles.length > 0 && (
|
|
105
|
+
<Box
|
|
106
|
+
flexDirection="column"
|
|
107
|
+
borderStyle="single"
|
|
108
|
+
borderColor="magenta"
|
|
109
|
+
paddingX={2}
|
|
110
|
+
paddingY={1}
|
|
111
|
+
marginTop={1}
|
|
112
|
+
>
|
|
113
|
+
<Text color="magenta" bold> TOP {stats.largestFiles.length} LARGEST FILES</Text>
|
|
114
|
+
<Box marginTop={1} flexDirection="column">
|
|
115
|
+
{stats.largestFiles.map((file, i) => {
|
|
116
|
+
const relPath = path.relative(stats.rootDir, file.filePath);
|
|
117
|
+
return (
|
|
118
|
+
<Box key={file.filePath}>
|
|
119
|
+
<Box width={4}><Text color="gray">{i + 1}.</Text></Box>
|
|
120
|
+
<Box width={40}><Text color="white" wrap="truncate-end">{relPath}</Text></Box>
|
|
121
|
+
<Text color="yellow">{formatSize(file.size)}</Text>
|
|
122
|
+
</Box>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
</Box>
|
|
126
|
+
</Box>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
{/* Footer */}
|
|
130
|
+
<Box marginTop={1}>
|
|
131
|
+
<Text color="gray">Scanned at {stats.scannedAt.toLocaleString()}</Text>
|
|
132
|
+
</Box>
|
|
133
|
+
</Box>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
2
|
+
import React, { useCallback, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
export interface WizardResult {
|
|
5
|
+
rootDir: string;
|
|
6
|
+
outputMode: 'terminal' | 'markdown' | 'html';
|
|
7
|
+
includeTests: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface WizardProps {
|
|
11
|
+
onComplete: (result: WizardResult) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Step = 'rootDir' | 'outputMode' | 'includeTests';
|
|
15
|
+
|
|
16
|
+
const OUTPUT_MODES = ['terminal', 'markdown', 'html'] as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Minimal interactive step-based wizard when no CLI flags are provided.
|
|
20
|
+
* Follows prompt-group-flow, input-useinput-hook, input-escape-routes guidelines.
|
|
21
|
+
*/
|
|
22
|
+
export function Wizard({ onComplete }: WizardProps): React.ReactElement {
|
|
23
|
+
const { exit } = useApp();
|
|
24
|
+
const [step, setStep] = useState<Step>('rootDir');
|
|
25
|
+
const [rootDir, setRootDir] = useState('.');
|
|
26
|
+
const [rootDirInput, setRootDirInput] = useState('.');
|
|
27
|
+
const [outputModeIndex, setOutputModeIndex] = useState(0);
|
|
28
|
+
const [includeTestsIndex, setIncludeTestsIndex] = useState(0);
|
|
29
|
+
|
|
30
|
+
const boolOptions = ['Yes', 'No'] as const;
|
|
31
|
+
|
|
32
|
+
useInput(useCallback((input: string, key: { return?: boolean; escape?: boolean; upArrow?: boolean; downArrow?: boolean; backspace?: boolean; delete?: boolean }) => {
|
|
33
|
+
// Escape route
|
|
34
|
+
if (key.escape) {
|
|
35
|
+
exit();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (step === 'rootDir') {
|
|
40
|
+
if (key.return) {
|
|
41
|
+
setRootDir(rootDirInput || '.');
|
|
42
|
+
setStep('outputMode');
|
|
43
|
+
} else if (key.backspace || key.delete) {
|
|
44
|
+
setRootDirInput(prev => prev.slice(0, -1));
|
|
45
|
+
} else if (input && !key.upArrow && !key.downArrow) {
|
|
46
|
+
setRootDirInput(prev => prev + input);
|
|
47
|
+
}
|
|
48
|
+
} else if (step === 'outputMode') {
|
|
49
|
+
if (key.upArrow) {
|
|
50
|
+
setOutputModeIndex(prev => (prev - 1 + OUTPUT_MODES.length) % OUTPUT_MODES.length);
|
|
51
|
+
} else if (key.downArrow) {
|
|
52
|
+
setOutputModeIndex(prev => (prev + 1) % OUTPUT_MODES.length);
|
|
53
|
+
} else if (key.return) {
|
|
54
|
+
setStep('includeTests');
|
|
55
|
+
}
|
|
56
|
+
} else if (step === 'includeTests') {
|
|
57
|
+
if (key.upArrow || key.downArrow) {
|
|
58
|
+
setIncludeTestsIndex(prev => (prev + 1) % 2);
|
|
59
|
+
} else if (key.return) {
|
|
60
|
+
onComplete({
|
|
61
|
+
rootDir: rootDir,
|
|
62
|
+
outputMode: OUTPUT_MODES[outputModeIndex],
|
|
63
|
+
includeTests: includeTestsIndex === 0,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [step, rootDirInput, rootDir, outputModeIndex, includeTestsIndex, onComplete, exit]));
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
|
71
|
+
<Text color="cyan" bold>KOUNT Setup Wizard</Text>
|
|
72
|
+
<Text color="gray">Press ESC to cancel at any time.</Text>
|
|
73
|
+
<Box marginTop={1} flexDirection="column">
|
|
74
|
+
|
|
75
|
+
{/* Step 1: Root Directory */}
|
|
76
|
+
<Box flexDirection="column">
|
|
77
|
+
<Text color={step === 'rootDir' ? 'white' : 'green'} bold>
|
|
78
|
+
{step === 'rootDir' ? '>' : '✓'} Root directory:
|
|
79
|
+
{step !== 'rootDir' && <Text color="cyan"> {rootDir}</Text>}
|
|
80
|
+
</Text>
|
|
81
|
+
{step === 'rootDir' && (
|
|
82
|
+
<Box marginLeft={2}>
|
|
83
|
+
<Text color="cyan">{rootDirInput}</Text>
|
|
84
|
+
<Text color="gray">█</Text>
|
|
85
|
+
</Box>
|
|
86
|
+
)}
|
|
87
|
+
</Box>
|
|
88
|
+
|
|
89
|
+
{/* Step 2: Output Mode */}
|
|
90
|
+
{(step === 'outputMode' || step === 'includeTests') && (
|
|
91
|
+
<Box flexDirection="column" marginTop={1}>
|
|
92
|
+
<Text color={step === 'outputMode' ? 'white' : 'green'} bold>
|
|
93
|
+
{step === 'outputMode' ? '>' : '✓'} Output mode:
|
|
94
|
+
{step !== 'outputMode' && <Text color="cyan"> {OUTPUT_MODES[outputModeIndex]}</Text>}
|
|
95
|
+
</Text>
|
|
96
|
+
{step === 'outputMode' && (
|
|
97
|
+
<Box marginLeft={2} flexDirection="column">
|
|
98
|
+
{OUTPUT_MODES.map((mode, i) => (
|
|
99
|
+
<Text key={mode} color={i === outputModeIndex ? 'cyan' : 'gray'}>
|
|
100
|
+
{i === outputModeIndex ? '> ' : ' '}{mode}
|
|
101
|
+
</Text>
|
|
102
|
+
))}
|
|
103
|
+
</Box>
|
|
104
|
+
)}
|
|
105
|
+
</Box>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
{/* Step 3: Include Tests */}
|
|
109
|
+
{step === 'includeTests' && (
|
|
110
|
+
<Box flexDirection="column" marginTop={1}>
|
|
111
|
+
<Text color="white" bold>{'>'} Include test files?</Text>
|
|
112
|
+
<Box marginLeft={2} flexDirection="column">
|
|
113
|
+
{boolOptions.map((opt, i) => (
|
|
114
|
+
<Text key={opt} color={i === includeTestsIndex ? 'cyan' : 'gray'}>
|
|
115
|
+
{i === includeTestsIndex ? '> ' : ' '}{opt}
|
|
116
|
+
</Text>
|
|
117
|
+
))}
|
|
118
|
+
</Box>
|
|
119
|
+
</Box>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
</Box>
|
|
123
|
+
</Box>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ignore, { Ignore } from 'ignore';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_IGNORES = [
|
|
6
|
+
'node_modules',
|
|
7
|
+
'dist',
|
|
8
|
+
'build',
|
|
9
|
+
'.git',
|
|
10
|
+
'.next',
|
|
11
|
+
'.nuxt',
|
|
12
|
+
'coverage',
|
|
13
|
+
'.kountcache.json', // Our own cache file
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
interface IgnoreContext {
|
|
17
|
+
ig: Ignore;
|
|
18
|
+
dir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parses ignore files and builds a matcher set for a given directory tree.
|
|
23
|
+
* Respects default ignores automatically.
|
|
24
|
+
*/
|
|
25
|
+
export class IgnoreParser {
|
|
26
|
+
// Store ignore instances keyed by directory path
|
|
27
|
+
private directoryIgnores = new Map<string, Ignore>();
|
|
28
|
+
private rootDir: string;
|
|
29
|
+
private respectGitignore: boolean;
|
|
30
|
+
|
|
31
|
+
// Track binary extensions to filter out automatically
|
|
32
|
+
private static readonly BINARY_EXTENSIONS = new Set([
|
|
33
|
+
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.svg', '.webp', '.tiff', '.bmp',
|
|
34
|
+
'.mp4', '.mkv', '.avi', '.mov', '.wmv', '.webm', '.flv',
|
|
35
|
+
'.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a',
|
|
36
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
37
|
+
'.zip', '.tar', '.gz', '.7z', '.rar', '.bz2', '.xz',
|
|
38
|
+
'.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.db', '.sqlite',
|
|
39
|
+
'.app', '.dmg', '.iso', '.class', '.jar', '.war', '.ear',
|
|
40
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
41
|
+
'.pyc', '.pyo', '.pyd', '.o', '.a', '.lib', '.out',
|
|
42
|
+
'.wasm'
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
constructor(rootDir: string, respectGitignore: boolean = true) {
|
|
46
|
+
this.rootDir = path.resolve(rootDir);
|
|
47
|
+
this.respectGitignore = respectGitignore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Initializes the base ignores (defaults + root .kountignore/.gitignore).
|
|
52
|
+
* Call this before walking.
|
|
53
|
+
*/
|
|
54
|
+
async init(): Promise<void> {
|
|
55
|
+
const rootIg = ignore().add(DEFAULT_IGNORES);
|
|
56
|
+
await this.loadIgnoreFiles(this.rootDir, rootIg);
|
|
57
|
+
this.directoryIgnores.set(this.rootDir, rootIg);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Loads `.gitignore` or `.kountignore` inside a specific directory.
|
|
62
|
+
*/
|
|
63
|
+
async loadIgnoreForDir(dirPath: string): Promise<void> {
|
|
64
|
+
if (this.directoryIgnores.has(dirPath)) return;
|
|
65
|
+
|
|
66
|
+
// Inherit from parent directory (if any) or fallback to root if not loaded somehow
|
|
67
|
+
const parentDir = path.dirname(dirPath);
|
|
68
|
+
const parentIg = this.directoryIgnores.get(parentDir) || this.directoryIgnores.get(this.rootDir);
|
|
69
|
+
|
|
70
|
+
// Create a new instance starting with parent's rules. Wait, ignore doesn't deep clone easily.
|
|
71
|
+
// Instead, we just maintain a stack of ig instances during walk, but for class-level,
|
|
72
|
+
// we can create a fresh ignore and add parent's rules if we tracked them, OR just test
|
|
73
|
+
// a path against all ignores from root to current dir.
|
|
74
|
+
// For simplicity and speed: We will test a relative path against an array of ignores.
|
|
75
|
+
|
|
76
|
+
// Actually, ignore is designed to be used by resolving paths relative to where the .gitignore is.
|
|
77
|
+
// Let's store rules per directory.
|
|
78
|
+
const ig = ignore();
|
|
79
|
+
const hasRules = await this.loadIgnoreFiles(dirPath, ig);
|
|
80
|
+
|
|
81
|
+
// If no new rules, we don't strictly need to store it, but let's store undefined or similar
|
|
82
|
+
// to mark it processed.
|
|
83
|
+
if (hasRules) {
|
|
84
|
+
this.directoryIgnores.set(dirPath, ig);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Helper to append rules from files to an Ignore instance.
|
|
90
|
+
* Returns true if rules were added.
|
|
91
|
+
*/
|
|
92
|
+
private async loadIgnoreFiles(dirPath: string, ig: Ignore): Promise<boolean> {
|
|
93
|
+
let hasRules = false;
|
|
94
|
+
|
|
95
|
+
// Load .kountignore first (custom rules overrides everything else if needed, though ignore merges them)
|
|
96
|
+
try {
|
|
97
|
+
const kountIgnorePath = path.join(dirPath, '.kountignore');
|
|
98
|
+
const kountRules = await fs.readFile(kountIgnorePath, 'utf8');
|
|
99
|
+
ig.add(kountRules);
|
|
100
|
+
hasRules = true;
|
|
101
|
+
} catch (e) { /* ignores missing file */ }
|
|
102
|
+
|
|
103
|
+
// Load .gitignore
|
|
104
|
+
if (this.respectGitignore) {
|
|
105
|
+
try {
|
|
106
|
+
const gitIgnorePath = path.join(dirPath, '.gitignore');
|
|
107
|
+
const gitRules = await fs.readFile(gitIgnorePath, 'utf8');
|
|
108
|
+
ig.add(gitRules);
|
|
109
|
+
hasRules = true;
|
|
110
|
+
} catch (e) { /* ignores missing file */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return hasRules;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Checks if an absolute path should be ignored.
|
|
118
|
+
*/
|
|
119
|
+
isIgnored(absolutePath: string, isDirectory: boolean): boolean {
|
|
120
|
+
// 1. Binary check
|
|
121
|
+
if (!isDirectory) {
|
|
122
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
123
|
+
if (IgnoreParser.BINARY_EXTENSIONS.has(ext)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2. Resolve relative to root to check against ignores
|
|
129
|
+
const relativeToRoot = path.relative(this.rootDir, absolutePath);
|
|
130
|
+
|
|
131
|
+
// Edge case: if it evaluates to empty string (which means absolutePath == rootDir), don't ignore
|
|
132
|
+
if (relativeToRoot === '') return false;
|
|
133
|
+
|
|
134
|
+
// We need to check the path formatted for `ignore` package (forward slashes)
|
|
135
|
+
// and if it's a directory, adding a trailing slash helps `ignore` match dir-only rules eagerly.
|
|
136
|
+
const posixPath = relativeToRoot.split(path.sep).join('/');
|
|
137
|
+
const testPath = isDirectory ? `${posixPath}/` : posixPath;
|
|
138
|
+
|
|
139
|
+
// 3. Test against root ignores first (which contains defaults)
|
|
140
|
+
const rootIg = this.directoryIgnores.get(this.rootDir);
|
|
141
|
+
if (rootIg && rootIg.ignores(testPath)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 4. Test against nested directory ignores (from root down to the file's dir)
|
|
146
|
+
// E.g. for src/components/Button.tsx, check src/ and src/components/
|
|
147
|
+
const parts = relativeToRoot.split(path.sep);
|
|
148
|
+
let currentPath = this.rootDir;
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < parts.length - 1; i++) { // -1 because we don't need to check in the file's own name as a dir
|
|
151
|
+
currentPath = path.join(currentPath, parts[i]);
|
|
152
|
+
const dirIg = this.directoryIgnores.get(currentPath);
|
|
153
|
+
|
|
154
|
+
if (dirIg) {
|
|
155
|
+
// The ignore package checks paths relative to where the .gitignore file is located.
|
|
156
|
+
const relToIgnoreFile = path.relative(currentPath, absolutePath);
|
|
157
|
+
const relPosixPath = relToIgnoreFile.split(path.sep).join('/');
|
|
158
|
+
const nestedTestPath = isDirectory ? `${relPosixPath}/` : relPosixPath;
|
|
159
|
+
|
|
160
|
+
if (dirIg.ignores(nestedTestPath)) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|