@anirudw/repolens 0.1.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,34 @@
1
+ name: Publish to NPM
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Node.js
17
+ uses: actions/setup-node@v4
18
+ with:
19
+ node-version: '20.x'
20
+ registry-url: 'https://registry.npmjs.org/'
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Run tests
26
+ run: npm run test
27
+
28
+ - name: Build project
29
+ run: npm run build
30
+
31
+ - name: Publish to NPM
32
+ run: npm publish --access public
33
+ env:
34
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # repolens
2
+
3
+ A CLI tool to visualize repository dependency graphs by parsing ASTs across multiple languages.
4
+
5
+ ## Features
6
+
7
+ - **Multi-language support**: JavaScript, TypeScript, Python, Java, and Markdown
8
+ - **Smart scanning**: Respects `.gitignore` rules and common build directories
9
+ - **Dependency analysis**: Builds a graph of imports and references
10
+ - **Multiple outputs**: Terminal summaries, JSON export, and interactive HTML visualization
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install
16
+ npm run build
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # Scan current directory
23
+ repolens
24
+
25
+ # Scan specific directory
26
+ repolens ./path/to/repo
27
+
28
+ # With verbose output
29
+ repolens --verbose
30
+
31
+ # Output formats
32
+ repolens --format json --output graph.json
33
+ repolens --format html --output graph.html
34
+ ```
35
+
36
+ ## Supported Languages
37
+
38
+ - JavaScript/JSX (.js, .jsx)
39
+ - TypeScript/TSX (.ts, .tsx)
40
+ - Python (.py)
41
+ - Java (.java)
42
+ - Markdown (.md)
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createCommand } from "../dist/cli/commands.js";
4
+
5
+ const program = createCommand();
6
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@anirudw/repolens",
3
+ "version": "0.1.0",
4
+ "description": "A CLI tool to visualize repository dependency graphs",
5
+ "type": "module",
6
+ "bin": {
7
+ "repolens": "./bin/repolens.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsx src/cli/index.ts",
12
+ "test": "vitest",
13
+ "lint": "tsc --noEmit"
14
+ },
15
+ "keywords": [
16
+ "repository",
17
+ "graph",
18
+ "dependencies",
19
+ "cli"
20
+ ],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "commander": "^12.1.0",
25
+ "ignore": "^5.3.1",
26
+ "picocolors": "^1.0.1",
27
+ "remark": "^15.0.1",
28
+ "remark-parse": "^11.0.0",
29
+ "tree-sitter": "^0.21.1",
30
+ "tree-sitter-java": "^0.21.0",
31
+ "tree-sitter-javascript": "^0.21.2",
32
+ "tree-sitter-python": "^0.21.0",
33
+ "unist-util-visit": "^5.1.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/mdast": "^4.0.4",
37
+ "@types/node": "^20.11.0",
38
+ "tsx": "^4.7.0",
39
+ "typescript": "^5.3.3",
40
+ "vitest": "^1.2.0"
41
+ }
42
+ }
@@ -0,0 +1,112 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { scanDirectory } from "../scanner/index.js";
5
+ import { createParser } from "../parser/index.js";
6
+ import { Graph, analyzeGraph } from "../graph/index.js";
7
+ import { exportGraphToJson } from "../renderers/json/exporter.js";
8
+ import type { ParsedFile } from "../parser/types.js";
9
+ import type { ScanResult } from "../scanner/walker.js";
10
+ import { pc } from "../utils/colors.js";
11
+
12
+ export function createCommand(): Command {
13
+ const program = new Command();
14
+
15
+ program
16
+ .name("repolens")
17
+ .description("Visualize repository dependency graphs")
18
+ .version("0.1.0")
19
+ .argument("[path]", "Directory to scan", process.cwd())
20
+ .option("-v, --verbose", "Enable verbose output", false)
21
+ .option(
22
+ "-f, --format <format>",
23
+ "Output format (text, json)",
24
+ "text"
25
+ )
26
+ .option("-o, --output <file>", "Output file path")
27
+ .action(async (path, options) => {
28
+ const scanResult = scanDirectory({ rootDir: path, verbose: options.verbose });
29
+
30
+ if (options.verbose) {
31
+ console.log(pc.dim("\nParsing files..."));
32
+ }
33
+
34
+ const parsedFiles: ParsedFile[] = [];
35
+ for (const file of scanResult.files) {
36
+ const parser = createParser(file.absolutePath);
37
+ if (parser) {
38
+ try {
39
+ const content = readFileSync(file.absolutePath, "utf-8");
40
+ const parsed = await parser.parse(file.absolutePath, content);
41
+ parsedFiles.push(parsed);
42
+ } catch (err) {
43
+ if (options.verbose) {
44
+ console.warn(`Warning: Failed to parse ${file.relativePath}: ${err}`);
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ const graph = new Graph(parsedFiles);
51
+ const rankedNodes = analyzeGraph(graph);
52
+
53
+ if (options.format === "json") {
54
+ const outputPath = options.output ?? resolve(process.cwd(), "repolens-graph.json");
55
+ await exportGraphToJson(graph, parsedFiles, outputPath);
56
+ console.log(`Graph exported to ${outputPath}`);
57
+ } else {
58
+ printSummary(scanResult, rankedNodes, options.verbose);
59
+ }
60
+ });
61
+
62
+ return program;
63
+ }
64
+
65
+ function printSummary(
66
+ result: ScanResult,
67
+ rankedNodes: ReturnType<typeof analyzeGraph>,
68
+ verbose: boolean
69
+ ): void {
70
+ console.log(pc.bold("\nRepository Scan Summary\n"));
71
+ console.log(`Total files found: ${pc.cyan(result.totalFiles.toString())}`);
72
+ console.log(`Files ignored: ${result.ignoredCount}\n`);
73
+
74
+ console.log(pc.bold("Files by language:"));
75
+ const langColors: Record<string, (s: string) => string> = {
76
+ javascript: pc.yellow,
77
+ typescript: pc.blue,
78
+ python: pc.green,
79
+ java: pc.red,
80
+ markdown: pc.magenta,
81
+ };
82
+
83
+ for (const [lang, count] of Object.entries(result.filesByLanguage)) {
84
+ if (count > 0) {
85
+ const color = langColors[lang] ?? pc.white;
86
+ console.log(` ${color(`• ${lang}`)}: ${pc.bold(count.toString())}`);
87
+ }
88
+ }
89
+
90
+ const topHubs = rankedNodes.filter((n) => n.inboundEdges > 0).slice(0, 3);
91
+ if (topHubs.length > 0) {
92
+ console.log(pc.bold("\nTop Hubs (Most Connected):"));
93
+ for (const hub of topHubs) {
94
+ const badge = hub.metadata?.heuristics?.isReact ? " [React]" : "";
95
+ console.log(
96
+ ` ${pc.cyan(hub.relativePath)}${badge}: ${pc.bold(hub.inboundEdges.toString())} inbound`
97
+ );
98
+ }
99
+ }
100
+
101
+ if (verbose && result.files.length > 0) {
102
+ console.log(pc.bold("\nScanned files:"));
103
+ for (const file of result.files.slice(0, 50)) {
104
+ console.log(` ${pc.dim(file.relativePath)}`);
105
+ }
106
+ if (result.files.length > 50) {
107
+ console.log(` ${pc.dim(`... and ${result.files.length - 50} more`)}`);
108
+ }
109
+ }
110
+
111
+ console.log();
112
+ }
@@ -0,0 +1,4 @@
1
+ import { createCommand } from "./commands.js";
2
+
3
+ const program = createCommand();
4
+ program.parse(process.argv);
@@ -0,0 +1,31 @@
1
+ import { Graph, type GraphNode } from "../network.js";
2
+
3
+ export interface RankedNode extends GraphNode {
4
+ centrality: number;
5
+ }
6
+
7
+ export function analyzeGraph(graph: Graph): RankedNode[] {
8
+ const nodes = graph.getNodes();
9
+ const ranked: RankedNode[] = [];
10
+
11
+ for (const [, node] of nodes) {
12
+ let centrality = node.inboundEdges;
13
+
14
+ if (node.metadata?.heuristics?.isReact) {
15
+ centrality += 5;
16
+ }
17
+
18
+ if (node.metadata?.heuristics?.hasMainMethod) {
19
+ centrality += 5;
20
+ }
21
+
22
+ ranked.push({
23
+ ...node,
24
+ centrality,
25
+ });
26
+ }
27
+
28
+ ranked.sort((a, b) => b.centrality - a.centrality);
29
+
30
+ return ranked;
31
+ }
@@ -0,0 +1,2 @@
1
+ export { Graph, type GraphNode, type GraphEdge } from "./network.js";
2
+ export { analyzeGraph, type RankedNode } from "./analyzer/pagerank.js";
@@ -0,0 +1,148 @@
1
+ import { resolve, dirname, join } from "node:path";
2
+ import type { ParsedFile, ExtractedDependency } from "../parser/types.js";
3
+
4
+ export interface GraphNode {
5
+ id: string;
6
+ relativePath: string;
7
+ language: string;
8
+ metadata: ParsedFile["metadata"];
9
+ inboundEdges: number;
10
+ outboundEdges: number;
11
+ }
12
+
13
+ export interface GraphEdge {
14
+ source: string;
15
+ target: string;
16
+ type: ExtractedDependency["type"];
17
+ }
18
+
19
+ export class Graph {
20
+ private nodes: Map<string, GraphNode> = new Map();
21
+ private edges: GraphEdge[] = [];
22
+
23
+ constructor(files: ParsedFile[]) {
24
+ for (const file of files) {
25
+ this.nodes.set(file.id, {
26
+ id: file.id,
27
+ relativePath: file.relativePath,
28
+ language: file.language,
29
+ metadata: file.metadata,
30
+ inboundEdges: 0,
31
+ outboundEdges: 0,
32
+ });
33
+ }
34
+
35
+ this.resolveEdges(files);
36
+ }
37
+
38
+ private resolveEdges(files: ParsedFile[]): void {
39
+ for (const file of files) {
40
+ const dir = dirname(file.id);
41
+
42
+ for (const dep of file.dependencies) {
43
+ if (!dep.rawSpecifier) continue;
44
+
45
+ if (
46
+ dep.rawSpecifier.startsWith("http") ||
47
+ dep.rawSpecifier.startsWith("//") ||
48
+ dep.rawSpecifier.startsWith("#") ||
49
+ !this.isLocalImport(dep.rawSpecifier)
50
+ ) {
51
+ continue;
52
+ }
53
+
54
+ const resolvedPath = this.resolveImportPath(
55
+ dir,
56
+ dep.rawSpecifier
57
+ );
58
+
59
+ if (resolvedPath && this.nodes.has(resolvedPath)) {
60
+ dep.resolvedPath = resolvedPath;
61
+ this.edges.push({
62
+ source: file.id,
63
+ target: resolvedPath,
64
+ type: dep.type,
65
+ });
66
+
67
+ const targetNode = this.nodes.get(resolvedPath);
68
+ if (targetNode) {
69
+ targetNode.inboundEdges++;
70
+ }
71
+
72
+ const sourceNode = this.nodes.get(file.id);
73
+ if (sourceNode) {
74
+ sourceNode.outboundEdges++;
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ private resolveImportPath(
82
+ fromDir: string,
83
+ specifier: string
84
+ ): string | null {
85
+ const candidatePaths = [
86
+ specifier,
87
+ `${specifier}.js`,
88
+ `${specifier}.ts`,
89
+ `${specifier}.jsx`,
90
+ `${specifier}.tsx`,
91
+ `${specifier}/index.js`,
92
+ `${specifier}/index.ts`,
93
+ `${specifier}/index.jsx`,
94
+ `${specifier}/index.tsx`,
95
+ ];
96
+
97
+ for (const candidate of candidatePaths) {
98
+ try {
99
+ const fullPath = resolve(fromDir, candidate);
100
+ if (this.nodes.has(fullPath)) {
101
+ return fullPath;
102
+ }
103
+ } catch {
104
+ continue;
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ private isLocalImport(specifier: string): boolean {
112
+ return (
113
+ specifier.startsWith("./") ||
114
+ specifier.startsWith("../") ||
115
+ specifier.startsWith("/")
116
+ );
117
+ }
118
+
119
+ getNodes(): Map<string, GraphNode> {
120
+ return this.nodes;
121
+ }
122
+
123
+ getEdges(): GraphEdge[] {
124
+ return this.edges;
125
+ }
126
+
127
+ getNode(id: string): GraphNode | undefined {
128
+ return this.nodes.get(id);
129
+ }
130
+
131
+ getNeighbors(id: string): { inbound: GraphNode[]; outbound: GraphNode[] } {
132
+ const inbound: GraphNode[] = [];
133
+ const outbound: GraphNode[] = [];
134
+
135
+ for (const edge of this.edges) {
136
+ if (edge.target === id) {
137
+ const node = this.nodes.get(edge.source);
138
+ if (node) inbound.push(node);
139
+ }
140
+ if (edge.source === id) {
141
+ const node = this.nodes.get(edge.target);
142
+ if (node) outbound.push(node);
143
+ }
144
+ }
145
+
146
+ return { inbound, outbound };
147
+ }
148
+ }
@@ -0,0 +1,32 @@
1
+ import type { ParserStrategy } from "./types.js";
2
+ import { MarkdownParser } from "./strategies/markdown.js";
3
+ import { JavaScriptParser } from "./strategies/javascript.js";
4
+ import { PythonParser } from "./strategies/python.js";
5
+ import { JavaParser } from "./strategies/java.js";
6
+
7
+ const JAVASCRIPT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
8
+ const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
9
+ const PYTHON_EXTENSIONS = new Set([".py"]);
10
+ const JAVA_EXTENSIONS = new Set([".java"]);
11
+
12
+ export function createParser(filePath: string): ParserStrategy | null {
13
+ const ext = filePath.slice(filePath.lastIndexOf(".")).toLowerCase();
14
+
15
+ if (JAVASCRIPT_EXTENSIONS.has(ext)) {
16
+ return new JavaScriptParser();
17
+ }
18
+
19
+ if (MARKDOWN_EXTENSIONS.has(ext)) {
20
+ return new MarkdownParser();
21
+ }
22
+
23
+ if (PYTHON_EXTENSIONS.has(ext)) {
24
+ return new PythonParser();
25
+ }
26
+
27
+ if (JAVA_EXTENSIONS.has(ext)) {
28
+ return new JavaParser();
29
+ }
30
+
31
+ return null;
32
+ }
@@ -0,0 +1,154 @@
1
+ import type { ParserStrategy, ExtractedDependency, ParsedFile } from "../types.js";
2
+
3
+ type TreeSitterParser = {
4
+ parse(content: string): { rootNode: TreeNode };
5
+ setLanguage(lang: unknown): void;
6
+ };
7
+
8
+ let TreeSitterParser: new () => TreeSitterParser;
9
+ let Java: unknown;
10
+
11
+ async function ensureLoaded(): Promise<void> {
12
+ if (!TreeSitterParser) {
13
+ const [ts, j] = await Promise.all([
14
+ import("tree-sitter"),
15
+ import("tree-sitter-java"),
16
+ ]);
17
+ TreeSitterParser = ts.default as new () => TreeSitterParser;
18
+ Java = j.default;
19
+ }
20
+ }
21
+
22
+ type TreeNode = {
23
+ type: string;
24
+ text: string;
25
+ startPosition: { row: number; column: number };
26
+ endPosition: { row: number; column: number };
27
+ children: TreeNode[];
28
+ childForFieldName(name: string): TreeNode | null;
29
+ };
30
+
31
+ export class JavaParser implements ParserStrategy {
32
+ private parser: TreeSitterParser | null = null;
33
+
34
+ private async ensureParser(): Promise<TreeSitterParser> {
35
+ if (!this.parser) {
36
+ await ensureLoaded();
37
+ this.parser = new TreeSitterParser();
38
+ this.parser.setLanguage(Java);
39
+ }
40
+ return this.parser;
41
+ }
42
+
43
+ async parse(filePath: string, content: string): Promise<ParsedFile> {
44
+ const parser = await this.ensureParser();
45
+ const tree = parser.parse(content);
46
+ const rootNode = tree.rootNode as TreeNode;
47
+ const dependencies: ExtractedDependency[] = [];
48
+ const heuristics: Record<string, boolean> = {};
49
+
50
+ this.extractImports(rootNode, dependencies);
51
+ heuristics.hasMainMethod = this.detectMainMethod(rootNode);
52
+
53
+ return {
54
+ id: filePath,
55
+ relativePath: filePath.split("/").pop() ?? filePath,
56
+ language: "java",
57
+ dependencies,
58
+ metadata: {
59
+ sizeBytes: Buffer.byteLength(content, "utf-8"),
60
+ heuristics,
61
+ },
62
+ };
63
+ }
64
+
65
+ private extractImports(node: TreeNode, dependencies: ExtractedDependency[]): void {
66
+ if (node.type === "import_declaration") {
67
+ const importText = this.getScopedIdentifierText(node);
68
+ if (importText) {
69
+ dependencies.push({
70
+ rawSpecifier: importText,
71
+ type: "import",
72
+ location: {
73
+ line: node.startPosition.row + 1,
74
+ column: node.startPosition.column + 1,
75
+ },
76
+ resolvedPath: importText,
77
+ });
78
+ }
79
+ }
80
+
81
+ for (const child of node.children) {
82
+ this.extractImports(child, dependencies);
83
+ }
84
+ }
85
+
86
+ private detectMainMethod(node: TreeNode): boolean {
87
+ if (node.type === "method_declaration") {
88
+ const nameNode = this.getChildByType(node, "identifier");
89
+ const modifiersNode = this.getChildByType(node, "modifiers");
90
+
91
+ if (nameNode?.text === "main") {
92
+ if (modifiersNode) {
93
+ const modifierTexts = this.getModifierTexts(modifiersNode);
94
+ if (modifierTexts.includes("public") && modifierTexts.includes("static")) {
95
+ return true;
96
+ }
97
+ }
98
+ }
99
+ }
100
+
101
+ for (const child of node.children) {
102
+ if (this.detectMainMethod(child)) {
103
+ return true;
104
+ }
105
+ }
106
+
107
+ return false;
108
+ }
109
+
110
+ private getChildByType(node: TreeNode, type: string): TreeNode | null {
111
+ for (const child of node.children) {
112
+ if (child.type === type) return child;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ private getModifierTexts(node: TreeNode): string[] {
118
+ const modifiers: string[] = [];
119
+ for (const child of node.children) {
120
+ if (child.type === "public" || child.type === "private" || child.type === "protected" ||
121
+ child.type === "static" || child.type === "final" || child.type === "abstract" ||
122
+ child.type === "synchronized" || child.type === "volatile" || child.type === "transient" ||
123
+ child.type === "native" || child.type === "strictfp") {
124
+ modifiers.push(child.text);
125
+ } else if (child.type === "annotation" || child.type === "identifier") {
126
+ modifiers.push(child.text);
127
+ }
128
+ }
129
+ return modifiers;
130
+ }
131
+
132
+ private getScopedIdentifierText(node: TreeNode): string {
133
+ for (const child of node.children) {
134
+ if (child.type === "scoped_identifier") {
135
+ const parts: string[] = [];
136
+ this.collectIdentifiers(child, parts);
137
+ return parts.join(".");
138
+ } else if (child.type === "identifier") {
139
+ return child.text;
140
+ }
141
+ }
142
+ return "";
143
+ }
144
+
145
+ private collectIdentifiers(node: TreeNode, parts: string[]): void {
146
+ for (const child of node.children) {
147
+ if (child.type === "identifier") {
148
+ parts.push(child.text);
149
+ } else if (child.type === "scoped_identifier") {
150
+ this.collectIdentifiers(child, parts);
151
+ }
152
+ }
153
+ }
154
+ }