repograph
Version:
Generate rich, semantic, and interactive codemaps with a functional, composable API for Node.js.
570 lines (558 loc) • 26 kB
JavaScript
;
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