@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 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.2`). `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`.
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.2`):
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.2
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.2` or a commit SHA in production.
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.2
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.2` has to exist before `uses: CHAPIBUNNY/repolens@v1.15.2` resolves, and `@chappibunny/repolens@1.15.2` has to be on npm before `publish` can install it. `command: comment` and `command: badge` do not install that package.
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.2
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chappibunny/repolens",
3
- "version": "1.15.2",
3
+ "version": "1.15.3",
4
4
  "mcpName": "io.github.CHAPIBUNNY/repolens",
5
5
  "description": "AI-assisted documentation intelligence system for technical and non-technical audiences",
6
6
  "license": "MIT",
@@ -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 keys = modules.map(m => m.key.toLowerCase());
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 modules) {
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 cite = (label, keywords, extra = "") => {
228
- const found = matchingKeys(keywords);
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 (has("api") || has("endpoint")) cite("API route pattern", ["api", "endpoint"]);
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 (has("test") || has("spec")) cite("Dedicated test infrastructure", ["test", "spec"]);
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 statements = [];
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
- for (const line of lines) {
424
- buf = buf ? `${buf} ${line.trim()}` : line.trim();
425
- depth += (line.match(/\(/g) || []).length;
426
- depth -= (line.match(/\)/g) || []).length;
427
- if (depth > 0) continue;
428
- if (buf) statements.push(buf);
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 imports = [];
435
- for (const statement of statements) {
436
- if (statement.startsWith("import ")) {
437
- const rest = statement.slice("import ".length).replace(/[()]/g, " ");
438
- for (const part of rest.split(",")) {
439
- const name = part.trim().split(/\s+/)[0];
440
- if (name && /^[\w.]+$/.test(name)) imports.push({ module: name, level: 0 });
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
- imports.push({ module: match[1], level: 0 });
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, and type-only imports are skipped), Python (`import` / `from ... import`, including relative imports), and Go (`go.mod` module paths). Go `*_test.go` files are not part of the graph.*");
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");