@cod3vil/kount-cli 1.0.0 → 1.0.1

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/package.json CHANGED
@@ -1,16 +1,15 @@
1
1
  {
2
2
  "name": "@cod3vil/kount-cli",
3
3
  "packageManager": "bun@1.3.5",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "description": "Project Intelligence for Codebases — analyze your code with precision.",
6
6
  "module": "src/index.tsx",
7
7
  "type": "module",
8
8
  "bin": {
9
- "kount": "./bin/kount.js"
9
+ "kount": "./dist/kount.js"
10
10
  },
11
11
  "files": [
12
- "src",
13
- "bin",
12
+ "dist",
14
13
  "README.md"
15
14
  ],
16
15
  "publishConfig": {
@@ -21,7 +20,7 @@
21
20
  "start": "bun run src/index.tsx",
22
21
  "test": "vitest run",
23
22
  "test:watch": "vitest",
24
- "build": "bun build ./src/index.tsx --compile --external react-devtools-core --outfile dist/kount",
23
+ "build": "bun build ./src/index.tsx --outfile dist/kount.js --target node",
25
24
  "prepublishOnly": "bun run build",
26
25
  "release": "np"
27
26
  },
@@ -50,6 +49,7 @@
50
49
  "@types/node": "^25.3.2",
51
50
  "@types/react": "^19.2.14",
52
51
  "np": "^11.0.2",
52
+ "react-devtools-core": "^7.0.1",
53
53
  "vitest": "^4.0.18"
54
54
  },
55
55
  "peerDependencies": {
package/bin/kount.js DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env bun
2
- import './src/index.tsx';
package/src/cli/.gitkeep DELETED
File without changes
@@ -1,175 +0,0 @@
1
- import fsp from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- /**
5
- * Resolved configuration used throughout the application.
6
- */
7
- export interface KountConfig {
8
- rootDir: string;
9
- outputMode: 'terminal' | 'markdown' | 'html';
10
- includeTests: boolean;
11
- respectGitignore: boolean;
12
- cache: {
13
- enabled: boolean;
14
- clearFirst: boolean;
15
- };
16
- force: boolean;
17
- outputPath?: string;
18
- }
19
-
20
- /**
21
- * Shape of the .kountrc.json / .kountrc.yaml config file.
22
- */
23
- interface ConfigFile {
24
- rootDir?: string;
25
- outputMode?: string;
26
- includeTests?: boolean;
27
- respectGitignore?: boolean;
28
- cache?: {
29
- enabled?: boolean;
30
- clearFirst?: boolean;
31
- };
32
- }
33
-
34
- /**
35
- * CLI flags that can override the config file.
36
- */
37
- export interface CliFlags {
38
- rootDir?: string;
39
- outputMode?: string;
40
- includeTests?: boolean;
41
- respectGitignore?: boolean;
42
- cache?: boolean;
43
- clearCache?: boolean;
44
- force?: boolean;
45
- output?: string;
46
- }
47
-
48
- const DEFAULTS: KountConfig = {
49
- rootDir: '.',
50
- outputMode: 'terminal',
51
- includeTests: false,
52
- respectGitignore: true,
53
- cache: {
54
- enabled: true,
55
- clearFirst: false,
56
- },
57
- force: false,
58
- };
59
-
60
- /**
61
- * Attempts to load a config file from the given directory.
62
- * Checks for .kountrc.json first, then .kountrc.yaml.
63
- */
64
- async function loadConfigFile(dir: string): Promise<ConfigFile> {
65
- const jsonPath = path.join(dir, '.kountrc.json');
66
-
67
- try {
68
- const raw = await fsp.readFile(jsonPath, 'utf8');
69
- return JSON.parse(raw) as ConfigFile;
70
- } catch {
71
- // JSON not found or invalid — try YAML
72
- }
73
-
74
- // YAML support: we'll parse manually for the simple flat structure
75
- // rather than adding a heavy dependency. This handles basic key: value pairs.
76
- const yamlPath = path.join(dir, '.kountrc.yaml');
77
- try {
78
- const raw = await fsp.readFile(yamlPath, 'utf8');
79
- return parseSimpleYaml(raw);
80
- } catch {
81
- // No config file found — use defaults
82
- }
83
-
84
- return {};
85
- }
86
-
87
- /**
88
- * Minimal YAML parser for flat config files.
89
- * Handles: string, boolean, and nested single-level objects.
90
- */
91
- function parseSimpleYaml(raw: string): ConfigFile {
92
- const result: Record<string, unknown> = {};
93
- let currentObject: Record<string, unknown> | null = null;
94
- let currentKey = '';
95
-
96
- for (const line of raw.split('\n')) {
97
- const trimmed = line.trim();
98
- if (trimmed === '' || trimmed.startsWith('#')) continue;
99
-
100
- const indent = line.length - line.trimStart().length;
101
-
102
- if (indent > 0 && currentObject !== null) {
103
- // Nested key
104
- const match = trimmed.match(/^(\w+)\s*:\s*(.+)$/);
105
- if (match) {
106
- currentObject[match[1]] = parseYamlValue(match[2]);
107
- }
108
- } else {
109
- // Top-level key
110
- const match = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
111
- if (match) {
112
- const key = match[1];
113
- const value = match[2].trim();
114
-
115
- if (value === '') {
116
- // Start of nested object
117
- currentKey = key;
118
- currentObject = {};
119
- result[key] = currentObject;
120
- } else {
121
- currentObject = null;
122
- result[key] = parseYamlValue(value);
123
- }
124
- }
125
- }
126
- }
127
-
128
- return result as unknown as ConfigFile;
129
- }
130
-
131
- function parseYamlValue(val: string): string | boolean | number {
132
- if (val === 'true') return true;
133
- if (val === 'false') return false;
134
- const num = Number(val);
135
- if (!isNaN(num) && val !== '') return num;
136
- // Strip quotes
137
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
138
- return val.slice(1, -1);
139
- }
140
- return val;
141
- }
142
-
143
- /**
144
- * Resolves the final configuration from:
145
- * CLI flags > Config file > Defaults
146
- *
147
- * @param cliFlags Parsed CLI arguments.
148
- * @param cwd Current working directory (for finding config files).
149
- */
150
- export async function resolveConfig(cliFlags: CliFlags, cwd: string = process.cwd()): Promise<KountConfig> {
151
- const fileConfig = await loadConfigFile(cwd);
152
-
153
- const outputMode = validateOutputMode(
154
- cliFlags.outputMode ?? fileConfig.outputMode ?? DEFAULTS.outputMode
155
- );
156
-
157
- return {
158
- rootDir: path.resolve(cwd, cliFlags.rootDir ?? fileConfig.rootDir ?? DEFAULTS.rootDir),
159
- outputMode,
160
- includeTests: cliFlags.includeTests ?? fileConfig.includeTests ?? DEFAULTS.includeTests,
161
- respectGitignore: cliFlags.respectGitignore ?? fileConfig.respectGitignore ?? DEFAULTS.respectGitignore,
162
- cache: {
163
- enabled: cliFlags.cache ?? fileConfig.cache?.enabled ?? DEFAULTS.cache.enabled,
164
- clearFirst: cliFlags.clearCache ?? fileConfig.cache?.clearFirst ?? DEFAULTS.cache.clearFirst,
165
- },
166
- force: cliFlags.force ?? DEFAULTS.force,
167
- outputPath: cliFlags.output,
168
- };
169
- }
170
-
171
- function validateOutputMode(mode: string): 'terminal' | 'markdown' | 'html' {
172
- const valid = ['terminal', 'markdown', 'html'];
173
- if (valid.includes(mode)) return mode as 'terminal' | 'markdown' | 'html';
174
- return 'terminal';
175
- }
package/src/cli/parser.ts DELETED
@@ -1,52 +0,0 @@
1
- import { Command } from 'commander';
2
- import type { CliFlags } from './config-resolver.js';
3
-
4
- /**
5
- * Creates and configures the Commander CLI program.
6
- * Returns parsed CLI flags for the config resolver.
7
- *
8
- * Follows tuicfg-sensible-defaults, tuicfg-flags-over-args,
9
- * and tuicfg-help-system guidelines from terminal-ui skill.
10
- */
11
- export function createCli(argv: string[]): CliFlags {
12
- const program = new Command();
13
-
14
- program
15
- .name('kount')
16
- .description('Project Intelligence for Codebases — analyze your code with precision.')
17
- .version('1.0.0')
18
- .option('-d, --root-dir <path>', 'Root directory to scan (default: current directory)')
19
- .option(
20
- '-o, --output-mode <mode>',
21
- 'Output mode: terminal, markdown, or html (default: terminal)'
22
- )
23
- .option('-t, --include-tests', 'Include test files in the analysis')
24
- .option('--no-gitignore', 'Ignore .gitignore rules')
25
- .option('--no-cache', 'Disable caching')
26
- .option('--clear-cache', 'Clear the cache before scanning')
27
- .option('-f, --force', 'Force overwrite output files (for markdown mode)')
28
- .option('--output <path>', 'Output file path (for markdown mode)')
29
- .parse(argv);
30
-
31
- const opts = program.opts<{
32
- rootDir?: string;
33
- outputMode?: string;
34
- includeTests?: boolean;
35
- gitignore?: boolean;
36
- cache?: boolean;
37
- clearCache?: boolean;
38
- force?: boolean;
39
- output?: string;
40
- }>();
41
-
42
- return {
43
- rootDir: opts.rootDir,
44
- outputMode: opts.outputMode,
45
- includeTests: opts.includeTests,
46
- respectGitignore: opts.gitignore, // Commander converts --no-gitignore to gitignore: false
47
- cache: opts.cache, // Commander converts --no-cache to cache: false
48
- clearCache: opts.clearCache,
49
- force: opts.force,
50
- output: opts.output,
51
- };
52
- }
package/src/core/.gitkeep DELETED
File without changes
@@ -1,204 +0,0 @@
1
- import fsp from 'node:fs/promises';
2
- import path from 'node:path';
3
- import {
4
- BlankLinesPlugin,
5
- CommentLinesPlugin,
6
- FileSizePlugin,
7
- LanguageDistributionPlugin,
8
- LargestFilesPlugin,
9
- TotalFilesPlugin,
10
- TotalLinesPlugin,
11
- } from '../plugins/index.js';
12
- import type { AnalyzedFileData, AnalyzerPlugin, PluginResult, ProjectStats } from '../plugins/types.js';
13
- import type { ScannedFile } from '../scanner/stream-reader.js';
14
- import { Scanner } from '../scanner/stream-reader.js';
15
- import { CacheManager } from './cache.js';
16
-
17
- /**
18
- * Default set of built-in v1 plugins.
19
- */
20
- function getDefaultPlugins(): AnalyzerPlugin[] {
21
- return [
22
- new TotalLinesPlugin(),
23
- new BlankLinesPlugin(),
24
- new CommentLinesPlugin(),
25
- new FileSizePlugin(),
26
- new TotalFilesPlugin(),
27
- new LanguageDistributionPlugin(),
28
- new LargestFilesPlugin(),
29
- ];
30
- }
31
-
32
- export interface AggregatorOptions {
33
- respectGitignore?: boolean;
34
- plugins?: AnalyzerPlugin[];
35
- cacheEnabled?: boolean;
36
- clearCache?: boolean;
37
- }
38
-
39
- /**
40
- * Orchestrator: connects the Scanner to the Plugin pipeline.
41
- * Streams each file, builds AnalyzedFileData, then runs all plugins.
42
- * Integrates the CacheManager to skip unchanged files.
43
- */
44
- export class Aggregator {
45
- private scanner: Scanner;
46
- private plugins: AnalyzerPlugin[];
47
- private rootDir: string;
48
- private cache: CacheManager;
49
- private clearCacheFirst: boolean;
50
-
51
- constructor(rootDir: string, options?: AggregatorOptions) {
52
- this.rootDir = path.resolve(rootDir);
53
- this.scanner = new Scanner(this.rootDir, options?.respectGitignore ?? true);
54
- this.plugins = options?.plugins ?? getDefaultPlugins();
55
- this.cache = new CacheManager(this.rootDir, options?.cacheEnabled ?? true);
56
- this.clearCacheFirst = options?.clearCache ?? false;
57
- }
58
-
59
- /**
60
- * Runs the full pipeline: discover → cache check → stream → analyze → aggregate → save cache.
61
- * Returns a ProjectStats payload ready for reporters.
62
- *
63
- * @param onProgress Optional callback fired per file for progress tracking.
64
- */
65
- async run(onProgress?: (current: number, total: number, filePath: string) => void): Promise<ProjectStats> {
66
- // 0. Cache setup
67
- if (this.clearCacheFirst) {
68
- await this.cache.clear();
69
- }
70
- await this.cache.load();
71
-
72
- // 1. Discover files
73
- const scannedFiles = await this.scanner.discover(this.rootDir);
74
-
75
- // 2. Stream each file (or use cache) and build enriched data
76
- const analyzedFiles: AnalyzedFileData[] = [];
77
- let current = 0;
78
-
79
- for (const scannedFile of scannedFiles) {
80
- const fileData = await this.processFile(scannedFile);
81
- analyzedFiles.push(fileData);
82
- current++;
83
-
84
- if (onProgress) {
85
- onProgress(current, scannedFiles.length, scannedFile.filePath);
86
- }
87
- }
88
-
89
- // 3. Run each plugin against the full analyzed data
90
- const pluginResults = new Map<string, PluginResult>();
91
- for (const plugin of this.plugins) {
92
- const result = plugin.analyze(analyzedFiles);
93
- pluginResults.set(plugin.name, result);
94
- }
95
-
96
- // 4. Update cache with fresh per-file metrics from plugins
97
- for (const file of analyzedFiles) {
98
- const metrics: Record<string, number> = {};
99
- for (const [pluginName, result] of pluginResults) {
100
- const value = result.perFile.get(file.filePath);
101
- if (value !== undefined) {
102
- metrics[pluginName] = value;
103
- }
104
- }
105
-
106
- try {
107
- const stat = await fsp.stat(file.filePath);
108
- this.cache.set(file.filePath, stat.mtimeMs, stat.size, metrics);
109
- } catch {
110
- // File may have been deleted between discover and cache save — skip
111
- }
112
- }
113
-
114
- // 5. Save cache to disk
115
- await this.cache.save();
116
-
117
- // 6. Compute language distribution
118
- const langPlugin = this.plugins.find(
119
- (p): p is LanguageDistributionPlugin => p.name === 'LanguageDistribution'
120
- ) as LanguageDistributionPlugin | undefined;
121
- const languageDistribution = langPlugin
122
- ? langPlugin.getDistribution(analyzedFiles)
123
- : new Map<string, number>();
124
-
125
- // 7. Compute largest files
126
- const largestPlugin = this.plugins.find(
127
- (p): p is LargestFilesPlugin => p.name === 'LargestFiles'
128
- ) as LargestFilesPlugin | undefined;
129
- const largestFiles = largestPlugin
130
- ? largestPlugin.getTopFiles(analyzedFiles)
131
- : [];
132
-
133
- return {
134
- rootDir: this.rootDir,
135
- totalFiles: scannedFiles.length,
136
- pluginResults,
137
- languageDistribution,
138
- largestFiles,
139
- scannedAt: new Date(),
140
- };
141
- }
142
-
143
- /**
144
- * Processes a single file: checks cache first, streams if cache miss.
145
- */
146
- private async processFile(scannedFile: ScannedFile): Promise<AnalyzedFileData> {
147
- // Check cache (we need the current stat to compare)
148
- try {
149
- const stat = await fsp.stat(scannedFile.filePath);
150
- const cached = this.cache.lookup(scannedFile.filePath, stat.mtimeMs, stat.size);
151
-
152
- if (cached !== null) {
153
- // Cache hit — we still need AnalyzedFileData for the plugins.
154
- // Since plugins need `lines`, we need to re-stream on cache miss.
155
- // For cache hits, we can't avoid re-reading if plugins need full line data.
156
- // However the cache stores per-file metrics directly, so for a future
157
- // optimization we could bypass plugins entirely. For now, we still stream
158
- // but the cache mechanism is in place for the next optimization pass.
159
- }
160
- } catch {
161
- // stat failed — proceed with streaming
162
- }
163
-
164
- return this.streamAndParse(scannedFile);
165
- }
166
-
167
- /**
168
- * Streams a single file and collects its lines.
169
- * Uses the Scanner's streaming API to avoid loading the entire file at once.
170
- */
171
- private async streamAndParse(scannedFile: ScannedFile): Promise<AnalyzedFileData> {
172
- const lines: string[] = [];
173
- let remainder = '';
174
-
175
- await this.scanner.streamFile(scannedFile.filePath, (chunk, isLast) => {
176
- if (chunk.length === 0 && isLast) {
177
- // Flush any remaining partial line
178
- if (remainder.length > 0) {
179
- lines.push(remainder);
180
- remainder = '';
181
- }
182
- return;
183
- }
184
-
185
- const text = remainder + chunk.toString('utf8');
186
- const parts = text.split('\n');
187
-
188
- // All complete lines (everything except the last element which may be partial)
189
- for (let i = 0; i < parts.length - 1; i++) {
190
- lines.push(parts[i]);
191
- }
192
-
193
- // Keep the last part as remainder (may be partial line)
194
- remainder = parts[parts.length - 1];
195
- });
196
-
197
- return {
198
- filePath: scannedFile.filePath,
199
- size: scannedFile.size,
200
- extension: path.extname(scannedFile.filePath),
201
- lines,
202
- };
203
- }
204
- }
package/src/core/cache.ts DELETED
@@ -1,130 +0,0 @@
1
- import fsp from 'node:fs/promises';
2
- import path from 'node:path';
3
-
4
- /**
5
- * Per-file cached entry storing metrics and invalidation keys.
6
- */
7
- export interface CacheEntry {
8
- /** Last modified time in ms (from stat.mtimeMs). */
9
- mtimeMs: number;
10
- /** File size in bytes (from stat.size). */
11
- size: number;
12
- /** Cached plugin results keyed by plugin name. */
13
- metrics: Record<string, number>;
14
- }
15
-
16
- /**
17
- * Shape of the .kountcache.json file on disk.
18
- */
19
- interface CacheFile {
20
- version: number;
21
- entries: Record<string, CacheEntry>;
22
- }
23
-
24
- const CACHE_VERSION = 1;
25
- const CACHE_FILENAME = '.kountcache.json';
26
-
27
- /**
28
- * Manages the .kountcache.json file for incremental scanning.
29
- * Uses mtime + size to determine if a file needs re-scanning.
30
- */
31
- export class CacheManager {
32
- private cachePath: string;
33
- private entries: Map<string, CacheEntry> = new Map();
34
- private enabled: boolean;
35
- private dirty = false;
36
-
37
- constructor(rootDir: string, enabled: boolean = true) {
38
- this.cachePath = path.join(path.resolve(rootDir), CACHE_FILENAME);
39
- this.enabled = enabled;
40
- }
41
-
42
- /**
43
- * Loads the cache from disk. If it doesn't exist or is corrupt, starts fresh.
44
- */
45
- async load(): Promise<void> {
46
- if (!this.enabled) return;
47
-
48
- try {
49
- const raw = await fsp.readFile(this.cachePath, 'utf8');
50
- const parsed: CacheFile = JSON.parse(raw);
51
-
52
- if (parsed.version !== CACHE_VERSION) {
53
- // Version mismatch — discard and start fresh
54
- this.entries = new Map();
55
- return;
56
- }
57
-
58
- this.entries = new Map(Object.entries(parsed.entries));
59
- } catch {
60
- // File doesn't exist or is corrupt — start with empty cache
61
- this.entries = new Map();
62
- }
63
- }
64
-
65
- /**
66
- * Checks whether a file's cached entry is still valid by comparing
67
- * mtime and size from the current stat against the stored values.
68
- *
69
- * @returns The cached metrics if valid, or null if the file needs re-scanning.
70
- */
71
- lookup(filePath: string, currentMtimeMs: number, currentSize: number): Record<string, number> | null {
72
- if (!this.enabled) return null;
73
-
74
- const entry = this.entries.get(filePath);
75
- if (!entry) return null;
76
-
77
- if (entry.mtimeMs === currentMtimeMs && entry.size === currentSize) {
78
- return entry.metrics;
79
- }
80
-
81
- // Invalidated — mtime or size changed
82
- return null;
83
- }
84
-
85
- /**
86
- * Stores or updates a file's cache entry after scanning.
87
- */
88
- set(filePath: string, mtimeMs: number, size: number, metrics: Record<string, number>): void {
89
- if (!this.enabled) return;
90
-
91
- this.entries.set(filePath, { mtimeMs, size, metrics });
92
- this.dirty = true;
93
- }
94
-
95
- /**
96
- * Persists the cache to disk if any entries were updated.
97
- */
98
- async save(): Promise<void> {
99
- if (!this.enabled || !this.dirty) return;
100
-
101
- const cacheFile: CacheFile = {
102
- version: CACHE_VERSION,
103
- entries: Object.fromEntries(this.entries),
104
- };
105
-
106
- await fsp.writeFile(this.cachePath, JSON.stringify(cacheFile, null, 2), 'utf8');
107
- this.dirty = false;
108
- }
109
-
110
- /**
111
- * Removes the cache file from disk.
112
- */
113
- async clear(): Promise<void> {
114
- this.entries = new Map();
115
- this.dirty = false;
116
-
117
- try {
118
- await fsp.unlink(this.cachePath);
119
- } catch {
120
- // File didn't exist — that's fine
121
- }
122
- }
123
-
124
- /**
125
- * Returns the number of cached entries (for diagnostics).
126
- */
127
- get size(): number {
128
- return this.entries.size;
129
- }
130
- }