You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
Deno external prefix filtering (http://, https://, npm:, jsr:) — the closure returns None for these, the shared builder treats unresolved as "skip edge."
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.
.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:
defbuild_dep_graph(path: Path, roslyn_cmd: str|None=None) ->dict[str, dict[str, Any]]:
delroslyn_cmdreturnts_build_dep_graph(
path,
TYPESCRIPT_SPEC, # whose resolve_import is the TS closurefind_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 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:
Migrating Rust/Python/C++/Dart build_dep_graph implementations. They solve different problems and don't benefit from this work.
Splitting the overloaded frameworks: bool flag into framework_phases + framework_imports. Pure cleanup; do it when something needs one without the other.
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:
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.
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.
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.
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.
.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.
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.
The problem
The TypeScript plugin has its own dependency-graph builder,
build_dep_graph, atdesloppify/languages/typescript/detectors/deps/__init__.py:52-108. The shared infrastructure has another one,ts_build_dep_graph, atdesloppify/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:go/detectors/deps.py:13-19(stub returning{})rust/detectors/deps.py:21-37(workspace/package index)python/detectors/deps.py:46(AST-based)cxx/detectors/deps.py:199-207(compile-commands graph)dart/detectors/deps.py:73-87(package graph)generic_lang(...)→make_ts_dep_builderBut 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_graphis doing the same JS-style tree-sitter import work that the sharedts_build_dep_graphalready does for JS and every othergeneric_langplugin.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_graphalready delegates all import resolution tospec.resolve_import(...)atgraph.py:107. TypeScript's three "TS-specific" capabilities all fit cleanly inside a TS-flavoredresolve_importclosure:http://,https://,npm:,jsr:) — the closure returnsNonefor these, the shared builder treats unresolved as "skip edge."@/→src/, etc.) — the closure runs the existing_resolve_module+_load_tsconfig_pathslogic before falling back to the default JS-style resolver..jsspecifier resolving to.tsfile — already handled byiter_resolve_candidatesatresolve.py:136-143; just keep it inside the closure.The closure approach means:
TreeSitterLangSpec. The existingresolve_import: Callablefield absorbs everything. No God-object risk.GO_SPECatspecs/compiled.py:28-32andTYPESCRIPT_SPECatspecs/scripting.py:244-249, both of which already bind language-specific resolvers via the sameresolve_importfield.typescript/detectors/deps/__init__.py:build_dep_graphbecomes: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/.astrofiles are graph nodes.graph[svelte_key]exists withimports/importerspopulated.Shared builder behavior (used by JS today):
.svelte/.vue/.astrofiles 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-67andgraph.py:174-181.Unifying TS onto the shared builder means picking one. The case for the shared-builder behavior:
.sveltefile isn't really a TypeScript orphan.desloppify/engine/detectors/graph.pyis semantics-agnostic (it filters by extension, not graph membership), so there's no production regression risk from the change.If we agree on the shared-builder semantics, the test contract changes by exactly two lines:
test_ts_deps.py:448—assert utils_key in graph[svelte_key]["imports"]→ delete (svelte_key no longer in graph)test_ts_deps.py:566-567—assert graph[svelte_key]["import_count"] == 1→ deleteThe other framework-file tests at
test_ts_deps.py:434-535keep 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:
build_dep_graphimplementations. They solve different problems and don't benefit from this work.frameworks: boolflag intoframework_phases+framework_imports. Pure cleanup; do it when something needs one without the other.framework_source_extensionsto 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:
resolve_importinsidemake_ts_dep_builder(path)per-scan, not at module-import time onTYPESCRIPT_SPEC. Otherwise monorepo scans silently fall back to a default tsconfig. Pin with a test fixture: a monorepo withpackages/foo/tsconfig.jsoncarrying custombaseUrl: "src"+paths: { "@app/*": ["app/*"] }. Confirm aliases resolve against the right tsconfig root.external_importstracking. TS's hand-rolled builder setsgraph[node]["external_imports"]for Deno URLs (http://,npm:, etc.). Grep downstream — does any detector read this? If yes, add a small optional flag onts_build_dep_graphand turn it on for TS only. If no, dropping it is fine.build_dynamic_import_targetsexternal use. It's used atphases_coupling.py:130andcommands.py:127for orphan detection (dynamic_import_finder=kwarg). Confirm it can stay as a free function exposed fromtypescript/detectors/deps/runtime.pywithout being folded into the shared builder.nextauthjs/next-auth-exampleort3-oss/create-t3-turbo). Manually rundesloppify scanbefore+after with the new wrapper. Confirm import edge count and orphan findings match..js-specifier-resolving-to-.ts-file edge case.iter_resolve_candidatesatresolve.py:136-143handles this. Confirm it lives inside the TSresolve_importclosure and isn't silently lost.import_querycoverage. Verify the existing query atscripting.py:244-249captures: type-only imports, side-effect imports,export … from "…"re-exports, dynamicimport(). If any are missed, the migration could silently drop edges.Estimated blast radius
test_ts_deps.py:448+test_ts_deps.py:566-567)desloppify deps src/App.svelteno longer returns a node entry (framework files aren't tracked as nodes); orphan detection on TS+Svelte/Vue/Astro projects unchangedDebate synthesis (rationale for the chosen approach)
I asked four AI advisors with different specialties to weigh in:
ImportGraphCapabilitiesfieldThe decisive findings:
resolve_import: Callablealready 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.Related
source_extensionsregistry-driven onFrameworkSpec🤖 Issue drafted with input from a 4-way AI debate (Gemini / Codex / Sonnet / Opus). Full debate transcript available on request.