Skip to content

Migrate TS plugin's build_dep_graph onto shared ts_build_dep_graph; align framework-file graph-node semantics #615

Description

@elfensky

The problem

The TypeScript plugin has its own dependency-graph builder, build_dep_graph, at desloppify/languages/typescript/detectors/deps/__init__.py:52-108. The shared infrastructure has another one, ts_build_dep_graph, at desloppify/languages/_framework/treesitter/imports/graph.py. They do almost the same job — parse imports, build edges, scan framework files — with slightly different implementations.

When PR #614 landed a cross-CWD bug fix, the fix had to be applied twice — once in the shared builder, once in the TypeScript-only copy. Same bug, two patches.

The next dep-graph bug will hit the same wall, and the wall will keep growing as more frameworks land via the FrameworkSpec registry from #418.

Why this isn't just "fix the outlier"

I asked four AI advisors to investigate before recommending an approach (debate synthesis at the end of this issue). Codex's codebase archaeology surfaced a useful nuance:

TS isn't the only deep plugin with its own build_dep_graph. Rust, Python, C++, Dart, and Go all have one too:

Plugin Builder location
Go go/detectors/deps.py:13-19 (stub returning {})
Rust rust/detectors/deps.py:21-37 (workspace/package index)
Python python/detectors/deps.py:46 (AST-based)
C++ cxx/detectors/deps.py:199-207 (compile-commands graph)
Dart dart/detectors/deps.py:73-87 (package graph)
Java/Ruby/PHP/Swift/Kotlin go through generic_lang(...)make_ts_dep_builder

But Rust/Python/C++/Dart are solving different problems (workspace indexes, compile commands, AST analysis, package graphs). They wouldn't share TS's pattern anyway. TS is uniquely tree-sitter-aligned among the deep plugins — its build_dep_graph is doing the same JS-style tree-sitter import work that the shared ts_build_dep_graph already does for JS and every other generic_lang plugin.

So this isn't "establish a universal pattern across all deep plugins." It's "TS is the one deep plugin whose dep-graph implementation can fold cleanly into the shared one."

The solution

The shared ts_build_dep_graph already delegates all import resolution to spec.resolve_import(...) at graph.py:107. TypeScript's three "TS-specific" capabilities all fit cleanly inside a TS-flavored resolve_import closure:

  1. Deno external prefix filtering (http://, https://, npm:, jsr:) — the closure returns None for these, the shared builder treats unresolved as "skip edge."
  2. tsconfig path-alias resolution (@/src/, etc.) — the closure runs the existing _resolve_module + _load_tsconfig_paths logic before falling back to the default JS-style resolver.
  3. .js specifier resolving to .ts file — already handled by iter_resolve_candidates at resolve.py:136-143; just keep it inside the closure.

The closure approach means:

  • Zero new fields on TreeSitterLangSpec. The existing resolve_import: Callable field absorbs everything. No God-object risk.
  • No new strategy classes. This is the existing pattern in the codebase — see GO_SPEC at specs/compiled.py:28-32 and TYPESCRIPT_SPEC at specs/scripting.py:244-249, both of which already bind language-specific resolvers via the same resolve_import field.
  • The 90+ lines of hand-rolled TS code collapses to ~10 lines. typescript/detectors/deps/__init__.py:build_dep_graph becomes:
def build_dep_graph(path: Path, roslyn_cmd: str | None = None) -> dict[str, dict[str, Any]]:
    del roslyn_cmd
    return ts_build_dep_graph(
        path,
        TYPESCRIPT_SPEC,  # whose resolve_import is the TS closure
        find_ts_and_tsx_files(path),
        framework_extensions=framework_source_extensions(ecosystem="node"),
    )

The policy question (needs maintainer input before code lands)

This is the only judgment call in the work, and it deserves an explicit decision before any branch exists.

Current TS behavior: .svelte/.vue/.astro files are graph nodes. graph[svelte_key] exists with imports/importers populated.

Shared builder behavior (used by JS today): .svelte/.vue/.astro files contribute importer edges only. They are not graph nodes. graph[svelte_key] is absent. This is documented behavior — see _framework/treesitter/imports/graph.py:61-67 and graph.py:174-181.

Unifying TS onto the shared builder means picking one. The case for the shared-builder behavior:

  • Framework files are not host-language source. Including them as nodes muddles "orphan" and "coupling" reports — a .svelte file isn't really a TypeScript orphan.
  • JS already chose this semantics. The orphan detector at desloppify/engine/detectors/graph.py is semantics-agnostic (it filters by extension, not graph membership), so there's no production regression risk from the change.
  • The maintainer's closing comment on Framework detection architecture: spec-driven horizontal layer (like tree-sitter) #418 documented FrameworkSpec semantics explicitly before implementation; this is the equivalent decision for the dep-graph layer.

If we agree on the shared-builder semantics, the test contract changes by exactly two lines:

  • test_ts_deps.py:448assert utils_key in graph[svelte_key]["imports"] → delete (svelte_key no longer in graph)
  • test_ts_deps.py:566-567assert graph[svelte_key]["import_count"] == 1 → delete

The other framework-file tests at test_ts_deps.py:434-535 keep passing because they check importer-edge membership (assert svelte_key in graph[utils_key]["importers"]), which is preserved.

What's NOT in scope

Three follow-up items deliberately left for separate work:

  1. Migrating Rust/Python/C++/Dart build_dep_graph implementations. They solve different problems and don't benefit from this work.
  2. Splitting the overloaded frameworks: bool flag into framework_phases + framework_imports. Pure cleanup; do it when something needs one without the other.
  3. Opening framework_source_extensions to non-Node ecosystems. Speculative until a Python framework with source extensions appears.

Investigation list (before opening the PR)

These are concrete things to verify on a real codebase before committing to the migration:

  1. Closure capture for tsconfig. Build the TS resolve_import inside make_ts_dep_builder(path) per-scan, not at module-import time on TYPESCRIPT_SPEC. Otherwise monorepo scans silently fall back to a default tsconfig. Pin with a test fixture: a monorepo with packages/foo/tsconfig.json carrying custom baseUrl: "src" + paths: { "@app/*": ["app/*"] }. Confirm aliases resolve against the right tsconfig root.
  2. external_imports tracking. TS's hand-rolled builder sets graph[node]["external_imports"] for Deno URLs (http://, npm:, etc.). Grep downstream — does any detector read this? If yes, add a small optional flag on ts_build_dep_graph and turn it on for TS only. If no, dropping it is fine.
  3. build_dynamic_import_targets external use. It's used at phases_coupling.py:130 and commands.py:127 for orphan detection (dynamic_import_finder= kwarg). Confirm it can stay as a free function exposed from typescript/detectors/deps/runtime.py without being folded into the shared builder.
  4. Monorepo regression. Find a real TS monorepo with non-default tsconfig (e.g. nextauthjs/next-auth-example or t3-oss/create-t3-turbo). Manually run desloppify scan before+after with the new wrapper. Confirm import edge count and orphan findings match.
  5. .js-specifier-resolving-to-.ts-file edge case. iter_resolve_candidates at resolve.py:136-143 handles this. Confirm it lives inside the TS resolve_import closure and isn't silently lost.
  6. TS tree-sitter import_query coverage. Verify the existing query at scripting.py:244-249 captures: type-only imports, side-effect imports, export … from "…" re-exports, dynamic import(). If any are missed, the migration could silently drop edges.

Estimated blast radius

  • Files modified: ~5 (delete ~150 lines, add ~30)
  • Tests that break: exactly 2 assertion lines (test_ts_deps.py:448 + test_ts_deps.py:566-567)
  • Behavior change: desloppify deps src/App.svelte no longer returns a node entry (framework files aren't tracked as nodes); orphan detection on TS+Svelte/Vue/Astro projects unchanged
  • Effort estimate: ~11h dev + 2h manual testing on a real monorepo + maintainer review round-trip ≈ 3-4 days end-to-end

Debate synthesis (rationale for the chosen approach)

I asked four AI advisors with different specialties to weigh in:

Advisor Recommended Filed issue or branch?
Gemini (architectural patterns from peer tools) B — Strategy pattern object Issue first
Codex (codebase archaeology) A with grouped ImportGraphCapabilities field Branch-and-code
Sonnet (pragmatic implementer, read the actual code) A — closure-based, no new spec fields Issue first
Opus (strategic framing, synthesis) initially C-but-bigger → revised to A after Sonnet's evidence Issue first

The decisive findings:

  • Sonnet's code-grounded read that the existing resolve_import: Callable already absorbs all TS quirks — meaning the spec needs zero new fields. This neutralizes Gemini's "God object" concern, which is correct in the abstract but doesn't apply here.
  • Codex's archaeology that the deep plugins with their own builders are solving different problems and wouldn't share TS's pattern — meaning this isn't "establish a universal pattern," it's "TS folds into the tree-sitter import domain that already has a shared builder."
  • The 3:1 issue-first majority is driven by the graph-node semantics decision, which is a behavior change worth confirming with the maintainer before code lands.

Related


🤖 Issue drafted with input from a 4-way AI debate (Gemini / Codex / Sonnet / Opus). Full debate transcript available on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions