UNPKG

repograph

Version:

Generate rich, semantic, and interactive codemaps with a functional, composable API for Node.js.

570 lines (558 loc) 26 kB
#!/usr/bin/env bun 'use strict'; var repographCore = require('repograph-core'); var globby = require('globby'); var path3 = require('path'); var fs = require('fs/promises'); var url = require('url'); var Tinypool = require('tinypool'); var Parser = require('web-tree-sitter'); var fs2 = require('fs'); var child_process = require('child_process'); var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null; function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } function _interopNamespace(e) { if (e && e.__esModule) return e; var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var path3__default = /*#__PURE__*/_interopDefault(path3); var fs__default = /*#__PURE__*/_interopDefault(fs); var Tinypool__default = /*#__PURE__*/_interopDefault(Tinypool); var Parser__namespace = /*#__PURE__*/_interopNamespace(Parser); var fs2__default = /*#__PURE__*/_interopDefault(fs2); var readFile = async (filePath) => { try { const buffer = await fs__default.default.readFile(filePath); if (buffer.includes(0)) { throw new repographCore.FileSystemError("File appears to be binary", filePath); } return buffer.toString("utf-8"); } catch (e) { if (e instanceof repographCore.FileSystemError) throw e; throw new repographCore.FileSystemError("Failed to read file", filePath, e); } }; var writeFile = async (filePath, content) => { try { await fs__default.default.mkdir(path3__default.default.dirname(filePath), { recursive: true }); await fs__default.default.writeFile(filePath, content); } catch (e) { throw new repographCore.FileSystemError("Failed to write file", filePath, e); } }; var isDirectory = async (filePath) => { try { const stats = await fs__default.default.stat(filePath); return stats.isDirectory(); } catch (e) { if (e && typeof e === "object" && "code" in e && e.code === "ENOENT") { return false; } throw new repographCore.FileSystemError("Failed to check if path is a directory", filePath, e); } }; // src/pipeline/discover.ts var createDefaultDiscoverer = () => { return async ({ root, include, ignore: userIgnore, noGitignore = false }) => { if (!await isDirectory(root)) { throw new repographCore.FileSystemError("Root path is not a directory or does not exist", root); } const patterns = include && include.length > 0 ? [...include] : ["**/*"]; const foundPaths = await globby.globby(patterns, { cwd: root, gitignore: !noGitignore, ignore: [...userIgnore || []], dot: true, absolute: true, onlyFiles: true, followSymbolicLinks: true // Follow symlinks to find all possible files }); const relativePaths = foundPaths.map((p) => path3__default.default.relative(root, p).replace(/\\/g, "/")); const visitedRealPaths = /* @__PURE__ */ new Set(); const safeRelativePaths = []; for (const relativePath of relativePaths) { const fullPath = path3__default.default.resolve(root, relativePath); try { const realPath = await fs.realpath(fullPath); if (!visitedRealPaths.has(realPath)) { visitedRealPaths.add(realPath); safeRelativePaths.push(relativePath); } } catch (error) { repographCore.logger.debug(`Skipping file due to symlink resolution error: ${relativePath}`); } } const fileContents = await Promise.all( safeRelativePaths.map(async (relativePath) => { try { const absolutePath = path3__default.default.join(root, relativePath); const content = await readFile(absolutePath); return { path: relativePath, content }; } catch (e) { repographCore.logger.debug(`Skipping file that could not be read: ${relativePath}`, e instanceof Error ? e.message : e); return null; } }) ); return fileContents.filter((c) => c !== null); }; }; var getDirname = () => path3__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)))); var loadedLanguages = /* @__PURE__ */ new Map(); var isInitialized = false; var initializeParser = async () => { if (isInitialized) return; await Parser__namespace.Parser.init(); isInitialized = true; }; var findWasmFile = async (config) => { const wasmFileName = path3__default.default.basename(config.wasmPath); if (!wasmFileName) { throw new repographCore.ParserError(`Invalid wasmPath format for ${config.name}: ${config.wasmPath}.`, config.name); } const currentDir = getDirname(); const distWasmPath = path3__default.default.resolve(currentDir, "wasm", wasmFileName); if (fs2__default.default.existsSync(distWasmPath)) return distWasmPath; const projectDistWasmPath = path3__default.default.resolve(currentDir, "../../dist/wasm", wasmFileName); if (fs2__default.default.existsSync(projectDistWasmPath)) return projectDistWasmPath; try { const [pkgName, ...rest] = config.wasmPath.split("/"); const wasmPathInPkg = rest.join("/"); const pkgJsonUrl = await undefined(`${pkgName}/package.json`); const pkgDir = path3__default.default.dirname(url.fileURLToPath(pkgJsonUrl)); const resolvedWasmPath = path3__default.default.join(pkgDir, wasmPathInPkg); if (fs2__default.default.existsSync(resolvedWasmPath)) { return resolvedWasmPath; } } catch (e) { } throw new repographCore.ParserError(`WASM file for ${config.name} not found. Looked in ${distWasmPath}, ${projectDistWasmPath}, and tried resolving from node_modules.`, config.name); }; var loadLanguage = async (config) => { if (loadedLanguages.has(config.name)) { return loadedLanguages.get(config.name); } await initializeParser(); try { const wasmPath = await findWasmFile(config); repographCore.logger.debug(`Loading WASM for ${config.name} from: ${wasmPath}`); const language = await Parser__namespace.Language.load(wasmPath); const loadedLanguage = { config, language }; loadedLanguages.set(config.name, loadedLanguage); return loadedLanguage; } catch (error) { const message = `Failed to load Tree-sitter WASM file for ${config.name}.`; repographCore.logger.error(message, error); throw new repographCore.ParserError(message, config.name, error); } }; var createParserForLanguage = async (config) => { const { language } = await loadLanguage(config); const parser = new Parser__namespace.Parser(); parser.setLanguage(language); return parser; }; async function processFileInWorker({ file, langConfig }) { const parser = await createParserForLanguage(langConfig); return repographCore.analyzeFileContent({ file, langConfig, parser }); } // src/pipeline/analyze.ts var normalizePath = (p) => p.replace(/\\/g, "/"); var { getImportResolver } = repographCore.createLanguageImportResolvers(path3.posix); var createTreeSitterAnalyzer = (options = {}) => { const { maxWorkers = 1 } = options; return async (files) => { const nodes = /* @__PURE__ */ new Map(); let unresolvedRelations = []; const allFilePaths = files.map((f) => normalizePath(f.path)); for (const file of files) { const langConfig = repographCore.getLanguageConfigForFile(normalizePath(file.path)); nodes.set(file.path, { id: file.path, type: "file", name: path3.posix.basename(file.path), filePath: file.path, startLine: 1, endLine: file.content.split("\n").length, language: langConfig?.name }); } const filesToProcess = files.map((file) => ({ file, langConfig: repographCore.getLanguageConfigForFile(normalizePath(file.path)) })).filter((item) => !!item.langConfig); if (maxWorkers > 1) { repographCore.logger.debug(`Analyzing files in parallel with ${maxWorkers} workers.`); const pool = new Tinypool__default.default({ filename: new url.URL("analyzer.worker.js", (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))).pathname, maxThreads: maxWorkers }); const tasks = filesToProcess.map((item) => pool.run(item)); const results = await Promise.all(tasks); for (const result of results) { if (result) { result.nodes.forEach((node) => nodes.set(node.id, node)); unresolvedRelations.push(...result.relations); } } } else { repographCore.logger.debug(`Analyzing files sequentially in the main thread.`); for (const item of filesToProcess) { try { const result = await processFileInWorker(item); if (result) { result.nodes.forEach((node) => nodes.set(node.id, node)); unresolvedRelations.push(...result.relations); } } catch (error) { repographCore.logger.warn(new repographCore.ParserError(`Failed to process ${item.file.path}`, item.langConfig.name, error)); } } } const edges = []; const importEdges = []; for (const rel of unresolvedRelations) { if (rel.type === "imports") { const fromNode = nodes.get(rel.fromId); if (!fromNode || fromNode.type !== "file" || !fromNode.language) continue; const resolver = getImportResolver(fromNode.language); const toId = resolver(rel.fromId, rel.toName, allFilePaths); if (toId && nodes.has(toId)) { importEdges.push({ fromId: rel.fromId, toId, type: "imports" }); } } } const symbolResolver = new repographCore.SymbolResolver(nodes, importEdges); for (const rel of unresolvedRelations) { if (rel.type === "imports") continue; const fromFile = rel.fromId.split("#")[0]; const toNode = symbolResolver.resolve(rel.toName, fromFile); if (toNode && rel.fromId !== toNode.id) { const edgeType = rel.type === "reference" ? "calls" : rel.type; edges.push({ fromId: rel.fromId, toId: toNode.id, type: edgeType }); } } const finalEdges = [...importEdges, ...edges]; const uniqueEdges = [...new Map(finalEdges.map((e) => [`${e.fromId}->${e.toId}->${e.type}`, e])).values()]; return { nodes: Object.freeze(nodes), edges: Object.freeze(uniqueEdges) }; }; }; var createGitRanker = (options = {}) => { return async (graph) => { const { maxCommits = 500 } = options; const ranks = /* @__PURE__ */ new Map(); if (graph.nodes.size === 0) { return { ...graph, ranks }; } try { const command = `git log --max-count=${maxCommits} --name-only --pretty=format:`; const output = child_process.execSync(command, { encoding: "utf-8" }); const files = output.split("\n").filter(Boolean); const changeCounts = {}; for (const file of files) { changeCounts[file] = (changeCounts[file] || 0) + 1; } const maxChanges = Math.max(...Object.values(changeCounts), 1); for (const [nodeId, attributes] of graph.nodes) { if (attributes.type === "file") { const count = changeCounts[attributes.filePath] ?? 0; ranks.set(nodeId, count / maxChanges); } else { ranks.set(nodeId, 0); } } } catch (e) { repographCore.logger.warn('Failed to use "git" for ranking. Is git installed and is this a git repository? Defaulting to 0 for all ranks.'); for (const [nodeId] of graph.nodes) { ranks.set(nodeId, 0); } } return { ...graph, ranks }; }; }; var selectRanker = (rankingStrategy = "pagerank") => { if (rankingStrategy === "git-changes") return createGitRanker(); if (rankingStrategy === "pagerank") return repographCore.createPageRanker(); throw new Error(`Invalid ranking strategy: '${rankingStrategy}'. Available options are 'pagerank', 'git-changes'.`); }; var analyzeProject = async (options = {}) => { const { root, logLevel, include, ignore, noGitignore, maxWorkers, files: inputFiles } = options; if (logLevel) { repographCore.logger.setLevel(logLevel); } const ranker = selectRanker(options.rankingStrategy); try { let files; if (inputFiles && inputFiles.length > 0) { repographCore.logger.info("1/3 Using provided files..."); files = inputFiles; } else { const effectiveRoot = root || process.cwd(); repographCore.logger.info(`1/3 Discovering files in "${effectiveRoot}"...`); const discoverer = createDefaultDiscoverer(); files = await discoverer({ root: path3__default.default.resolve(effectiveRoot), include, ignore, noGitignore }); } repographCore.logger.debug(` -> Found ${files.length} files to analyze.`); repographCore.logger.info("2/3 Analyzing code and building graph..."); const analyzer = createTreeSitterAnalyzer({ maxWorkers }); const graph = await analyzer(files); repographCore.logger.debug(` -> Built graph with ${graph.nodes.size} nodes and ${graph.edges.length} edges.`); repographCore.logger.info("3/3 Ranking graph nodes..."); const rankedGraph = await ranker(graph); repographCore.logger.debug(" -> Ranking complete."); return rankedGraph; } catch (error) { throw new repographCore.RepoGraphError(`Failed to analyze project`, error); } }; var generateMap = async (options = {}) => { const finalOptions = { ...options, logLevel: options.logLevel ?? "info" }; const { root = process.cwd(), output = "./repograph.md" } = finalOptions; try { const rankedGraph = await analyzeProject(finalOptions); repographCore.logger.info("4/4 Rendering output..."); const renderer = repographCore.createMarkdownRenderer(); const markdown = renderer(rankedGraph, finalOptions.rendererOptions); repographCore.logger.debug(" -> Rendering complete."); const outputPath = path3__default.default.isAbsolute(output) ? output : path3__default.default.resolve(root, output); repographCore.logger.info(`Writing report to ${path3__default.default.relative(process.cwd(), outputPath)}...`); await writeFile(outputPath, markdown); repographCore.logger.info(" -> Report saved."); } catch (error) { throw error; } }; var createMapGenerator = (pipeline) => { if (!pipeline || typeof pipeline.discover !== "function" || typeof pipeline.analyze !== "function" || typeof pipeline.rank !== "function" || typeof pipeline.render !== "function") { throw new Error("createMapGenerator: A valid pipeline object with discover, analyze, rank, and render functions must be provided."); } return async (config) => { const { root, output, include, ignore, noGitignore, rendererOptions } = config; let stage = "discover"; try { repographCore.logger.info("1/4 Discovering files..."); const files = await pipeline.discover({ root, include, ignore, noGitignore }); repographCore.logger.debug(` -> Found ${files.length} files to analyze.`); stage = "analyze"; repographCore.logger.info("2/4 Analyzing code and building graph..."); const graph = await pipeline.analyze(files); repographCore.logger.debug(` -> Built graph with ${graph.nodes.size} nodes and ${graph.edges.length} edges.`); stage = "rank"; repographCore.logger.info("3/4 Ranking graph nodes..."); const rankedGraph = await pipeline.rank(graph); repographCore.logger.debug(" -> Ranking complete."); stage = "render"; repographCore.logger.info("4/4 Rendering output..."); const markdown = pipeline.render(rankedGraph, rendererOptions); repographCore.logger.debug(" -> Rendering complete."); if (output) { const outputPath = path3__default.default.isAbsolute(output) ? output : path3__default.default.resolve(root, output); stage = "write"; repographCore.logger.info(`Writing report to ${path3__default.default.relative(process.cwd(), outputPath)}...`); await writeFile(outputPath, markdown); repographCore.logger.info(" -> Report saved."); } return { graph: rankedGraph, markdown }; } catch (error) { const message = error instanceof Error ? error.message : String(error); const stageErrorMessage = stage === "write" ? `Failed to write output file` : `Error in ${stage} stage`; throw new repographCore.RepoGraphError(`${stageErrorMessage}: ${message}`, error); } }; }; var isRunningDirectly = () => { if (typeof process.argv[1] === "undefined") return false; const runningFile = path3__default.default.resolve(process.argv[1]); const currentFile = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))); return runningFile === currentFile; }; var copyWasmFiles = async (destination) => { try { const { promises: fs3 } = await import('fs'); const path8 = await import('path'); const sourceDir = path8.resolve(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))), "..", "wasm"); await fs3.mkdir(destination, { recursive: true }); const wasmFiles = (await fs3.readdir(sourceDir)).filter((file) => file.endsWith(".wasm")); for (const file of wasmFiles) { const srcPath = path8.join(sourceDir, file); const destPath = path8.join(destination, file); await fs3.copyFile(srcPath, destPath); repographCore.logger.info(`Copied ${file} to ${path8.relative(process.cwd(), destPath)}`); } repographCore.logger.info(` \u2705 All ${wasmFiles.length} WASM files copied successfully.`); } catch (err) { repographCore.logger.error("Error copying WASM files.", err); } }; if (isRunningDirectly()) { (async () => { const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(` Usage: repograph [root] [options] repograph copy-wasm [destination] Commands: [root] Analyze a repository at the given root path. This is the default command. copy-wasm [destination] Copy the necessary Tree-sitter WASM files to a specified directory for browser-based usage. (default destination: "./public/wasm") Arguments: root The root directory of the repository to analyze. Defaults to the current working directory. Options: -h, --help Display this help message. -v, --version Display the version number. --output <path> Path to the output Markdown file. (default: "repograph.md") --include <pattern> Glob pattern for files to include. Can be specified multiple times. --ignore <pattern> Glob pattern for files to ignore. Can be specified multiple times. --no-gitignore Do not respect .gitignore files. --ranking-strategy <name> The ranking strategy to use. (default: "pagerank", options: "pagerank", "git-changes") --max-workers <num> Set the maximum number of parallel workers for analysis. (default: 1) --log-level <level> Set the logging level. (default: "info", options: "silent", "error", "warn", "info", "debug") Output Formatting: --no-header Do not include the main "RepoGraph" header. --no-overview Do not include the project overview section. --no-mermaid Do not include the Mermaid dependency graph. --no-file-list Do not include the list of top-ranked files. --no-symbol-details Do not include the detailed file and symbol breakdown. --top-file-count <num> Set the number of files in the top list. (default: 10) --file-section-separator <str> Custom separator for file sections. (default: "---") --no-symbol-relations Hide symbol relationship details (e.g., calls, implements). --no-symbol-line-numbers Hide line numbers for symbols. --no-symbol-snippets Hide code snippets for symbols. --max-relations-to-show <num> Max number of 'calls' relations to show per symbol. (default: 3) `); process.exit(0); } if (args[0] === "copy-wasm") { const destDir = args[1] || "./public/wasm"; repographCore.logger.info(`Copying WASM files to "${path3__default.default.resolve(destDir)}"...`); await copyWasmFiles(destDir); process.exit(0); } if (args.includes("--version") || args.includes("-v")) { const { readFileSync } = await import('fs'); const pkgPath = new URL("../package.json", (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); console.log(pkg.version); process.exit(0); } const options = {}; const includePatterns = []; const ignorePatterns = []; const rendererOptions = {}; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (!arg) continue; switch (arg) { case "--output": options.output = args[++i]; break; case "--include": includePatterns.push(args[++i]); break; case "--ignore": ignorePatterns.push(args[++i]); break; case "--no-gitignore": options.noGitignore = true; break; case "--ranking-strategy": options.rankingStrategy = args[++i]; break; case "--max-workers": options.maxWorkers = parseInt(args[++i], 10); break; case "--log-level": options.logLevel = args[++i]; break; case "--no-header": rendererOptions.includeHeader = false; break; case "--no-overview": rendererOptions.includeOverview = false; break; case "--no-mermaid": rendererOptions.includeMermaidGraph = false; break; case "--no-file-list": rendererOptions.includeFileList = false; break; case "--no-symbol-details": rendererOptions.includeSymbolDetails = false; break; case "--top-file-count": rendererOptions.topFileCount = parseInt(args[++i], 10); break; case "--file-section-separator": rendererOptions.fileSectionSeparator = args[++i]; break; case "--no-symbol-relations": rendererOptions.symbolDetailOptions = { ...rendererOptions.symbolDetailOptions || {}, includeRelations: false }; break; case "--no-symbol-line-numbers": rendererOptions.symbolDetailOptions = { ...rendererOptions.symbolDetailOptions || {}, includeLineNumber: false }; break; case "--no-symbol-snippets": rendererOptions.symbolDetailOptions = { ...rendererOptions.symbolDetailOptions || {}, includeCodeSnippet: false }; break; case "--max-relations-to-show": rendererOptions.symbolDetailOptions = { ...rendererOptions.symbolDetailOptions || {}, maxRelationsToShow: parseInt(args[++i], 10) }; break; default: if (!arg.startsWith("-")) options.root = arg; break; } } if (includePatterns.length > 0) options.include = includePatterns; if (ignorePatterns.length > 0) options.ignore = ignorePatterns; if (Object.keys(rendererOptions).length > 0) options.rendererOptions = rendererOptions; const finalOutput = path3__default.default.resolve(options.root || process.cwd(), options.output || "repograph.md"); repographCore.logger.info(`Starting RepoGraph analysis for "${path3__default.default.resolve(options.root || process.cwd())}"...`); try { await generateMap(options); repographCore.logger.info(` \u2705 Success! RepoGraph map saved to ${path3__default.default.relative(process.cwd(), finalOutput)}`); } catch (error) { if (error instanceof repographCore.RepoGraphError) { repographCore.logger.error(` \u274C Error generating RepoGraph map: ${error.message}`); } else { repographCore.logger.error("\n\u274C An unknown error occurred while generating the RepoGraph map.", error); } process.exit(1); } })().catch((error) => { console.error("Fatal error:", error); process.exit(1); }); } exports.analyzeProject = analyzeProject; exports.createDefaultDiscoverer = createDefaultDiscoverer; exports.createGitRanker = createGitRanker; exports.createMapGenerator = createMapGenerator; exports.createTreeSitterAnalyzer = createTreeSitterAnalyzer; exports.generateMap = generateMap; exports.initializeParser = initializeParser; Object.keys(repographCore).forEach(function (k) { if (k !== 'default' && !Object.prototype.hasOwnProperty.call(exports, k)) Object.defineProperty(exports, k, { enumerable: true, get: function () { return repographCore[k]; } }); }); //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map