@chappibunny/repolens 1.15.2 → 1.15.3
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 +13 -0
- package/README.md +8 -8
- package/llms-install.md +1 -0
- package/package.json +1 -1
- package/src/analyzers/context-builder.js +49 -8
- package/src/analyzers/dependency-graph.js +31 -5
- package/src/analyzers/import-parsers.js +118 -24
- package/src/renderers/renderAnalysis.js +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to RepoLens will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## 1.15.3
|
|
6
|
+
|
|
7
|
+
### Dependency graph
|
|
8
|
+
|
|
9
|
+
- Python imports inside `if TYPE_CHECKING:`, `if typing.TYPE_CHECKING:`, and `if <alias>.TYPE_CHECKING:` (for example `import typing as t`) are not runtime edges and do not form cycles. `from . import name` resolves to the sibling module
|
|
10
|
+
- Node.js built-ins (`fs`, `child_process`, `events`, `node:*`, and the rest of the standard builtin list) are not external packages
|
|
11
|
+
- Go hub rows are packages (`github.com/spf13/cobra`, `github.com/go-chi/chi/v5/middleware`), not individual `.go` files
|
|
12
|
+
|
|
13
|
+
### Pattern text
|
|
14
|
+
|
|
15
|
+
- Test directories and test files are not evidence for architecture patterns. A `tests/test_commands.py` module does not make the repo a "CLI tool architecture"
|
|
16
|
+
- "API route pattern" requires an `api`, `routes`, `router`, `endpoint`, `controller`, or `handler` directory. A library file named `_api.py` does not match
|
|
17
|
+
|
|
5
18
|
## 1.15.2
|
|
6
19
|
|
|
7
20
|
### Dependency graph
|
package/README.md
CHANGED
|
@@ -241,9 +241,9 @@ If `npx` reports `Unknown command: mcp`, an `.npmrc` with `min-release-age` (or
|
|
|
241
241
|
|
|
242
242
|
## GitHub Action
|
|
243
243
|
|
|
244
|
-
`action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --yes @chappibunny/repolens@1.15.
|
|
244
|
+
`action.yml` is a composite action named **RepoLens Architecture Docs**. `publish`, `demo`, and `doctor` run the published npm package (`npx --yes @chappibunny/repolens@1.15.3`). `comment` and `badge` run from the action checkout. Inputs are `command` (`publish`, `demo`, `doctor`, `comment`, or `badge`), `config`, `node-version`, `pr-comment`, `version`, `badge-out`, `badge-url`, and `badge-link`.
|
|
245
245
|
|
|
246
|
-
Publishing docs (the npm package at tag `v1.15.
|
|
246
|
+
Publishing docs (the npm package at tag `v1.15.3`):
|
|
247
247
|
|
|
248
248
|
```yaml
|
|
249
249
|
name: Architecture docs
|
|
@@ -259,7 +259,7 @@ jobs:
|
|
|
259
259
|
runs-on: ubuntu-latest
|
|
260
260
|
steps:
|
|
261
261
|
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
|
|
262
|
-
- uses: CHAPIBUNNY/repolens@v1.15.
|
|
262
|
+
- uses: CHAPIBUNNY/repolens@v1.15.3
|
|
263
263
|
with:
|
|
264
264
|
command: publish
|
|
265
265
|
config: .repolens.yml
|
|
@@ -267,7 +267,7 @@ jobs:
|
|
|
267
267
|
pr-comment: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.15.
|
|
270
|
+
On `v1.15.2`, `pr-comment: true` runs the architecture diff from the action checkout and turns the published package's older short summary off. The diff lists modules, files, dependencies, the code-health delta, and new hotspots. It edits one sticky comment (`<!-- repolens:architecture-diff -->`) instead of adding a new comment each push. The footer is "Generated by RepoLens" and links to this repository and the [gallery](https://chapibunny.github.io/repolens/). Tag `v1.15.1` does not contain `command: comment`. Pin `v1.15.3` or a commit SHA in production.
|
|
271
271
|
|
|
272
272
|
```yaml
|
|
273
273
|
name: RepoLens architecture comment
|
|
@@ -283,7 +283,7 @@ jobs:
|
|
|
283
283
|
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
|
|
284
284
|
with:
|
|
285
285
|
fetch-depth: 0
|
|
286
|
-
- uses: CHAPIBUNNY/repolens@v1.15.
|
|
286
|
+
- uses: CHAPIBUNNY/repolens@v1.15.3
|
|
287
287
|
with:
|
|
288
288
|
command: comment
|
|
289
289
|
```
|
|
@@ -292,7 +292,7 @@ jobs:
|
|
|
292
292
|
|
|
293
293
|
`pr-comment: true` on `publish`, `demo`, or `doctor` (when the action ref contains this change) runs the same comment after the package and turns the package's older comment off, so the pull request still gets one comment.
|
|
294
294
|
|
|
295
|
-
The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.15.
|
|
295
|
+
The action is listed on the GitHub Marketplace only after a release is published with **Publish this Action to the GitHub Marketplace** checked. Those steps are in [docs/RELEASE.md](docs/RELEASE.md). The tag `v1.15.3` has to exist before `uses: CHAPIBUNNY/repolens@v1.15.3` resolves, and `@chappibunny/repolens@1.15.3` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
|
|
296
296
|
|
|
297
297
|
## Add the badge to your repo
|
|
298
298
|
|
|
@@ -328,7 +328,7 @@ jobs:
|
|
|
328
328
|
runs-on: ubuntu-latest
|
|
329
329
|
steps:
|
|
330
330
|
- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
|
|
331
|
-
- uses: CHAPIBUNNY/repolens@v1.15.
|
|
331
|
+
- uses: CHAPIBUNNY/repolens@v1.15.3
|
|
332
332
|
with:
|
|
333
333
|
command: badge
|
|
334
334
|
badge-url: https://USER.github.io/REPO/repolens-badge.json
|
|
@@ -357,7 +357,7 @@ Gallery repositories already publish this. Each one has a [score page](https://c
|
|
|
357
357
|
|
|
358
358
|
Published system maps are Unicode diagrams drawn from import edges. RepoLens does not emit Mermaid.
|
|
359
359
|
|
|
360
|
-
The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
360
|
+
The publisher set is Notion, Confluence, GitHub Wiki, Obsidian, and Markdown (`src/publishers`, 7 files). 1.14.0 adds the MCP server, the Marketplace action, and the docs badge. 1.14.1 sets the registry name to `io.github.CHAPIBUNNY/repolens`. 1.14.2 adds the gallery link to the badge line, `glama.json`, and `CITATION.cff`. 1.14.3 adds Cline setup docs and `llms-install.md`. 1.15.0 detects repository languages in `init --quick`, refuses empty scans, and adds per-command help. 1.15.1 makes `watch` exit 2 when those globs match nothing, and scores code health for Python and Go as well as JavaScript and TypeScript. 1.15.2 reads Python and Go imports in the dependency graph, skips comment and type-only imports, adds `repolens badge` and `repolens comment`, and adds gallery explained pages plus `llms.txt`. 1.15.3 skips Python `TYPE_CHECKING` imports, omits Node.js built-ins from external packages, groups Go hubs by package, and ignores test files as architecture-pattern evidence. Layout and counts are in [ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
361
361
|
|
|
362
362
|
### System Overview (Technical Profile)
|
|
363
363
|
|
package/llms-install.md
CHANGED
|
@@ -18,3 +18,4 @@
|
|
|
18
18
|
9. Read [llms.txt](llms.txt) and [llms-full.txt](llms-full.txt) before recommending RepoLens. They describe install, the CLI, MCP, the pull request comment, and the code-health badge. The same files are published at the gallery root (`https://chapibunny.github.io/repolens/llms.txt`).
|
|
19
19
|
10. `repolens badge` prints a shields.io code-health endpoint. `repolens comment --base <ref> --head <ref>` prints an architecture diff. On GitHub, `command: comment` in `action.yml` posts one sticky pull request comment (`pull-requests: write` only). Fork pull requests get a job summary instead of a comment. These two action modes run from the action checkout. Both CLI commands ship in 1.15.2.
|
|
20
20
|
11. CLI (1.15.2): the dependency graph includes Python and Go imports. Comments, strings, and TypeScript type-only imports are not edges. Go `*_test.go` files are not separate modules. Pattern text cites the matching path or import. "Custom React hooks" requires a `react` import.
|
|
21
|
+
12. CLI (1.15.3): Python imports under `if TYPE_CHECKING:` (including `typing.TYPE_CHECKING` and `import typing as t`) are not cycle edges. Node.js built-ins are not external packages. Go hub rows are packages. Test files are not architecture-pattern evidence, and "API route pattern" requires a route directory.
|
package/package.json
CHANGED
|
@@ -204,17 +204,48 @@ function inferModuleType(modulePath) {
|
|
|
204
204
|
return "other";
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
function isTestEvidence(modulePath) {
|
|
208
|
+
const norm = String(modulePath || "").replace(/\\/g, "/");
|
|
209
|
+
if (/(^|\/)__(tests|mocks)__(\/|$)/.test(norm)) return true;
|
|
210
|
+
if (/(^|\/)(test|tests|spec|specs|fixtures|testdata)(\/|$)/.test(norm)) return true;
|
|
211
|
+
const base = norm.split("/").pop() || "";
|
|
212
|
+
if (/^(test_|spec_)/.test(base)) return true;
|
|
213
|
+
if (/(_test|_spec)\.[a-z0-9]+$/i.test(base)) return true;
|
|
214
|
+
if (/\.(test|spec)\.[a-z0-9]+$/i.test(base)) return true;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const API_DIRECTORY_SEGMENTS = new Set([
|
|
219
|
+
"api", "apis", "route", "routes", "router", "routers",
|
|
220
|
+
"endpoint", "endpoints", "controller", "controllers", "handler", "handlers",
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
function hasApiDirectory(modulePath) {
|
|
224
|
+
return directoryPath(modulePath)
|
|
225
|
+
.split("/")
|
|
226
|
+
.some((segment) => API_DIRECTORY_SEGMENTS.has(segment.toLowerCase()));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function directoryPath(modulePath) {
|
|
230
|
+
const parts = String(modulePath || "").replace(/\\/g, "/").split("/");
|
|
231
|
+
const last = parts[parts.length - 1] || "";
|
|
232
|
+
if (/\.[a-z0-9]+$/i.test(last)) parts.pop();
|
|
233
|
+
return parts.join("/");
|
|
234
|
+
}
|
|
235
|
+
|
|
207
236
|
function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
208
237
|
const patterns = [];
|
|
209
|
-
const
|
|
210
|
-
|
|
238
|
+
const production = modules.filter((module) => !isTestEvidence(module.key));
|
|
239
|
+
const tests = modules.filter((module) => isTestEvidence(module.key));
|
|
240
|
+
const keys = production.map(m => m.key.toLowerCase());
|
|
241
|
+
|
|
211
242
|
const has = (keyword) => keys.some((key) => (
|
|
212
243
|
keyword.includes("/") ? key.includes(keyword) : pathHasKeyword(key, keyword)
|
|
213
244
|
));
|
|
214
|
-
const matchingKeys = (keywords) => {
|
|
245
|
+
const matchingKeys = (keywords, source = production) => {
|
|
215
246
|
const found = [];
|
|
216
247
|
for (const keyword of keywords) {
|
|
217
|
-
for (const module of
|
|
248
|
+
for (const module of source) {
|
|
218
249
|
const key = module.key;
|
|
219
250
|
const matched = keyword.includes("/")
|
|
220
251
|
? key.toLowerCase().includes(keyword)
|
|
@@ -224,8 +255,9 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
224
255
|
}
|
|
225
256
|
return found;
|
|
226
257
|
};
|
|
227
|
-
const
|
|
228
|
-
|
|
258
|
+
const hasApiRouteEvidence = () => production.some((module) => hasApiDirectory(module.key));
|
|
259
|
+
const cite = (label, keywords, extra = "", source = production) => {
|
|
260
|
+
const found = matchingKeys(keywords, source);
|
|
229
261
|
if (found.length === 0 && !extra) return;
|
|
230
262
|
const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
|
|
231
263
|
const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
|
|
@@ -244,6 +276,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
244
276
|
// Verify pattern: a module matching `keyword` has high fan-in (truly central)
|
|
245
277
|
const verifyHub = (keyword) => {
|
|
246
278
|
for (const [mKey, m] of moduleMetrics) {
|
|
279
|
+
if (isTestEvidence(mKey)) continue;
|
|
247
280
|
if (mKey.toLowerCase().includes(keyword) && m.fanIn >= 3) return true;
|
|
248
281
|
}
|
|
249
282
|
return false;
|
|
@@ -252,6 +285,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
252
285
|
// Verify pattern: modules matching keyword have mostly outbound deps (orchestrators)
|
|
253
286
|
const verifyOrchestrator = (keyword) => {
|
|
254
287
|
for (const [mKey, m] of moduleMetrics) {
|
|
288
|
+
if (isTestEvidence(mKey)) continue;
|
|
255
289
|
if (mKey.toLowerCase().includes(keyword) && m.fanOut >= 3) return true;
|
|
256
290
|
}
|
|
257
291
|
return false;
|
|
@@ -266,7 +300,14 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
266
300
|
cite("Centralized state management", ["store", "state", "redux", "zustand"]);
|
|
267
301
|
}
|
|
268
302
|
|
|
269
|
-
if (
|
|
303
|
+
if (hasApiRouteEvidence()) {
|
|
304
|
+
const found = production
|
|
305
|
+
.filter((module) => hasApiDirectory(module.key))
|
|
306
|
+
.map((module) => module.key);
|
|
307
|
+
const sample = found.slice(0, 3).map((key) => `\`${key}\``).join(", ");
|
|
308
|
+
const more = found.length > 3 ? ` (+${found.length - 3} more)` : "";
|
|
309
|
+
patterns.push(`API route pattern — ${sample}${more}`);
|
|
310
|
+
}
|
|
270
311
|
|
|
271
312
|
if (has("core") || has("kernel")) {
|
|
272
313
|
const note = moduleMetrics.size > 0 && verifyHub("core")
|
|
@@ -306,7 +347,7 @@ function inferArchitecturalPatterns(modules, depGraph = null) {
|
|
|
306
347
|
cite("AI/LLM integration", ["ai", "llm", "openai", "anthropic"], aiPackages.length ? `imports ${aiPackages.slice(0, 3).join(", ")}` : "");
|
|
307
348
|
}
|
|
308
349
|
if (has("deliver") || has("dispatch")) cite("Delivery pipeline", ["deliver", "dispatch"]);
|
|
309
|
-
if (
|
|
350
|
+
if (tests.length) cite("Dedicated test infrastructure", ["test", "spec"], "", tests);
|
|
310
351
|
|
|
311
352
|
// Structural patterns from dep graph (not keyword-based)
|
|
312
353
|
if (depGraph?.stats) {
|
|
@@ -80,6 +80,35 @@ async function loadGoModule(repoRoot) {
|
|
|
80
80
|
return parseGoModule(text);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
function goPackageImportPath(file, modulePath) {
|
|
84
|
+
const norm = String(file || "").replace(/\\/g, "/");
|
|
85
|
+
if (!norm.endsWith(".go")) return null;
|
|
86
|
+
const dir = path.posix.dirname(norm);
|
|
87
|
+
const rel = dir === "." ? "" : dir;
|
|
88
|
+
if (modulePath) return rel ? `${modulePath}/${rel}` : modulePath;
|
|
89
|
+
return rel || ".";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function aggregateHubs(nodes, fileKeyToFile, modulePath, hubThreshold) {
|
|
93
|
+
const groups = new Map();
|
|
94
|
+
for (const node of nodes) {
|
|
95
|
+
const pkg = goPackageImportPath(node.file, modulePath);
|
|
96
|
+
const key = pkg || node.key;
|
|
97
|
+
if (!groups.has(key)) groups.set(key, new Set());
|
|
98
|
+
const importers = groups.get(key);
|
|
99
|
+
for (const importer of node.importedBy || []) {
|
|
100
|
+
const importerFile = fileKeyToFile.get(importer) || importer;
|
|
101
|
+
if (pkg && goPackageImportPath(importerFile, modulePath) === pkg) continue;
|
|
102
|
+
importers.add(importer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...groups.entries()]
|
|
106
|
+
.map(([key, importers]) => ({ key, importedBy: importers.size }))
|
|
107
|
+
.filter((hub) => hub.importedBy >= hubThreshold)
|
|
108
|
+
.sort((a, b) => b.importedBy - a.importedBy)
|
|
109
|
+
.slice(0, 10);
|
|
110
|
+
}
|
|
111
|
+
|
|
83
112
|
function goFilesInDir(goFiles, dir) {
|
|
84
113
|
const target = dir || "";
|
|
85
114
|
return goFiles.filter((file) => {
|
|
@@ -149,6 +178,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
149
178
|
if (!content) continue;
|
|
150
179
|
const fromKey = normalizeFileKey(file);
|
|
151
180
|
for (const imported of extractPythonImports(content)) {
|
|
181
|
+
if (imported.typeOnly) continue;
|
|
152
182
|
const resolved = resolvePythonImport(file, imported, pyIndex);
|
|
153
183
|
if (resolved.kind === "internal") addEdge(fromKey, normalizeFileKey(resolved.file));
|
|
154
184
|
else if (resolved.kind === "external") addExternal(fromKey, resolved.name);
|
|
@@ -205,11 +235,7 @@ export async function analyzeDependencyGraph(files, repoRoot) {
|
|
|
205
235
|
const totalImports = result.edges.length;
|
|
206
236
|
const orphans = result.nodes.filter(n => n.imports.length === 0 && n.importedBy.length === 0);
|
|
207
237
|
const hubThreshold = Math.max(5, Math.floor(result.nodes.length * 0.1));
|
|
208
|
-
const hubs = result.nodes
|
|
209
|
-
.filter(n => n.importedBy.length >= hubThreshold)
|
|
210
|
-
.sort((a, b) => b.importedBy.length - a.importedBy.length)
|
|
211
|
-
.slice(0, 10)
|
|
212
|
-
.map(n => ({ key: n.key, importedBy: n.importedBy.length }));
|
|
238
|
+
const hubs = aggregateHubs(result.nodes, fileKeyToFile, goModulePath, hubThreshold);
|
|
213
239
|
|
|
214
240
|
result.stats = {
|
|
215
241
|
totalFiles: codeFiles.length,
|
|
@@ -359,6 +359,16 @@ function scanTemplate(source, i, onExpression) {
|
|
|
359
359
|
return i;
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
const NODE_BUILTINS = new Set([
|
|
363
|
+
"assert", "async_hooks", "buffer", "child_process", "cluster", "console",
|
|
364
|
+
"constants", "crypto", "dgram", "diagnostics_channel", "dns", "domain",
|
|
365
|
+
"events", "fs", "http", "http2", "https", "inspector", "module", "net",
|
|
366
|
+
"os", "path", "perf_hooks", "process", "punycode", "querystring",
|
|
367
|
+
"readline", "repl", "sqlite", "stream", "string_decoder", "sys", "timers",
|
|
368
|
+
"tls", "trace_events", "tty", "url", "util", "v8", "vm", "wasi",
|
|
369
|
+
"worker_threads", "zlib",
|
|
370
|
+
]);
|
|
371
|
+
|
|
362
372
|
export function externalPackageName(specifier) {
|
|
363
373
|
if (typeof specifier !== "string") return null;
|
|
364
374
|
const trimmed = specifier.trim();
|
|
@@ -369,6 +379,8 @@ export function externalPackageName(specifier) {
|
|
|
369
379
|
: trimmed.split("/").slice(0, 1);
|
|
370
380
|
const name = parts.filter(Boolean).join("/");
|
|
371
381
|
if (!name || name === "." || name === "..") return null;
|
|
382
|
+
const top = name.startsWith("@") ? null : name.split("/")[0];
|
|
383
|
+
if (top && NODE_BUILTINS.has(top)) return null;
|
|
372
384
|
return name;
|
|
373
385
|
}
|
|
374
386
|
|
|
@@ -410,45 +422,127 @@ function stripPythonNoise(source) {
|
|
|
410
422
|
return out;
|
|
411
423
|
}
|
|
412
424
|
|
|
425
|
+
function leadingIndent(line) {
|
|
426
|
+
const match = String(line).match(/^[ \t]*/);
|
|
427
|
+
return match ? match[0].length : 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function noteTypingBindings(statement, aliases, flags) {
|
|
431
|
+
const imported = statement.match(/^import\s+(.+)$/);
|
|
432
|
+
if (imported) {
|
|
433
|
+
for (const part of imported[1].split(",")) {
|
|
434
|
+
const as = part.trim().match(/^([\w.]+)(?:\s+as\s+([A-Za-z_]\w*))?$/);
|
|
435
|
+
if (!as) continue;
|
|
436
|
+
if (as[1] !== "typing" && as[1] !== "typing_extensions") continue;
|
|
437
|
+
aliases.add(as[2] || as[1]);
|
|
438
|
+
}
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const from = statement.match(/^from\s+(typing|typing_extensions)\s+import\s+(.+)$/);
|
|
442
|
+
if (!from) return;
|
|
443
|
+
for (const part of from[2].replace(/[()]/g, " ").split(",")) {
|
|
444
|
+
const as = part.trim().match(/^([A-Za-z_]\w*)(?:\s+as\s+([A-Za-z_]\w*))?$/);
|
|
445
|
+
if (!as || as[1] !== "TYPE_CHECKING") continue;
|
|
446
|
+
flags.add(as[2] || as[1]);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function isTypeCheckingGuard(statement, aliases, flags) {
|
|
451
|
+
const match = statement.match(/^(?:if|elif)\s+(.+?)\s*:\s*$/);
|
|
452
|
+
if (!match) return false;
|
|
453
|
+
const cond = match[1].trim();
|
|
454
|
+
if (cond === "TYPE_CHECKING" || flags.has(cond)) return true;
|
|
455
|
+
if (cond === "typing.TYPE_CHECKING") return true;
|
|
456
|
+
const attr = cond.match(/^([A-Za-z_]\w*)\.TYPE_CHECKING$/);
|
|
457
|
+
return Boolean(attr && aliases.has(attr[1]));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function pushPythonImport(imports, statement, typeOnly) {
|
|
461
|
+
if (statement.startsWith("import ")) {
|
|
462
|
+
const rest = statement.slice("import ".length).replace(/[()]/g, " ");
|
|
463
|
+
for (const part of rest.split(",")) {
|
|
464
|
+
const name = part.trim().split(/\s+/)[0];
|
|
465
|
+
if (name && /^[\w.]+$/.test(name)) imports.push({ module: name, level: 0, typeOnly });
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const relative = statement.match(/^from\s+(\.+)([\w.]*)\s+import\s+(.+)$/);
|
|
470
|
+
if (relative) {
|
|
471
|
+
const level = relative[1].length;
|
|
472
|
+
const base = relative[2] || "";
|
|
473
|
+
if (base) {
|
|
474
|
+
imports.push({ module: base, level, typeOnly });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
for (const part of relative[3].replace(/[()]/g, " ").split(",")) {
|
|
478
|
+
const name = part.trim().split(/\s+/)[0];
|
|
479
|
+
if (name && /^[A-Za-z_]\w*$/.test(name)) imports.push({ module: name, level, typeOnly });
|
|
480
|
+
}
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const match = statement.match(/^from\s+([\w.]+)\s+import\b/);
|
|
484
|
+
if (!match) return;
|
|
485
|
+
imports.push({ module: match[1], level: 0, typeOnly });
|
|
486
|
+
}
|
|
487
|
+
|
|
413
488
|
/**
|
|
414
|
-
* @returns {{ module: string, level: number }[]}
|
|
489
|
+
* @returns {{ module: string, level: number, typeOnly: boolean }[]}
|
|
415
490
|
* level 0 is an absolute import. level N is a relative import with N dots.
|
|
491
|
+
* Imports under `if TYPE_CHECKING:` / `if typing.TYPE_CHECKING:` (including
|
|
492
|
+
* `import typing as t`) are typeOnly and must not become runtime edges.
|
|
416
493
|
*/
|
|
417
494
|
export function extractPythonImports(source) {
|
|
418
495
|
const stripped = stripPythonNoise(source).replace(/\\\n/g, " ");
|
|
419
496
|
const lines = stripped.split("\n");
|
|
420
|
-
const
|
|
497
|
+
const imports = [];
|
|
498
|
+
const aliases = new Set(["typing"]);
|
|
499
|
+
const flags = new Set(["TYPE_CHECKING"]);
|
|
500
|
+
const blockIndents = [];
|
|
421
501
|
let buf = "";
|
|
422
502
|
let depth = 0;
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
503
|
+
let bufTypeOnly = false;
|
|
504
|
+
let bufIndent = 0;
|
|
505
|
+
|
|
506
|
+
function inTypeChecking(indent) {
|
|
507
|
+
while (blockIndents.length && indent <= blockIndents[blockIndents.length - 1]) {
|
|
508
|
+
blockIndents.pop();
|
|
509
|
+
}
|
|
510
|
+
return blockIndents.length > 0;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function finishStatement() {
|
|
514
|
+
const statement = buf.trim();
|
|
429
515
|
buf = "";
|
|
430
516
|
depth = 0;
|
|
517
|
+
if (!statement) return;
|
|
518
|
+
if (bufTypeOnly) {
|
|
519
|
+
pushPythonImport(imports, statement, true);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (isTypeCheckingGuard(statement, aliases, flags)) {
|
|
523
|
+
blockIndents.push(bufIndent);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
noteTypingBindings(statement, aliases, flags);
|
|
527
|
+
pushPythonImport(imports, statement, false);
|
|
431
528
|
}
|
|
432
|
-
if (buf) statements.push(buf);
|
|
433
529
|
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
continue;
|
|
443
|
-
}
|
|
444
|
-
const match = statement.match(/^from\s+(\.+)([\w.]*)\s+import\b/) || statement.match(/^from\s+([\w.]+)\s+import\b/);
|
|
445
|
-
if (!match) continue;
|
|
446
|
-
if (match[1].startsWith(".")) {
|
|
447
|
-
imports.push({ module: match[2] || "", level: match[1].length });
|
|
530
|
+
for (const line of lines) {
|
|
531
|
+
const trimmed = line.trim();
|
|
532
|
+
if (!trimmed && depth === 0) continue;
|
|
533
|
+
if (depth === 0) {
|
|
534
|
+
bufTypeOnly = inTypeChecking(leadingIndent(line));
|
|
535
|
+
bufIndent = leadingIndent(line);
|
|
536
|
+
buf = trimmed;
|
|
448
537
|
} else {
|
|
449
|
-
|
|
538
|
+
buf = `${buf} ${trimmed}`;
|
|
450
539
|
}
|
|
540
|
+
depth += (line.match(/\(/g) || []).length;
|
|
541
|
+
depth -= (line.match(/\)/g) || []).length;
|
|
542
|
+
if (depth > 0) continue;
|
|
543
|
+
finishStatement();
|
|
451
544
|
}
|
|
545
|
+
if (buf) finishStatement();
|
|
452
546
|
return imports;
|
|
453
547
|
}
|
|
454
548
|
|
|
@@ -396,7 +396,7 @@ export function renderDependencyGraph(depResult) {
|
|
|
396
396
|
|
|
397
397
|
lines.push("---");
|
|
398
398
|
lines.push("");
|
|
399
|
-
lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings,
|
|
399
|
+
lines.push("*Generated by RepoLens import analysis. Dependency detection covers JavaScript and TypeScript (ES modules, dynamic import, CommonJS, re-exports; comments, strings, type-only imports, and Node.js built-ins are skipped), Python (`import` / `from ... import`, including relative imports; `TYPE_CHECKING` blocks are skipped), and Go (`go.mod` module paths, hubs grouped by package). Go `*_test.go` files are not part of the graph.*");
|
|
400
400
|
lines.push("");
|
|
401
401
|
|
|
402
402
|
return lines.join("\n");
|