feat: sync 12 new skills into catalog (total 31 skills), update manifest and README
This commit is contained in:
164
skills/drawio-skill/scripts/jsimports.py
Normal file
164
skills/drawio-skill/scripts/jsimports.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a JS/TS project's module-import graph as autolayout graph JSON.
|
||||
|
||||
The JavaScript/TypeScript counterpart to pyimports.py: walks a source
|
||||
directory, scans each module for static and dynamic import specifiers
|
||||
(`import`/`export ... from`, `require()`, `import()`), keeps only the
|
||||
intra-project edges (relative specifiers that resolve to a file under the
|
||||
root), and applies transitive reduction so the diagram stays readable.
|
||||
|
||||
python3 jsimports.py src -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Resolution is path-based: relative specifiers are resolved against the
|
||||
importing file's directory, trying the .ts/.tsx/.js/.jsx/.mjs/.cjs extensions
|
||||
and directory index files. Bare specifiers (node_modules packages such as
|
||||
"react") are ignored. Scanning is regex-based rather than a full parser, so a
|
||||
specifier inside a comment or string literal is counted in rare cases.
|
||||
|
||||
Usage: python3 jsimports.py <src_dir> [-o graph.json] [--direction TB|LR]
|
||||
[--group] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
||||
SPEC = re.compile(
|
||||
r"(?:import|export)\b[^'\";]*?\bfrom\s*['\"]([^'\"]+)['\"]" # import/export ... from "x"
|
||||
r"|import\s*['\"]([^'\"]+)['\"]" # import "x" (side effect)
|
||||
r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # require("x")
|
||||
r"|import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # import("x") dynamic
|
||||
)
|
||||
|
||||
|
||||
def modid(path, root):
|
||||
"""Module id = path relative to root, extension stripped, posix separators."""
|
||||
rel = os.path.relpath(path, root)
|
||||
for ext in EXTS:
|
||||
if rel.endswith(ext):
|
||||
rel = rel[: -len(ext)]
|
||||
break
|
||||
return rel.replace(os.sep, "/")
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Map module id -> absolute file path for every source file under root."""
|
||||
root = os.path.abspath(root)
|
||||
modules = {}
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d != "node_modules" and not d.startswith(".")]
|
||||
for fn in files:
|
||||
if fn.endswith(EXTS) and not fn.endswith(".d.ts"):
|
||||
full = os.path.join(dirpath, fn)
|
||||
modules[modid(full, root)] = full
|
||||
return modules, root
|
||||
|
||||
|
||||
def resolve(spec, importer, root, modules):
|
||||
"""Resolve a relative specifier to a known module id, or None (external)."""
|
||||
if not spec.startswith("."):
|
||||
return None
|
||||
base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
|
||||
candidates = ([base + e for e in EXTS]
|
||||
+ [os.path.join(base, "index" + e) for e in EXTS]
|
||||
+ [base])
|
||||
for cand in candidates:
|
||||
mid = modid(cand, root)
|
||||
if mid in modules and modules[mid] != importer:
|
||||
return mid
|
||||
return None
|
||||
|
||||
|
||||
def edges_of(mid, path, root, modules):
|
||||
"""Intra-project modules imported by module `mid`."""
|
||||
found = set()
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
src = f.read()
|
||||
except OSError:
|
||||
return found
|
||||
for m in SPEC.finditer(src):
|
||||
spec = m.group(1) or m.group(2) or m.group(3) or m.group(4)
|
||||
target = resolve(spec, path, root, modules)
|
||||
if target and target != mid:
|
||||
found.add(target)
|
||||
return found
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def common_dir(ids):
|
||||
"""Longest shared leading path segment across module ids (e.g. 'src/')."""
|
||||
common = []
|
||||
for parts in zip(*[m.split("/") for m in ids]):
|
||||
if len(set(parts)) == 1:
|
||||
common.append(parts[0])
|
||||
else:
|
||||
break
|
||||
return "/".join(common) + "/" if common else ""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="JS/TS import graph -> autolayout graph JSON.")
|
||||
ap.add_argument("src", help="source directory")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group nodes into containers by top-level directory")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modules, root = discover(args.src)
|
||||
if not modules:
|
||||
sys.exit(f"error: no JS/TS modules found under {args.src}")
|
||||
edges = sorted({(m, t) for m, p in modules.items()
|
||||
for t in edges_of(m, p, root, modules)})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(modules), edges)
|
||||
strip = common_dir(list(modules))
|
||||
label = lambda m: (m[len(strip):] if strip and m.startswith(strip) else m) or m
|
||||
|
||||
def node(m):
|
||||
d = {"id": m, "label": label(m)}
|
||||
if args.group:
|
||||
rest = label(m).split("/")
|
||||
if len(rest) > 1: # has a sub-directory
|
||||
d["group"] = "/".join(rest[:-1]) # full directory path -> nested boxes
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(m) for m in modules],
|
||||
"edges": [{"source": s, "target": t} for s, t in edges],
|
||||
}
|
||||
text = json.dumps(graph, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
sys.stderr.write(f"wrote {args.output}\n")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user