@chappibunny/repolens 1.15.1 → 1.15.2

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.
@@ -1,23 +1,25 @@
1
- // Dependency graph analysis with cycle detection
2
- // Parses import/require statements, builds a directed graph, detects cycles via DFS
1
+ // Dependency graph analysis with cycle detection.
2
+ // JavaScript, TypeScript, Python, and Go imports become edges.
3
+ // Comments and strings are ignored. Type-only TypeScript imports are not edges.
3
4
 
4
5
  import fs from "node:fs/promises";
5
6
  import path from "node:path";
6
7
  import { info, warn } from "../utils/logger.js";
7
-
8
- const CODE_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
9
-
10
- // Patterns for extracting imports
11
- const IMPORT_PATTERNS = [
12
- // ES module: import ... from "..."
13
- /import\s+(?:[\w*{},\s]+\s+from\s+)?['"]([^'"]+)['"]/g,
14
- // Dynamic import: import("...")
15
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
16
- // CommonJS: require("...")
17
- /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
18
- // Re-export: export ... from "..."
19
- /export\s+(?:[\w*{},\s]+\s+from\s+)?['"]([^'"]+)['"]/g,
20
- ];
8
+ import {
9
+ extractJsImports,
10
+ externalPackageName,
11
+ extractPythonImports,
12
+ extractGoImports,
13
+ indexPythonModules,
14
+ resolvePythonImport,
15
+ goPackageDir,
16
+ isGoStdlib,
17
+ parseGoModule,
18
+ } from "./import-parsers.js";
19
+
20
+ const JS_EXTENSIONS = new Set([".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs"]);
21
+ const PY_EXTENSIONS = new Set([".py", ".pyw"]);
22
+ const GO_EXTENSIONS = new Set([".go"]);
21
23
 
22
24
  async function readFileSafe(filePath) {
23
25
  try {
@@ -27,6 +29,10 @@ async function readFileSafe(filePath) {
27
29
  }
28
30
  }
29
31
 
32
+ function isGoTestFile(file) {
33
+ return String(file).replace(/\\/g, "/").endsWith("_test.go");
34
+ }
35
+
30
36
  function isRelativeImport(specifier) {
31
37
  return specifier.startsWith("./") || specifier.startsWith("../");
32
38
  }
@@ -58,63 +64,110 @@ function normalizeFileKey(file) {
58
64
  return key;
59
65
  }
60
66
 
61
- export async function analyzeDependencyGraph(files, repoRoot) {
62
- const result = {
63
- nodes: [], // { key, file, imports: string[], importedBy: string[] }
64
- edges: [], // { from, to }
65
- cycles: [], // [ [a, b, c, a], ... ]
66
- externalDeps: [],// sorted unique bare-specifier imports
67
+ function emptyResult() {
68
+ return {
69
+ nodes: [],
70
+ edges: [],
71
+ cycles: [],
72
+ externalDeps: [],
67
73
  stats: null,
68
74
  summary: null,
69
75
  };
76
+ }
77
+
78
+ async function loadGoModule(repoRoot) {
79
+ const text = await readFileSafe(path.join(repoRoot, "go.mod"));
80
+ return parseGoModule(text);
81
+ }
70
82
 
71
- const codeFiles = files.filter(f => {
72
- const ext = path.extname(f);
73
- return CODE_EXTENSIONS.has(ext);
83
+ function goFilesInDir(goFiles, dir) {
84
+ const target = dir || "";
85
+ return goFiles.filter((file) => {
86
+ const fileDir = path.posix.dirname(file.replace(/\\/g, "/"));
87
+ const normalized = fileDir === "." ? "" : fileDir;
88
+ return normalized === target;
74
89
  });
90
+ }
91
+
92
+ export async function analyzeDependencyGraph(files, repoRoot) {
93
+ const result = emptyResult();
94
+
95
+ const jsFiles = files.filter((file) => JS_EXTENSIONS.has(path.extname(file)));
96
+ const pyFiles = files.filter((file) => PY_EXTENSIONS.has(path.extname(file)));
97
+ const goFiles = files.filter((file) => GO_EXTENSIONS.has(path.extname(file)) && !isGoTestFile(file));
98
+ const codeFiles = [...jsFiles, ...pyFiles, ...goFiles];
75
99
 
76
100
  if (codeFiles.length === 0) return result;
77
101
 
78
- // Build adjacency: fileKey → Set<fileKey>
79
102
  const adjacency = new Map();
80
103
  const fileKeyToFile = new Map();
81
104
  const allFileKeys = new Set();
82
105
  const externalSet = new Set();
106
+ const externalByKey = new Map();
83
107
 
84
- // Register all known file keys
85
- for (const file of codeFiles) {
108
+ function register(file) {
86
109
  const key = normalizeFileKey(file);
87
110
  allFileKeys.add(key);
88
- fileKeyToFile.set(key, file);
111
+ if (!fileKeyToFile.has(key)) fileKeyToFile.set(key, file);
89
112
  if (!adjacency.has(key)) adjacency.set(key, new Set());
113
+ if (!externalByKey.has(key)) externalByKey.set(key, new Set());
114
+ return key;
115
+ }
116
+
117
+ function addExternal(fromKey, name) {
118
+ if (!name) return;
119
+ externalSet.add(name);
120
+ externalByKey.get(fromKey).add(name);
121
+ }
122
+
123
+ function addEdge(fromKey, toKey) {
124
+ if (!toKey || toKey === fromKey || !allFileKeys.has(toKey)) return;
125
+ adjacency.get(fromKey).add(toKey);
90
126
  }
91
127
 
92
- // Parse imports and build edges
93
- for (const file of codeFiles) {
128
+ for (const file of codeFiles) register(file);
129
+
130
+ const pyIndex = indexPythonModules(pyFiles);
131
+ const goModulePath = goFiles.length > 0 ? await loadGoModule(repoRoot) : null;
132
+
133
+ for (const file of jsFiles) {
94
134
  const content = await readFileSafe(path.join(repoRoot, file));
95
135
  if (!content) continue;
136
+ const fromKey = normalizeFileKey(file);
137
+ for (const imported of extractJsImports(content)) {
138
+ if (imported.typeOnly) continue;
139
+ if (isRelativeImport(imported.specifier)) {
140
+ addEdge(fromKey, resolveImportPath(file, imported.specifier));
141
+ } else {
142
+ addExternal(fromKey, externalPackageName(imported.specifier));
143
+ }
144
+ }
145
+ }
96
146
 
147
+ for (const file of pyFiles) {
148
+ const content = await readFileSafe(path.join(repoRoot, file));
149
+ if (!content) continue;
97
150
  const fromKey = normalizeFileKey(file);
151
+ for (const imported of extractPythonImports(content)) {
152
+ const resolved = resolvePythonImport(file, imported, pyIndex);
153
+ if (resolved.kind === "internal") addEdge(fromKey, normalizeFileKey(resolved.file));
154
+ else if (resolved.kind === "external") addExternal(fromKey, resolved.name);
155
+ }
156
+ }
98
157
 
99
- for (const pattern of IMPORT_PATTERNS) {
100
- // Reset lastIndex since we reuse regex objects
101
- const regex = new RegExp(pattern.source, pattern.flags);
102
- let match;
103
- while ((match = regex.exec(content)) !== null) {
104
- const specifier = match[1];
105
-
106
- if (isRelativeImport(specifier)) {
107
- const resolved = resolveImportPath(file, specifier);
108
- if (resolved && allFileKeys.has(resolved)) {
109
- adjacency.get(fromKey).add(resolved);
110
- }
111
- } else if (!specifier.startsWith("node:")) {
112
- // Bare specifier → external dependency
113
- // Normalize scoped packages: @scope/pkg/path → @scope/pkg
114
- const parts = specifier.startsWith("@") ? specifier.split("/").slice(0, 2) : specifier.split("/").slice(0, 1);
115
- externalSet.add(parts.join("/"));
158
+ for (const file of goFiles) {
159
+ const content = await readFileSafe(path.join(repoRoot, file));
160
+ if (!content) continue;
161
+ const fromKey = normalizeFileKey(file);
162
+ for (const importPath of extractGoImports(content)) {
163
+ const dir = goPackageDir(importPath, goModulePath, "");
164
+ if (dir !== null) {
165
+ for (const target of goFilesInDir(goFiles, dir)) {
166
+ addEdge(fromKey, normalizeFileKey(target));
116
167
  }
168
+ continue;
117
169
  }
170
+ if (!isGoStdlib(importPath)) addExternal(fromKey, importPath);
118
171
  }
119
172
  }
120
173
 
@@ -139,6 +192,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
139
192
  file: fileKeyToFile.get(key) || key,
140
193
  imports: [...(adjacency.get(key) || [])],
141
194
  importedBy: importedByMap.get(key) || [],
195
+ externalImports: [...(externalByKey.get(key) || [])].sort(),
142
196
  });
143
197
  }
144
198
 
@@ -133,16 +133,54 @@ const DEFAULT_DOMAIN_HINTS = [
133
133
  }
134
134
  ];
135
135
 
136
- export function inferDomain(modulePath, customHints = []) {
136
+ const REACT_HOOK_KEYWORDS = new Set(["hook", "hooks", "use"]);
137
+
138
+ export function pathTokens(modulePath) {
139
+ return String(modulePath).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
140
+ }
141
+
142
+ /** Match a path token, not a substring (`ai` does not match `_main` or `email`). */
143
+ export function pathHasKeyword(modulePath, keyword) {
144
+ const needle = String(keyword).toLowerCase();
145
+ if (!needle) return false;
146
+ return pathTokens(modulePath).some((token) => {
147
+ if (token === needle || token === `${needle}s`) return true;
148
+ if (needle.length >= 5 && token.startsWith(needle)) return true;
149
+ return false;
150
+ });
151
+ }
152
+
153
+ export function moduleEvidence(moduleKey, depGraph) {
154
+ const key = String(moduleKey).replace(/\\/g, "/");
155
+ const nodes = (depGraph?.nodes || []).filter((node) => {
156
+ const file = String(node.file || node.key || "").replace(/\\/g, "/");
157
+ return file === key || file.startsWith(`${key}/`) || node.key === key;
158
+ });
159
+ const imports = new Set(nodes.flatMap((node) => node.externalImports || []));
160
+ const react = [...imports].some((name) => name === "react" || name === "react-dom" || name.startsWith("react/"));
161
+ const ai = [...imports].some((name) => /(^|\/)(openai|anthropic|langchain|ollama)(\/|$)/i.test(name) || name.startsWith("@anthropic-ai/") || name.startsWith("@ai-sdk/"));
162
+ return { react, ai };
163
+ }
164
+
165
+ function domainEvidenceText(hint, modulePath, keyword, evidence) {
166
+ const isDefault = DEFAULT_DOMAIN_HINTS.includes(hint);
167
+ if (!isDefault && hint.description) return hint.description;
168
+ const bits = [`path \`${modulePath}\` matches \`${keyword}\``];
169
+ if (REACT_HOOK_KEYWORDS.has(keyword) && evidence.react) bits.push("imports react");
170
+ if (hint.domain === "AI & Machine Learning" && evidence.ai) bits.push("imports an AI SDK");
171
+ return bits.join("; ");
172
+ }
173
+
174
+ export function inferDomain(modulePath, customHints = [], evidence = {}) {
137
175
  const hints = [...customHints, ...DEFAULT_DOMAIN_HINTS];
138
- const lowerPath = modulePath.toLowerCase();
139
-
176
+
140
177
  for (const hint of hints) {
141
178
  for (const keyword of hint.match) {
142
- if (lowerPath.includes(keyword)) {
179
+ if (REACT_HOOK_KEYWORDS.has(keyword) && !evidence.react) continue;
180
+ if (pathHasKeyword(modulePath, keyword)) {
143
181
  return {
144
182
  domain: hint.domain,
145
- description: hint.description,
183
+ description: domainEvidenceText(hint, modulePath, keyword, evidence),
146
184
  confidence: "pattern_match",
147
185
  matchedKeyword: keyword
148
186
  };
@@ -158,11 +196,11 @@ export function inferDomain(modulePath, customHints = []) {
158
196
  };
159
197
  }
160
198
 
161
- export function groupModulesByDomain(modules, customHints = []) {
199
+ export function groupModulesByDomain(modules, customHints = [], depGraph = null) {
162
200
  const domainGroups = {};
163
201
 
164
202
  for (const module of modules) {
165
- const inference = inferDomain(module.key, customHints);
203
+ const inference = inferDomain(module.key, customHints, moduleEvidence(module.key, depGraph));
166
204
  const domainName = inference.domain;
167
205
 
168
206
  if (!domainGroups[domainName]) {