Skip to content

fix(extract): honor tsconfig customConditions for workspace exports - #4137

Open
krishhgg wants to merge 1 commit into
Graphify-Labs:v8from
krishhgg:fix/ts-custom-conditions
Open

krishhgg wants to merge 1 commit into
Graphify-Labs:v8from
krishhgg:fix/ts-custom-conditions

Conversation

@krishhgg

@krishhgg krishhgg commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Workspace package imports ignore the importer's compilerOptions.customConditions. For example, zod puts its checked-in TypeScript source behind "@zod/source", alongside runtime exports that exist only after a build. On current v8, 229 distinct zod file/specifier sites still resolve to no file.

This adds a conservative fallback after the existing workspace resolution walk. It preserves every source file that walk reaches, including the dist-to-src fallback from #4485. Only an import left unresolved or on build output can change.

The fallback reads the importer's nearest tsconfig.json or jsconfig.json, merges relative or absolute extends parents in TypeScript's order, and caches completed merges per extraction run. It handles an exact exports entry whose first condition key is active in that importer and whose value names an in-package TypeScript implementation. Extension substitutions follow TypeScript 5.9.3. Declarations and JavaScript-only targets are refused.

It keeps v8's result when the config or target is uncertain:

  • Unknown, missing, malformed, cyclic, or package-name config inheritance; symlinked configs or importer directories.
  • JSONC stripping that would change any string, including an extends path. Conditions must also appear verbatim as string literals in the config that sets them. The shared JSONC parser is unchanged.
  • A resolution mode that does not read exports, or resolvePackageJsonExports: false.
  • Any baseUrl, outDir, or declarationDir; nonempty module suffixes; matching paths keys; or path-option templates.
  • Nested condition objects, arrays, null targets, numeric keys, wildcard requests, or requests ending in /.
  • Targets with traversal, backslashes, empty or node_modules segments, symlinks, or build-output locations.

Numeric-key rejection uses string checks, so an arbitrarily long key cannot raise an integer-conversion exception.

Verification against v0.9.92

Base: 86c57be8204673c2a1b9849a3292a7dd3ab373d8.
Candidate: c1e5dcb9e8d32ae966d5757f05315a5214ad0d4b.

The diff adds 1,192 lines across three files. It preserves both sets of tests at the rebase conflict and adds cases for source-twin priority, JSONC strings, numeric-key limits, and lexical importer paths.

  • The final 63 custom-condition cases produce 13 failures and 50 passes on unmodified v8, then 63 passes on this branch. Six related JS/TS test files pass all 240 tests.
  • New review regressions also failed before their fixes: four JSONC cases, one huge numeric key, and two symlinked importer paths.
  • Full suite: v8 8,208 passed, 149 skipped. Branch 8,271 passed, 149 skipped.
  • Ruff passes. Pyright reports the same 678 errors and 4 warnings on both versions, with identical normalized diagnostic multisets.
  • AST-only graphify update . --no-cluster completes.
  • Independent GPT-6-Astra review approves the code. Its 54-fixture matrix has 18 changed resolutions, all matching TypeScript 5.9.3. It also checks same-process config/cache changes and real CLI/watch incremental behavior.

Fresh CLI extractions used extract --code-only --no-cluster --no-dedup --max-workers 1 with separate output directories for baseline and candidate. The TypeScript 5.9.3 oracle uses each importer's own config and module resolution mode.

Repository Import recall before Import recall after Import precision before Import precision after
zod 665/891 = 74.6% 891/891 = 100% 665/677 = 98.23% 891/903 = 98.67%
hono 843/843 = 100% 843/843 = 100% 843/846 = 99.65% 843/846 = 99.65%
express 149/153 = 97.4% 149/153 = 97.4% 149/149 = 100% 149/149 = 100%

These scores cover immediate file import pairs within mapped oracle files. They do not measure symbol or call precision. Zod maps 891 of 896 oracle pairs; hono maps 843 of 860; express maps all 153. The existing 12 zod and 3 hono false-positive pairs remain unchanged.

All 229 changed zod file/specifier sites were unresolved on v8 and match TypeScript's selected file on this branch. Zod keeps all 5,922 existing nodes with all attributes unchanged, and all existing edges except 203 imports_from edges to the external ref_zod stub. In particular, all 1,903 existing imports to source files retain every attribute. Its graph changes from 10,004 to 10,090 edges and adds six external symbol stubs. Hono and express graph.json files are byte-identical.

Limits

This deliberately supports fewer shapes than TypeScript. Existing v8 source targets always win, even where its fixed condition priority or source-twin heuristic differs from TypeScript. Solution-style references, package.json imports conditions, JavaScript-only custom targets, and the refused shapes above remain outside this fallback.

Importer edits followed by changed-paths rebuilds match full CLI rebuilds in independent fixtures. Config-only edits can stay stale until a full graphify update, matching the existing alias-loader limitation. Windows was not tested; symlink tests skip where symlinks are unavailable.

The full suites ran with the development dependency group and an isolated SQL grammar. Other optional-dependency tests can skip. No generated skill sources changed. Both material AI contributors are disclosed in the commit metadata.

@krishhgg
krishhgg requested a review from safishamsi as a code owner October 6, 2026 01:16
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown

Thanks for the pull request, @krishhgg. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 1 advisory finding(s) below merit a look before merge.

Formal verification. PR-changed functions: 1/2 verified (0 proven, 1 may-equivalent, 0 distinguished) · 1 not verified (1 vacuous).

Not verified on this run: extract (vacuous: never exercised).


Graphify review — findings

Adds tsconfig customConditions support to JS/TS workspace import resolution, so a project that sets e.g. "@zod/source" resolves a workspace package through that condition's exports entry (./src/index.ts) instead of build-only import/require targets. _read_tsconfig_exports_options merges the relevant compilerOptions across relative extends chains the way tsc does, and _resolve_custom_condition_export handles only the shape where the custom condition is the first key and points at a TypeScript implementation file. Whenever tsc's outcome isn't modelled, the custom-condition lookup is skipped and the existing condition walk applies: unparseable or symlinked configs, package-name extends, a non-exports moduleResolution, matching paths, any baseUrl/outDir/declarationDir, or non-empty moduleSuffixes.

Worth a look

  • Escaped JSON customConditions are discarded — graphify/extractors/resolution.py:359 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2771 functions depend on the 576 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 778 callers, 50 callees
  • new: _rebuild_code() — 149 callers, 56 callees
  • new: _extract_generic() — 18 callers, 31 callees
  • new: _resolve_js_module_path() — 42 callers, 9 callees
  • new: extract_js() — 87 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: main() — 102 callers, 3 callees
  • new: dispatch_command() — 2 callers, 128 callees
  • …and 59 more — each is listed as a finding

Verification — 2771 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2581 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

151 of 334 test file(s) selected (45%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_blade_extractor.py — impact
  • tests/test_build.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_cache.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cobol_extractor.py — impact
  • tests/test_cpp_method_declarations.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_generic_callsites.py — impact
  • tests/test_csharp_interface_dispatch.py — impact
  • tests/test_csharp_member_calls.py — impact
  • tests/test_csharp_member_nodes.py — impact
  • tests/test_csharp_object_creation.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_csharp_tuple_type_refs.py — impact
  • tests/test_csharp_type_resolution.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_elixir_unqualified_call_scope.py — impact
  • tests/test_erlang_extractor.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_php_closures.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_file_node_id_spec.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_go_builtin_call_targets.py — impact
  • tests/test_go_import_repoint.py — impact
  • tests/test_go_interface_methods.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_import_self_loops.py — impact
  • tests/test_imported_export_forwarding.py — impact
  • tests/test_incremental.py — impact
  • tests/test_indirect_call_arrow_single_param_shadow.py — impact
  • tests/test_indirect_call_block_scoped_shadow.py — impact
  • … and 101 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Could not verify: Could not verify extract.

The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: not verifiable: all 90 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

No difference found (not proven): No behavior difference found in \_resolve\_workspace\_import (not a proof).

The verifier ran both versions of \_resolve\_workspace\_import on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 3 grounded finding(s) anchored inline below; 64 more finding(s) on lines outside this diff (see the check run).

return True
return False

def _load_tsconfig_custom_conditions(start_dir: Path, specifier: str) -> tuple[str, ...]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _load_tsconfig_custom_conditions()

high coupling complexity (Ca·Ce = 20).

Grounded coupling-delta finding (deterministic), not an LLM guess.

return None
return found

def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _resolve_workspace_import()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"case",
["empty-outdir", "empty-declarationdir", "pattern-precedence", "multi-star", "jsonc-condition"],
)
def test_tsconfig_custom_condition_keeps_v8_for_output_dirs_patterns_and_jsonc(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_tsconfig_custom_condition_keeps_v8_for_output_dirs_patterns_and_jsonc()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

@krishhgg

krishhgg commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

On the advisory about escaped customConditions: that is the intended fail-closed path. The shared JSONC reader can rewrite string contents (its trailing-comma pass turns "custom,}" into "custom}"), so the fallback only trusts a condition that appears verbatim as a JSON string literal in the config that sets it. A condition written with escapes, such as "@zod\/source", doesn't match that check, the fallback is skipped, and the import keeps exactly its v8 result. It's listed under the refusals in the description. If you'd rather accept escaped forms, the cleaner fix is a string-aware trailing-comma pass in _strip_jsonc, which I left out of this PR because it changes the shared reader for every config.

The coupling notes are on _load_tsconfig_custom_conditions, which merges the extends chain and applies the refusals, and on _resolve_workspace_import, which now calls the fallback after the unchanged v8 walk.

Workspace imports resolve exports through a fixed condition list that
ignores the importer's customConditions. Packages such as zod expose
TypeScript sources under a custom key beside runtime targets that exist
only after a build.

Keep the existing walk, including v8's dist-to-src fallback (Graphify-Labs#4485).
Only when it finds no source file, try the first custom-condition key
of an exact exports entry. Require an in-package TypeScript implementation
that tsc reaches through its extension lookups. Refuse uncertain config,
path and exports shapes, and keep v8's result in those cases.

Merge the importer's tsconfig extends chain with a per-extraction cache.
Cover source-twin precedence for built and unbuilt workspace packages,
per-importer options, cache refresh and conservative refusal boundaries.
Refuse JSONC configs whose string contents would change during stripping,
including parent paths, so unrelated literals cannot validate a corrupt value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
@krishhgg
krishhgg force-pushed the fix/ts-custom-conditions branch from 4cb7f73 to c1e5dcb Compare October 16, 2026 18:09

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant