From b6ffdbb8dd8eeec73f02d939f43e3320db06b461 Mon Sep 17 00:00:00 2001 From: Safi Date: Mon, 4 May 2026 11:17:06 +0100 Subject: [PATCH] v0.7.2: Fortran support + export CLI subcommands + skill.md size reduction - Add Fortran support (26th language): .f/.F/.f90/.F90/.f95/.F95/.f03/.F03/.f08/.F08 via tree-sitter-fortran; capital-F files preprocessed with cpp -w -P - Add graphify export {html,obsidian,wiki,svg,graphml,neo4j} CLI subcommands - Add graphify query/path/explain CLI subcommands - Reduce skill.md from 63KB to 47KB by replacing Python heredocs with CLI calls - Extend to_html() with node_limit param for auto-aggregation on large graphs - Add integration tests for all export/query/path/explain subcommands Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 22 ++ README.md | 4 +- graphify/__main__.py | 170 ++++++++++++++- graphify/detect.py | 2 +- graphify/export.py | 61 +++++- graphify/extract.py | 319 +++++++++++++++++++++++++-- graphify/skill.md | 441 ++------------------------------------ pyproject.toml | 3 +- tests/fixtures/sample.F90 | 17 ++ tests/fixtures/sample.f90 | 39 ++++ tests/test_cli_export.py | 199 +++++++++++++++++ tests/test_languages.py | 73 ++++++- 12 files changed, 903 insertions(+), 447 deletions(-) create mode 100644 tests/fixtures/sample.F90 create mode 100644 tests/fixtures/sample.f90 create mode 100644 tests/test_cli_export.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 64318eeb9..a1efb66e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.7.2 (2026-05-04) + +- Feat: Fortran support - extracts modules, subroutines, functions, programs, `use` imports, and `call` edges from `.f`, `.F`, `.f90`, `.F90`, `.f95`, `.F95`, `.f03`, `.F03`, `.f08`, `.F08` files; names are lowercased for case-insensitive matching (#694) + +## 0.7.1 (2026-05-04) + +- Fix: Obsidian export - community labels with `.`, `&`, `(`, `)` now produce valid Obsidian tags; only `[a-zA-Z0-9_\-/]` characters survive, preventing broken Dataview queries (#690) +- Fix: `_load_tsconfig_aliases()` now follows tsconfig `extends` chains - SvelteKit, Nuxt, and NestJS path aliases defined in extended configs are no longer silently dropped (#691) +- Fix: `.svelte` files now get a regex pass over the template layer after JS AST extraction - `{#await import('./X.svelte')}` markup-level dynamic imports are captured as edges (#692) +- Fix: recursion limit raised to 10,000 at extract entry points (main process + each worker) with a `_safe_extract` wrapper that skips pathological files with a clear warning instead of crashing the whole run (#695) + +## 0.7.0 (2026-05-03) + +Multi-dev busy-repo support: four gaps that caused merge conflicts, stale graphs, and silent cache misses in team workflows. + +- Feat: `graphify hook install` now also configures a git merge driver for `graphify-out/graph.json` — union-merges two graph.json files so git never produces conflict markers in the knowledge graph; writes `.gitattributes` and registers `graphify merge-driver` in `.git/config` +- Feat: `graphify merge-driver ` subcommand — takes two graph.json variants and writes their node/edge union back to ``; always exits 0 so merge never blocks +- Feat: Leiden community detection now seeded (`seed=42` when supported) for deterministic community IDs across parallel rebuilds — reduces JSON diff churn in multi-dev repos +- Feat: `graph.json` now embeds `built_at_commit` (git HEAD) at write time; `GRAPH_REPORT.md` surfaces the commit hash and a freshness check hint +- Fix: `file_hash` is now content-only (path removed from hash) — renamed files reuse their cache entry instead of re-extracting; cached `source_file` fields are updated to the new path on load +- Fix: watch mode mixed-batch handling — commits with both code and non-code files now rebuild code immediately AND write `needs_update` flag; previously code changes were silently dropped in mixed batches + ## 0.6.9 (2026-05-03) - Fix: `source_file` path separators normalized to forward slashes at graph ingestion — same physical file emitted with backslashes (Windows AST extractor) and forward slashes (semantic subagents) now merges into one node instead of splitting into two disconnected components (#683) diff --git a/README.md b/README.md index 2849c2f82..138b6b916 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Uninstall with the matching command (e.g. `graphify claude uninstall`). | Type | Extensions | |------|-----------| -| Code (25 languages) | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .jl .vue .svelte .sql` | +| Code (26 languages) | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .jl .vue .svelte .sql .f .F .f90 .F90 .f95 .F95 .f03 .F03 .f08 .F08` | | Docs | `.md .mdx .html .txt .rst .yaml .yml` | | Office | `.docx .xlsx` (requires `pip install graphifyy[office]`) | | PDFs | `.pdf` | @@ -192,7 +192,7 @@ graphify-out/cost.json # local only **Workflow:** 1. One person runs `/graphify .` and commits `graphify-out/`. 2. Everyone pulls — their assistant reads the graph immediately. -3. Run `graphify hook install` to auto-rebuild after each commit (AST only, no API cost). +3. Run `graphify hook install` to auto-rebuild after each commit (AST only, no API cost). This also sets up a git merge driver so `graph.json` is never left with conflict markers — two devs committing in parallel get their graphs union-merged automatically. 4. When docs or papers change, run `/graphify --update` to refresh those nodes. --- diff --git a/graphify/__main__.py b/graphify/__main__.py index 2a0308709..ddf33755e 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1060,6 +1060,7 @@ def main() -> None: print(" explain \"X\" plain-language explanation of a node and its neighbors") print(" --graph path to graph.json (default graphify-out/graph.json)") print(" clone clone a GitHub repo locally and print its path for /graphify") + print(" merge-driver git merge driver: union-merge two graph.json files (set up via hook install)") print(" merge-graphs merge two or more graph.json files into one cross-repo graph") print(" --out output path (default: graphify-out/merged-graph.json)") print(" --branch checkout a specific branch (default: repo default)") @@ -1516,10 +1517,12 @@ def main() -> None: labels = {cid: f"Community {cid}" for cid in communities} questions = suggest_questions(G, communities, labels) tokens = {"input": 0, "output": 0} + from graphify.export import _git_head as _gh + _commit = _gh() report = generate(G, communities, cohesion, labels, gods, surprises, {"warning": "cluster-only mode — file stats not available"}, tokens, str(watch_path), suggested_questions=questions, - min_community_size=min_community_size) + min_community_size=min_community_size, built_at_commit=_commit) out = watch_path / "graphify-out" (out / "GRAPH_REPORT.md").write_text(report, encoding="utf-8") to_json(G, communities, str(out / "graph.json")) @@ -1640,6 +1643,37 @@ def main() -> None: print(f"open with: xdg-open {out} (or file://{out.resolve()})") sys.exit(0) + elif cmd == "merge-driver": + # git merge driver for graph.json — takes (base, current, other) and writes + # the union of current+other nodes/edges back to current. Always exits 0 + # so git never marks graph.json as conflicted. + # Usage: graphify merge-driver %O %A %B (set in .git/config merge driver) + if len(sys.argv) < 5: + print("Usage: graphify merge-driver ", file=sys.stderr) + sys.exit(1) + _base_path, _current_path, _other_path = sys.argv[2], sys.argv[3], sys.argv[4] + import networkx as _nx + from networkx.readwrite import json_graph as _jg + def _load_graph(p: str): + data = json.loads(Path(p).read_text(encoding="utf-8")) + try: + return _jg.node_link_graph(data, edges="links"), data + except TypeError: + return _jg.node_link_graph(data), data + try: + G_cur, _ = _load_graph(_current_path) + G_oth, _ = _load_graph(_other_path) + except Exception as exc: + print(f"[graphify merge-driver] error loading graphs: {exc}", file=sys.stderr) + sys.exit(0) # exit 0 so git doesn't block the merge + merged = _nx.compose(G_cur, G_oth) + try: + out_data = _jg.node_link_data(merged, edges="links") + except TypeError: + out_data = _jg.node_link_data(merged) + Path(_current_path).write_text(json.dumps(out_data, indent=2), encoding="utf-8") + sys.exit(0) + elif cmd == "merge-graphs": # graphify merge-graphs graph1.json graph2.json ... --out merged.json args = sys.argv[2:] @@ -1700,6 +1734,140 @@ def main() -> None: local_path = _clone_repo(url, branch=branch, out_dir=out_dir) print(local_path) + elif cmd == "export": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + if subcmd not in ("html", "obsidian", "wiki", "svg", "graphml", "neo4j"): + print("Usage: graphify export ", file=sys.stderr) + print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr) + print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr) + print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr) + print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr) + print(" graphml [--graph PATH]", file=sys.stderr) + print(" neo4j [--graph PATH] [--push URI] [--user U] [--password P]", file=sys.stderr) + sys.exit(1) + + # Parse shared args + args = sys.argv[3:] + graph_path = Path(_GRAPHIFY_OUT) / "graph.json" + labels_path = Path(_GRAPHIFY_OUT) / ".graphify_labels.json" + analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json" + node_limit = 5000 + no_viz = False + obsidian_dir = Path(_GRAPHIFY_OUT) / "obsidian" + neo4j_uri: str | None = None + neo4j_user = "neo4j" + neo4j_password: str | None = None + i = 0 + while i < len(args): + a = args[i] + if a == "--graph" and i + 1 < len(args): + graph_path = Path(args[i + 1]); i += 2 + elif a == "--labels" and i + 1 < len(args): + labels_path = Path(args[i + 1]); i += 2 + elif a == "--node-limit" and i + 1 < len(args): + node_limit = int(args[i + 1]); i += 2 + elif a == "--no-viz": + no_viz = True; i += 1 + elif a == "--dir" and i + 1 < len(args): + obsidian_dir = Path(args[i + 1]); i += 2 + elif a == "--push" and i + 1 < len(args): + neo4j_uri = args[i + 1]; i += 2 + elif a == "--user" and i + 1 < len(args): + neo4j_user = args[i + 1]; i += 2 + elif a == "--password" and i + 1 < len(args): + neo4j_password = args[i + 1]; i += 2 + else: + i += 1 + + if not graph_path.exists(): + print(f"error: graph not found: {graph_path}. Run /graphify first.", file=sys.stderr) + sys.exit(1) + + from networkx.readwrite import json_graph as _jg + from graphify.build import build_from_json as _bfj + + _raw = json.loads(graph_path.read_text(encoding="utf-8")) + try: + G = _jg.node_link_graph(_raw, edges="links") + except TypeError: + G = _jg.node_link_graph(_raw) + + # Load optional analysis/labels + communities: dict[int, list[str]] = {} + if analysis_path.exists(): + _an = json.loads(analysis_path.read_text(encoding="utf-8")) + communities = {int(k): v for k, v in _an.get("communities", {}).items()} + cohesion: dict[int, float] = {int(k): v for k, v in _an.get("cohesion", {}).items()} + gods_data = _an.get("gods", []) + else: + cohesion = {} + gods_data = [] + + labels: dict[int, str] = {} + if labels_path.exists(): + labels = {int(k): v for k, v in json.loads(labels_path.read_text(encoding="utf-8")).items()} + + out_dir = graph_path.parent + + if subcmd == "html": + from graphify.export import to_html as _to_html + if no_viz: + html_target = out_dir / "graph.html" + if html_target.exists(): + html_target.unlink() + print("--no-viz: skipped graph.html") + else: + _to_html(G, communities, str(out_dir / "graph.html"), + community_labels=labels or None, node_limit=node_limit) + if G.number_of_nodes() <= node_limit: + print(f"graph.html written - open in any browser, no server needed") + + elif subcmd == "obsidian": + from graphify.export import to_obsidian as _to_obsidian, to_canvas as _to_canvas + n = _to_obsidian(G, communities, str(obsidian_dir), + community_labels=labels or None, cohesion=cohesion or None) + print(f"Obsidian vault: {n} notes in {obsidian_dir}/") + _to_canvas(G, communities, str(obsidian_dir / "graph.canvas"), + community_labels=labels or None) + print(f"Canvas: {obsidian_dir}/graph.canvas") + print(f"Open {obsidian_dir}/ as a vault in Obsidian.") + + elif subcmd == "wiki": + from graphify.wiki import to_wiki as _to_wiki + from graphify.analyze import god_nodes as _god_nodes + if not gods_data: + gods_data = _god_nodes(G) + n = _to_wiki(G, communities, str(out_dir / "wiki"), + community_labels=labels or None, cohesion=cohesion or None, + god_nodes_data=gods_data) + print(f"Wiki: {n} articles written to {out_dir}/wiki/") + print(f" {out_dir}/wiki/index.md -> agent entry point") + + elif subcmd == "svg": + from graphify.export import to_svg as _to_svg + _to_svg(G, communities, str(out_dir / "graph.svg"), + community_labels=labels or None) + print(f"graph.svg written - embeds in Obsidian, Notion, GitHub READMEs") + + elif subcmd == "graphml": + from graphify.export import to_graphml as _to_graphml + _to_graphml(G, communities, str(out_dir / "graph.graphml")) + print(f"graph.graphml written - open in Gephi, yEd, or any GraphML tool") + + elif subcmd == "neo4j": + if neo4j_uri: + from graphify.export import push_to_neo4j as _push + if neo4j_password is None: + print("error: --password required for --push", file=sys.stderr) + sys.exit(1) + result = _push(G, uri=neo4j_uri, user=neo4j_user, + password=neo4j_password, communities=communities) + print(f"Pushed to Neo4j: {result['nodes']} nodes, {result['edges']} edges") + else: + from graphify.export import to_cypher as _to_cypher + _to_cypher(G, str(out_dir / "cypher.txt")) + print(f"cypher.txt written - import with: cypher-shell < {out_dir}/cypher.txt") + elif cmd == "benchmark": from graphify.benchmark import run_benchmark, print_benchmark graph_path = sys.argv[2] if len(sys.argv) > 2 else "graphify-out/graph.json" diff --git a/graphify/detect.py b/graphify/detect.py index e1df09de1..87086021e 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -18,7 +18,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08'} DOC_EXTENSIONS = {'.md', '.mdx', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/export.py b/graphify/export.py index 14587addb..7121a2c69 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -11,6 +11,15 @@ from networkx.readwrite import json_graph from graphify.security import sanitize_label from graphify.analyze import _node_community_map +def _obsidian_tag(name: str) -> str: + """Sanitize a community name for use as an Obsidian tag. + + Obsidian tags only allow alphanumerics, hyphens, underscores, and slashes. + Spaces become underscores; everything else is stripped. + """ + return re.sub(r"[^a-zA-Z0-9_\-/]", "", name.replace(" ", "_")) + + def _strip_diacritics(text: str) -> str: import unicodedata nfkd = unicodedata.normalize("NFKD", text) @@ -340,7 +349,17 @@ def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None: G.graph["hyperedges"] = existing -def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False) -> bool: +def _git_head() -> str | None: + """Return the current git HEAD commit hash, or None if not in a git repo.""" + import subprocess as _sp + try: + r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + +def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None) -> bool: # Safety check: refuse to silently shrink an existing graph (#479) existing_path = Path(output_path) if not force and existing_path.exists(): @@ -383,6 +402,9 @@ def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, link["source"] = true_src link["target"] = true_tgt data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", []) + commit = built_at_commit if built_at_commit is not None else _git_head() + if commit: + data["built_at_commit"] = commit with open(output_path, "w", encoding="utf-8") as f: # nosec json.dump(data, f, indent=2) return True @@ -436,6 +458,7 @@ def to_html( output_path: str, community_labels: dict[int, str] | None = None, member_counts: dict[int, int] | None = None, + node_limit: int | None = None, ) -> None: """Generate an interactive vis.js HTML visualization of the graph. @@ -445,9 +468,39 @@ def to_html( If member_counts is provided (aggregated community view), node sizes are based on community member counts rather than graph degree. + + If node_limit is set and the graph exceeds it, automatically builds an + aggregated community-level meta-graph instead of raising ValueError. """ - limit = _viz_node_limit() + limit = node_limit if node_limit is not None else _viz_node_limit() if G.number_of_nodes() > limit: + if node_limit is not None: + # Build aggregated community meta-graph + from collections import Counter as _Counter + import networkx as _nx + print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...") + node_to_community = {nid: cid for cid, members in communities.items() for nid in members} + meta = _nx.Graph() + for cid, members in communities.items(): + meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}")) + edge_counts = _Counter() + for u, v in G.edges(): + cu, cv = node_to_community.get(u), node_to_community.get(v) + if cu is not None and cv is not None and cu != cv: + edge_counts[(min(cu, cv), max(cu, cv))] += 1 + for (cu, cv), w in edge_counts.items(): + meta.add_edge(str(cu), str(cv), weight=w, + relation=f"{w} cross-community edges", confidence="AGGREGATED") + if meta.number_of_nodes() <= 1: + print("Single community - aggregated view not useful. Skipping graph.html.") + return + meta_communities = {cid: [str(cid)] for cid in communities} + mc = {cid: len(members) for cid, members in communities.items()} + to_html(meta, meta_communities, output_path, + community_labels=community_labels, member_counts=mc) + print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)") + print("Tip: run with --obsidian for full node-level detail.") + return raise ValueError( f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz " f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, " @@ -639,7 +692,7 @@ def to_obsidian( ftype_tag = _FTYPE_TAG.get(ftype, f"graphify/{ftype}" if ftype else "graphify/document") dom_conf = _dominant_confidence(node_id) conf_tag = f"graphify/{dom_conf}" - comm_tag = f"community/{community_name.replace(' ', '_')}" + comm_tag = f"community/{_obsidian_tag(community_name)}" node_tags = [ftype_tag, conf_tag, comm_tag] lines: list[str] = [] @@ -751,7 +804,7 @@ def to_obsidian( lines.append("") # Dataview live query (improvement 2) - comm_tag_name = community_name.replace(" ", "_") + comm_tag_name = _obsidian_tag(community_name) lines.append("## Live Query (requires Dataview plugin)") lines.append("") lines.append("```dataview") diff --git a/graphify/extract.py b/graphify/extract.py index 3c47ff1df..2e694ebf6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -10,6 +10,24 @@ from pathlib import Path from typing import Callable, Any from .cache import load_cached, save_cached +_RECURSION_LIMIT = 10_000 + + +def _raise_recursion_limit() -> None: + if sys.getrecursionlimit() < _RECURSION_LIMIT: + sys.setrecursionlimit(_RECURSION_LIMIT) + + +def _safe_extract(extractor: Callable, path: Path) -> dict: + try: + return extractor(path) + except RecursionError: + print(f" warning: skipped {path} (recursion limit exceeded)", file=sys.stderr, flush=True) + return {"nodes": [], "edges": [], "error": "recursion_limit_exceeded"} + except Exception as e: + print(f" warning: skipped {path} ({type(e).__name__}: {e})", file=sys.stderr, flush=True) + return {"nodes": [], "edges": [], "error": f"{type(e).__name__}: {e}"} + def _make_id(*parts: str) -> str: """Build a stable node ID from one or more name parts.""" @@ -30,9 +48,44 @@ def _file_stem(path: Path) -> str: _TSCONFIG_ALIAS_CACHE: dict[str, dict[str, str]] = {} +def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, str]: + """Recursively read path aliases from a tsconfig, following extends chains. + + Child config paths override parent. Circular extends are detected via seen set. + npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk. + """ + if str(tsconfig) in seen: + return {} + seen.add(str(tsconfig)) + try: + data = json.loads(tsconfig.read_text(encoding="utf-8")) + except Exception: + return {} + + aliases: dict[str, str] = {} + extends = data.get("extends") + if extends and not extends.startswith("@"): + extended_path = (base_dir / extends).resolve() + if not extended_path.suffix: + extended_path = extended_path.with_suffix(".json") + if extended_path.exists(): + aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen)) + + paths = data.get("compilerOptions", {}).get("paths", {}) + for alias, targets in paths.items(): + if not targets: + continue + alias_prefix = alias.rstrip("/*") + target_base = targets[0].rstrip("/*") + aliases[alias_prefix] = str(base_dir / target_base) + + return aliases + + def _load_tsconfig_aliases(start_dir: Path) -> dict[str, str]: """Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases. + Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included. Returns a dict mapping alias prefix (e.g. "@/") to resolved base dir (e.g. "src/"). Result is cached by tsconfig path string. """ @@ -42,20 +95,7 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, str]: if tsconfig.exists(): key = str(tsconfig) if key not in _TSCONFIG_ALIAS_CACHE: - try: - data = json.loads(tsconfig.read_text(encoding="utf-8")) - paths = data.get("compilerOptions", {}).get("paths", {}) - aliases: dict[str, str] = {} - for alias, targets in paths.items(): - if not targets: - continue - # Strip trailing /* from alias and target - alias_prefix = alias.rstrip("/*") - target_base = targets[0].rstrip("/*") - aliases[alias_prefix] = str(candidate / target_base) - _TSCONFIG_ALIAS_CACHE[key] = aliases - except Exception: - _TSCONFIG_ALIAS_CACHE[key] = {} + _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(tsconfig, candidate, seen=set()) return _TSCONFIG_ALIAS_CACHE[key] return {} @@ -1656,6 +1696,42 @@ def extract_js(path: Path) -> dict: return _extract_generic(path, config) +def extract_svelte(path: Path) -> dict: + """Extract imports from .svelte files: script-block via JS AST + template regex fallback. + + Tree-sitter only sees the