@lakindu_perera/toren 1.0.7 → 1.0.9

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +120 -31
  3. package/dist/cli/toren.d.ts +24 -0
  4. package/dist/cli/toren.js +254 -0
  5. package/dist/cli/toren.js.map +1 -0
  6. package/dist/detectors/config-detector.d.ts +3 -0
  7. package/dist/detectors/config-detector.js +58 -0
  8. package/dist/detectors/config-detector.js.map +1 -0
  9. package/dist/detectors/health-detector.d.ts +12 -0
  10. package/dist/detectors/health-detector.js +53 -0
  11. package/dist/detectors/health-detector.js.map +1 -0
  12. package/dist/detectors/important-files-detector.d.ts +51 -0
  13. package/dist/detectors/important-files-detector.js +455 -0
  14. package/dist/detectors/important-files-detector.js.map +1 -0
  15. package/dist/detectors/package-manager-detector.d.ts +7 -0
  16. package/dist/detectors/package-manager-detector.js +35 -0
  17. package/dist/detectors/package-manager-detector.js.map +1 -0
  18. package/dist/detectors/project-info-detector.d.ts +13 -0
  19. package/dist/detectors/project-info-detector.js +97 -0
  20. package/dist/detectors/project-info-detector.js.map +1 -0
  21. package/dist/detectors/script-detector.d.ts +42 -0
  22. package/dist/detectors/script-detector.js +199 -0
  23. package/dist/detectors/script-detector.js.map +1 -0
  24. package/dist/focused-output.d.ts +27 -0
  25. package/dist/focused-output.js +201 -0
  26. package/dist/focused-output.js.map +1 -0
  27. package/dist/lifecycle.d.ts +2 -0
  28. package/dist/lifecycle.js +114 -0
  29. package/dist/lifecycle.js.map +1 -0
  30. package/dist/renderers/console-renderer.d.ts +19 -0
  31. package/dist/renderers/console-renderer.js +361 -0
  32. package/dist/renderers/console-renderer.js.map +1 -0
  33. package/dist/renderers/html-renderer.d.ts +14 -0
  34. package/{src → dist}/renderers/html-renderer.js +263 -201
  35. package/dist/renderers/html-renderer.js.map +1 -0
  36. package/dist/renderers/index.d.ts +6 -0
  37. package/dist/renderers/index.js +12 -0
  38. package/dist/renderers/index.js.map +1 -0
  39. package/dist/renderers/json-renderer.d.ts +10 -0
  40. package/dist/renderers/json-renderer.js +127 -0
  41. package/dist/renderers/json-renderer.js.map +1 -0
  42. package/dist/renderers/markdown-renderer.d.ts +13 -0
  43. package/dist/renderers/markdown-renderer.js +360 -0
  44. package/dist/renderers/markdown-renderer.js.map +1 -0
  45. package/dist/scanner/scan.d.ts +2 -0
  46. package/dist/scanner/scan.js +573 -0
  47. package/dist/scanner/scan.js.map +1 -0
  48. package/dist/types/index.d.ts +85 -0
  49. package/dist/types/index.js +2 -0
  50. package/dist/types/index.js.map +1 -0
  51. package/package.json +16 -7
  52. package/bin/toren.js +0 -308
  53. package/src/detectors/config-detector.js +0 -81
  54. package/src/detectors/script-detector.js +0 -32
  55. package/src/focused-output.js +0 -146
  56. package/src/lifecycle.js +0 -124
  57. package/src/renderers/console-renderer.js +0 -350
  58. package/src/renderers/index.js +0 -41
  59. package/src/renderers/json-renderer.js +0 -154
  60. package/src/renderers/markdown-renderer.js +0 -337
  61. package/src/scanner/scan.js +0 -532
@@ -1,532 +0,0 @@
1
- /**
2
- * @fileoverview Toren CLI — Codebase Scanner (Core Logic)
3
- *
4
- * Responsibilities:
5
- * - Recursively walk a project directory
6
- * - Ignore irrelevant paths (node_modules, .git, dist, build, etc.)
7
- * - Build an in-memory file-tree representation
8
- * - Detect the project type from marker files
9
- * - Identify known entry-point files
10
- *
11
- * This module is intentionally free of side-effects (no console.log),
12
- * except for warnings about unreadable directories.
13
- * All output concerns live in bin/toren.js.
14
- *
15
- * Designed to scale into:
16
- * - Module / dependency graph analysis
17
- * - Architecture visualisation layers
18
- * - AI explanation integrations
19
- */
20
-
21
- import fs from 'node:fs';
22
- import path from 'node:path';
23
- import { detectConfigs } from '../detectors/config-detector.js';
24
- import { detectScripts } from '../detectors/script-detector.js';
25
-
26
- // ---------------------------------------------------------------------------
27
- // Constants
28
- // ---------------------------------------------------------------------------
29
-
30
- /**
31
- * Directory / file names that are never walked.
32
- * Stored as a Set for O(1) membership checks.
33
- * @type {Set<string>}
34
- */
35
- const IGNORED_DIRS = new Set([
36
- 'node_modules',
37
- '.git',
38
- 'dist',
39
- 'build',
40
- '.cache',
41
- '.next',
42
- '.nuxt',
43
- 'out',
44
- 'coverage',
45
- '__pycache__',
46
- '.venv',
47
- 'venv',
48
- '.idea',
49
- '.vscode',
50
- '.svelte-kit',
51
- '.turbo',
52
- '.parcel-cache',
53
- '.DS_Store'
54
- ]);
55
-
56
- /**
57
- * Mapping from a marker filename to a human-readable project-type label.
58
- * Evaluated in iteration order — more specific entries should come first.
59
- * @type {Array<{ marker: string, label: string }>}
60
- */
61
- const PROJECT_TYPE_MARKERS = [
62
- { marker: 'package.json', label: 'Node.js / JavaScript' },
63
- { marker: 'pom.xml', label: 'Java / Spring Boot' },
64
- { marker: 'build.gradle', label: 'Java / Gradle' },
65
- { marker: 'requirements.txt', label: 'Python' },
66
- { marker: 'Pipfile', label: 'Python (Pipenv)' },
67
- { marker: 'pyproject.toml', label: 'Python (pyproject)' },
68
- { marker: 'go.mod', label: 'Go' },
69
- { marker: 'Cargo.toml', label: 'Rust' },
70
- { marker: 'composer.json', label: 'PHP / Composer' },
71
- { marker: 'Gemfile', label: 'Ruby' },
72
- { marker: 'mix.exs', label: 'Elixir' },
73
- ];
74
-
75
- // ---------------------------------------------------------------------------
76
- // Types (JSDoc — no TypeScript dependency required)
77
- // ---------------------------------------------------------------------------
78
-
79
- /**
80
- * @typedef {Object} FileNode
81
- * @property {'file'} type
82
- * @property {string} name - Basename of the file
83
- * @property {string} fullPath - Absolute path
84
- * @property {string} relPath - Path relative to the scanned root (POSIX style)
85
- */
86
-
87
- /**
88
- * @typedef {Object} DirNode
89
- * @property {'directory'} type
90
- * @property {string} name - Basename of the directory
91
- * @property {string} fullPath - Absolute path
92
- * @property {string} relPath - Path relative to the scanned root (POSIX style)
93
- * @property {Array<FileNode|DirNode>} children
94
- */
95
-
96
- /**
97
- * @typedef {Object} ScanResult
98
- * @property {string} rootPath - Absolute path that was scanned
99
- * @property {string} projectType - Detected project type label
100
- * @property {Array<string>} entryPoints - Relative paths of detected entry points
101
- * @property {Array<string>} configs - Relative paths of detected config files
102
- * @property {Array<{name: string, command: string}>} scripts - Parsed package scripts
103
- * @property {DirNode} tree - Full in-memory file tree
104
- * @property {Array<string>} flatFiles - All relative file paths (flat list)
105
- * @property {number} totalFolders - Total number of directories walked
106
- * @property {number} scanDurationMs - Wall-clock time of the scan in milliseconds
107
- */
108
-
109
- // ---------------------------------------------------------------------------
110
- // Internal helpers
111
- // ---------------------------------------------------------------------------
112
-
113
- /**
114
- * Normalize a path to use POSIX separators ('/').
115
- */
116
- function toPosix(p) {
117
- return p.replace(/\\/g, '/');
118
- }
119
-
120
- /**
121
- * Determine whether a directory entry should be skipped.
122
- *
123
- * @param {string} name - Basename of the entry
124
- * @param {fs.Dirent} dirent
125
- * @param {boolean} includeHidden
126
- * @returns {boolean}
127
- */
128
- function shouldIgnore(name, dirent, includeHidden) {
129
- if (!includeHidden && name.startsWith('.')) return true;
130
- return IGNORED_DIRS.has(name);
131
- }
132
-
133
- /**
134
- * Recursively walk `dirPath`, building a DirNode tree.
135
- * Also populates `flatFiles` array by reference.
136
- *
137
- * @param {string} dirPath - Absolute path of the current directory
138
- * @param {string} rootPath - Absolute path of the scan root (for relative paths)
139
- * @param {Array<string>} flatFiles - Accumulator for all relative file paths
140
- * @param {boolean} includeHidden - Whether to include hidden files
141
- * @param {number} maxFiles - Maximum number of files to scan before aborting
142
- * @returns {DirNode}
143
- */
144
- function walkDirectory(dirPath, rootPath, flatFiles, includeHidden, maxFiles) {
145
- const name = path.basename(dirPath);
146
- let rawRelPath = path.relative(rootPath, dirPath) || '.';
147
- const relPath = toPosix(rawRelPath);
148
-
149
- /** @type {DirNode} */
150
- const node = {
151
- type: 'directory',
152
- name,
153
- fullPath: dirPath,
154
- relPath,
155
- children: [],
156
- };
157
-
158
- let entries;
159
- try {
160
- entries = fs.readdirSync(dirPath, { withFileTypes: true });
161
- } catch {
162
- // Permission-denied or unreadable directory — skip silently.
163
- // Write to stderr so stdout remains clean for piped/redirected output.
164
- process.stderr.write(`[toren] warning: skipping unreadable directory: ${dirPath}\n`);
165
- return node;
166
- }
167
-
168
- // Sort: directories first, then files — both alphabetically.
169
- entries.sort((a, b) => {
170
- const aIsDir = a.isDirectory() ? 0 : 1;
171
- const bIsDir = b.isDirectory() ? 0 : 1;
172
- if (aIsDir !== bIsDir) return aIsDir - bIsDir;
173
- return a.name.localeCompare(b.name);
174
- });
175
-
176
- for (const dirent of entries) {
177
- if (shouldIgnore(dirent.name, dirent, includeHidden)) continue;
178
-
179
- const childPath = path.join(dirPath, dirent.name);
180
-
181
- // Explicitly skip symbolic links to prevent infinite loops and unsafe traversals
182
- if (dirent.isSymbolicLink()) continue;
183
-
184
- if (dirent.isDirectory()) {
185
- const childNode = walkDirectory(childPath, rootPath, flatFiles, includeHidden, maxFiles);
186
- node.children.push(childNode);
187
- } else if (dirent.isFile()) {
188
- if (flatFiles.length >= maxFiles) {
189
- const err = new Error(`Max file scan limit exceeded (${maxFiles} files).`);
190
- err.title = 'Scan limit exceeded';
191
- err.detailLabel = 'Hint';
192
- err.detailValue = 'Use --max-files <number> to increase the limit.';
193
- throw err;
194
- }
195
- const relFilePath = toPosix(path.relative(rootPath, childPath));
196
-
197
- /** @type {FileNode} */
198
- const fileNode = {
199
- type: 'file',
200
- name: dirent.name,
201
- fullPath: childPath,
202
- relPath: relFilePath,
203
- };
204
-
205
- node.children.push(fileNode);
206
- flatFiles.push(relFilePath);
207
- }
208
- }
209
-
210
- return node;
211
- }
212
-
213
- /**
214
- * Detect the project type by checking for known marker files in `rootPath`.
215
- *
216
- * Returns the label of the first matched marker, or `'Unknown'` if none match.
217
- *
218
- * @param {string} rootPath - Absolute path to the project root
219
- * @returns {string}
220
- */
221
- function detectProjectType(rootPath) {
222
- for (const { marker, label } of PROJECT_TYPE_MARKERS) {
223
- const markerPath = path.join(rootPath, marker);
224
- if (fs.existsSync(markerPath)) {
225
- // Refine Node.js projects by inspecting package.json dependencies.
226
- if (marker === 'package.json') {
227
- return refineNodeProjectType(markerPath);
228
- }
229
- return label;
230
- }
231
- }
232
- return 'Unknown';
233
- }
234
-
235
- /**
236
- * Read `package.json` and return a more specific label when React / Next / Vue
237
- * etc. are listed as dependencies.
238
- *
239
- * @param {string} pkgPath - Absolute path to package.json
240
- * @returns {string}
241
- */
242
- function refineNodeProjectType(pkgPath) {
243
- try {
244
- const raw = fs.readFileSync(pkgPath, 'utf8');
245
- const pkg = JSON.parse(raw);
246
- const deps = {
247
- ...pkg.dependencies,
248
- ...pkg.devDependencies,
249
- ...pkg.peerDependencies,
250
- };
251
-
252
- if (deps['next']) return 'Next.js';
253
- if (deps['nuxt'] || deps['nuxt3']) return 'Nuxt.js';
254
- if (deps['react']) return 'React';
255
- if (deps['vue']) return 'Vue.js';
256
- if (deps['@angular/core']) return 'Angular';
257
- if (deps['svelte']) return 'Svelte';
258
- if (deps['express']) return 'Node.js / Express';
259
- if (deps['fastify']) return 'Node.js / Fastify';
260
- if (deps['koa']) return 'Node.js / Koa';
261
- if (deps['typescript']) return 'Node.js / TypeScript';
262
- } catch {
263
- // Malformed package.json — fall through.
264
- }
265
- return 'Node.js / JavaScript';
266
- }
267
-
268
- // ---------------------------------------------------------------------------
269
- // Entry Point Heuristics
270
- // ---------------------------------------------------------------------------
271
-
272
- const FALSE_POSITIVES = [
273
- '/internal/', '/renderer/', '/renderers/', '/dist/', '/build/', '/generated/', '/node_modules/'
274
- ];
275
-
276
- function isFalsePositive(relPath) {
277
- const normalized = '/' + relPath + '/'; // relPath is already POSIX
278
- if (FALSE_POSITIVES.some(fp => normalized.includes(fp))) return true;
279
- if (relPath.includes('.test.') || relPath.includes('.spec.')) return true;
280
- // Exclude config files usually not entry points
281
- if (relPath.endsWith('.config.js') || relPath.endsWith('.config.ts')) return true;
282
- return false;
283
- }
284
-
285
- function findEntryPoints(projectType, flatFiles, rootPath) {
286
- let entries = [];
287
- const validFiles = flatFiles.filter(f => !isFalsePositive(f));
288
- const validSet = new Set(validFiles);
289
-
290
- if (projectType === 'Node.js / JavaScript' || projectType.startsWith('Node.js')) {
291
- try {
292
- const pkgPath = path.join(rootPath, 'package.json');
293
- if (fs.existsSync(pkgPath)) {
294
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
295
- if (pkg.bin) {
296
- if (typeof pkg.bin === 'string') entries.push(pkg.bin);
297
- else Object.values(pkg.bin).forEach(b => entries.push(b));
298
- }
299
- if (pkg.main) entries.push(pkg.main);
300
- }
301
- } catch {}
302
-
303
- // Normalize and filter package.json entries to ensure they exist
304
- entries = entries.map(e => toPosix(e).replace(/^\.\//, '')).filter(e => validSet.has(e));
305
-
306
- if (entries.length === 0) {
307
- const fallbacks = ['src/index.ts', 'src/index.js', 'lib/index.js', 'index.js'];
308
- for (const f of fallbacks) {
309
- if (validSet.has(f)) { entries.push(f); break; }
310
- }
311
- }
312
- } else if (
313
- projectType === 'React' || projectType === 'Next.js' ||
314
- projectType === 'Nuxt.js' || projectType === 'Vue.js' ||
315
- projectType === 'Angular' || projectType === 'Svelte'
316
- ) {
317
- const priorities = [
318
- 'src/main.tsx', 'src/main.jsx', 'src/main.ts', 'src/main.js',
319
- 'pages/_app.tsx', 'pages/_app.js',
320
- 'app/layout.tsx', 'app/layout.js',
321
- 'src/App.tsx', 'src/App.jsx',
322
- 'index.html',
323
- ];
324
- for (const p of priorities) {
325
- if (validSet.has(p)) { entries.push(p); break; }
326
- }
327
- } else if (projectType.includes('Java')) {
328
- const applicationJava = validFiles.filter(f => f.endsWith('Application.java'));
329
- if (applicationJava.length > 0) {
330
- entries.push(...applicationJava);
331
- } else {
332
- for (const f of validFiles) {
333
- if (f.endsWith('.java')) {
334
- try {
335
- const content = fs.readFileSync(path.join(rootPath, f), 'utf8');
336
- if (content.includes('public static void main')) {
337
- entries.push(f);
338
- break;
339
- }
340
- } catch {}
341
- }
342
- }
343
- }
344
- } else if (projectType.includes('Python')) {
345
- const priorities = ['main.py', 'app.py', '__main__.py', 'manage.py', 'run.py'];
346
- for (const p of priorities) {
347
- if (validSet.has(p)) { entries.push(p); break; }
348
- }
349
- if (entries.length === 0) {
350
- for (const f of validFiles) {
351
- if (f.endsWith('.py')) {
352
- try {
353
- const content = fs.readFileSync(path.join(rootPath, f), 'utf8');
354
- if (content.includes('if __name__ == "__main__":') || content.includes("if __name__ == '__main__':")) {
355
- entries.push(f);
356
- break;
357
- }
358
- } catch {}
359
- }
360
- }
361
- }
362
- } else if (projectType === 'Go') {
363
- const priorities = ['main.go', 'cmd/main.go', 'cmd/api/main.go'];
364
- for (const p of priorities) {
365
- if (validSet.has(p)) { entries.push(p); break; }
366
- }
367
- } else if (projectType === 'Rust') {
368
- const priorities = ['src/main.rs', 'src/lib.rs'];
369
- for (const p of priorities) {
370
- if (validSet.has(p)) { entries.push(p); break; }
371
- }
372
- } else if (projectType === 'Ruby') {
373
- const priorities = ['app.rb', 'main.rb', 'config.ru', 'Rakefile'];
374
- for (const p of priorities) {
375
- if (validSet.has(p)) { entries.push(p); break; }
376
- }
377
- } else if (projectType === 'PHP / Composer') {
378
- const priorities = ['index.php', 'public/index.php', 'src/index.php', 'artisan'];
379
- for (const p of priorities) {
380
- if (validSet.has(p)) { entries.push(p); break; }
381
- }
382
- } else if (projectType === 'Elixir') {
383
- const priorities = ['lib/mix/tasks/run.ex', 'mix.exs'];
384
- for (const p of priorities) {
385
- if (validSet.has(p)) { entries.push(p); break; }
386
- }
387
- }
388
-
389
- if (entries.length === 0) {
390
- // Fallbacks for Unknown or unrecognised projects.
391
- // First try well-known index file patterns.
392
- for (const f of validFiles) {
393
- if (/^(src\/)?index\.[a-z]+$/.test(f)) {
394
- entries.push(f);
395
- break;
396
- }
397
- }
398
- // If still empty, pick the file with the highest import/export density.
399
- if (entries.length === 0) {
400
- let best = null;
401
- let maxScore = -1;
402
- for (const f of validFiles) {
403
- if (f.match(/\.(js|ts|jsx|tsx|py|java|go|rs|rb|php)$/)) {
404
- try {
405
- const content = fs.readFileSync(path.join(rootPath, f), 'utf8');
406
- const score = (content.match(/import /g) || []).length +
407
- (content.match(/export /g) || []).length +
408
- (content.match(/require\(/g) || []).length;
409
- if (score > maxScore) {
410
- maxScore = score;
411
- best = f;
412
- }
413
- } catch {}
414
- }
415
- }
416
- if (best) entries.push(best);
417
- }
418
- }
419
-
420
- return Array.from(new Set(entries));
421
- }
422
-
423
- // ---------------------------------------------------------------------------
424
- // Tree helpers
425
- // ---------------------------------------------------------------------------
426
-
427
- /**
428
- * Count the total number of directory nodes in a tree (including the root).
429
- *
430
- * @param {DirNode} node
431
- * @returns {number}
432
- */
433
- function countFolders(node) {
434
- let count = 1; // count this directory
435
- for (const child of node.children ?? []) {
436
- if (child.type === 'directory') {
437
- count += countFolders(child);
438
- }
439
- }
440
- return count;
441
- }
442
-
443
- // ---------------------------------------------------------------------------
444
- // Public API
445
- // ---------------------------------------------------------------------------
446
-
447
- export function scan(targetPath, options = {}) {
448
- const rootPath = path.resolve(targetPath);
449
- const includeHidden = !!options.includeHidden;
450
- const maxFiles = options.maxFiles || 50000; // Default cap of 50k files for huge repos
451
-
452
- // Validate target
453
- let stat;
454
- try {
455
- stat = fs.statSync(rootPath);
456
- } catch (err) {
457
- const error = new Error(`Path does not exist: ${rootPath}`);
458
- if (err.code === 'EACCES' || err.code === 'EPERM') {
459
- error.title = 'Permission denied';
460
- error.message = 'You do not have permission to access the specified path.';
461
- } else {
462
- error.title = 'Invalid directory';
463
- error.message = 'The specified path does not exist.';
464
- }
465
- error.detailLabel = 'Path';
466
- error.detailValue = rootPath;
467
- throw error;
468
- }
469
-
470
- /** @type {Array<string>} */
471
- const flatFiles = [];
472
- /** @type {Array<string>} */
473
- let entryPoints = [];
474
- /** @type {Array<string>} */
475
- let configs = [];
476
- /** @type {Array<{name: string, command: string}>} */
477
- let scripts = [];
478
-
479
- const startTime = performance.now();
480
- let tree;
481
- let projectType = 'Unknown';
482
- let totalFolders = 0;
483
-
484
- if (stat.isDirectory()) {
485
- tree = walkDirectory(rootPath, rootPath, flatFiles, includeHidden, maxFiles);
486
- projectType = detectProjectType(rootPath);
487
- // Count all directory nodes in the tree (excluding root itself).
488
- totalFolders = countFolders(tree) - 1;
489
- entryPoints = findEntryPoints(projectType, flatFiles, rootPath);
490
- configs = detectConfigs(flatFiles).configs;
491
- scripts = detectScripts(rootPath).scripts;
492
- } else if (stat.isFile()) {
493
- const relFilePath = toPosix(path.basename(rootPath));
494
- tree = {
495
- type: 'directory',
496
- name: path.basename(path.dirname(rootPath)),
497
- fullPath: path.dirname(rootPath),
498
- relPath: '.',
499
- children: [{
500
- type: 'file',
501
- name: relFilePath,
502
- fullPath: rootPath,
503
- relPath: relFilePath
504
- }]
505
- };
506
- flatFiles.push(relFilePath);
507
- entryPoints = [relFilePath]; // A single file is its own entry point
508
- configs = detectConfigs(flatFiles).configs;
509
- scripts = detectScripts(path.dirname(rootPath)).scripts;
510
- } else {
511
- const err = new Error(`Path is neither a file nor a directory: ${rootPath}`);
512
- err.title = 'Unsupported path type';
513
- err.message = 'The specified path is neither a file nor a directory.';
514
- err.detailLabel = 'Path';
515
- err.detailValue = rootPath;
516
- throw err;
517
- }
518
-
519
- const scanDurationMs = performance.now() - startTime;
520
-
521
- return {
522
- rootPath,
523
- projectType,
524
- entryPoints,
525
- configs,
526
- scripts,
527
- tree,
528
- flatFiles,
529
- totalFolders,
530
- scanDurationMs,
531
- };
532
- }