@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.
- package/CHANGELOG.md +47 -0
- package/README.md +82 -6
- package/llms-full.txt +84 -0
- package/llms-install.md +5 -0
- package/llms.txt +50 -0
- package/package.json +4 -2
- package/src/analyzers/context-builder.js +91 -77
- package/src/analyzers/dependency-graph.js +102 -48
- package/src/analyzers/domain-inference.js +45 -7
- package/src/analyzers/import-parsers.js +716 -0
- package/src/badge.js +274 -0
- package/src/cli-args.js +11 -1
- package/src/cli.js +24 -1
- package/src/core/scan.js +4 -0
- package/src/delivery/architecture-comment.js +480 -0
- package/src/delivery/comment-constants.js +15 -0
- package/src/delivery/comment.js +9 -5
- package/src/docs/generate-doc-set.js +3 -2
- package/src/docs/llms-text.js +180 -0
- package/src/help.js +73 -2
- package/src/renderers/render.js +33 -32
- package/src/renderers/renderAnalysis.js +2 -2
- package/src/watch.js +0 -1
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
// Dependency graph analysis with cycle detection
|
|
2
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
nodes: [],
|
|
64
|
-
edges: [],
|
|
65
|
-
cycles: [],
|
|
66
|
-
externalDeps: []
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
176
|
+
|
|
140
177
|
for (const hint of hints) {
|
|
141
178
|
for (const keyword of hint.match) {
|
|
142
|
-
if (
|
|
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
|
|
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]) {
|