graphitect 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphify/__init__.py +30 -0
- graphify/__main__.py +757 -0
- graphify/_minhash.py +107 -0
- graphify/affected.py +318 -0
- graphify/always_on/agents-md.md +12 -0
- graphify/always_on/antigravity-rules.md +14 -0
- graphify/always_on/claude-md.md +9 -0
- graphify/always_on/gemini-md.md +9 -0
- graphify/always_on/kiro-steering.md +5 -0
- graphify/always_on/vscode-instructions.md +17 -0
- graphify/analyze.py +769 -0
- graphify/benchmark.py +152 -0
- graphify/build.py +2300 -0
- graphify/cache.py +1746 -0
- graphify/callflow_html.py +2051 -0
- graphify/cargo_introspect.py +109 -0
- graphify/cli.py +4745 -0
- graphify/cluster.py +409 -0
- graphify/command-kilo.md +15 -0
- graphify/cross_repo_calls.py +216 -0
- graphify/cross_repo_types.py +75 -0
- graphify/csharp_dispatch.py +154 -0
- graphify/dedup.py +1213 -0
- graphify/detect.py +2566 -0
- graphify/diagnostics.py +406 -0
- graphify/export.py +1349 -0
- graphify/exporters/__init__.py +1 -0
- graphify/exporters/base.py +14 -0
- graphify/exporters/graphdb.py +173 -0
- graphify/exporters/html.py +637 -0
- graphify/extract.py +7856 -0
- graphify/extractors/MIGRATION.md +107 -0
- graphify/extractors/__init__.py +66 -0
- graphify/extractors/apex.py +215 -0
- graphify/extractors/base.py +85 -0
- graphify/extractors/bash.py +579 -0
- graphify/extractors/blade.py +53 -0
- graphify/extractors/commonlisp.py +540 -0
- graphify/extractors/csharp.py +448 -0
- graphify/extractors/dart.py +564 -0
- graphify/extractors/dm.py +494 -0
- graphify/extractors/elixir.py +241 -0
- graphify/extractors/engine.py +6509 -0
- graphify/extractors/fortran.py +311 -0
- graphify/extractors/go.py +527 -0
- graphify/extractors/json_config.py +240 -0
- graphify/extractors/julia.py +289 -0
- graphify/extractors/markdown.py +408 -0
- graphify/extractors/models.py +131 -0
- graphify/extractors/objc.py +566 -0
- graphify/extractors/ocaml.py +289 -0
- graphify/extractors/pascal.py +688 -0
- graphify/extractors/pascal_forms.py +196 -0
- graphify/extractors/powershell.py +522 -0
- graphify/extractors/razor.py +192 -0
- graphify/extractors/resolution.py +3584 -0
- graphify/extractors/robot.py +296 -0
- graphify/extractors/rust.py +470 -0
- graphify/extractors/sln.py +92 -0
- graphify/extractors/sql.py +720 -0
- graphify/extractors/terraform.py +181 -0
- graphify/extractors/verilog.py +329 -0
- graphify/extractors/zig.py +181 -0
- graphify/file_slice.py +246 -0
- graphify/global_graph.py +194 -0
- graphify/google_workspace.py +237 -0
- graphify/hooks.py +933 -0
- graphify/ids.py +93 -0
- graphify/ingest.py +358 -0
- graphify/install.py +2366 -0
- graphify/llm.py +3544 -0
- graphify/manifest.py +4 -0
- graphify/manifest_ingest.py +311 -0
- graphify/mcp_ingest.py +386 -0
- graphify/multigraph_compat.py +212 -0
- graphify/pascal_resolution.py +129 -0
- graphify/paths.py +436 -0
- graphify/pg_introspect.py +165 -0
- graphify/prs.py +770 -0
- graphify/querylog.py +80 -0
- graphify/reflect.py +882 -0
- graphify/report.py +346 -0
- graphify/resolver_registry.py +85 -0
- graphify/ruby_resolution.py +242 -0
- graphify/scip_ingest.py +363 -0
- graphify/security.py +460 -0
- graphify/semantic_cleanup.py +336 -0
- graphify/serve.py +2608 -0
- graphify/skill-agents.md +710 -0
- graphify/skill-aider.md +1283 -0
- graphify/skill-amp.md +710 -0
- graphify/skill-claw.md +713 -0
- graphify/skill-codex.md +710 -0
- graphify/skill-copilot.md +713 -0
- graphify/skill-devin.md +1410 -0
- graphify/skill-droid.md +710 -0
- graphify/skill-kilo.md +722 -0
- graphify/skill-kiro.md +713 -0
- graphify/skill-opencode.md +705 -0
- graphify/skill-pi.md +713 -0
- graphify/skill-trae.md +711 -0
- graphify/skill-vscode.md +709 -0
- graphify/skill-windows.md +755 -0
- graphify/skill.md +713 -0
- graphify/skills/agents/references/add-watch.md +56 -0
- graphify/skills/agents/references/exports.md +87 -0
- graphify/skills/agents/references/extraction-spec.md +70 -0
- graphify/skills/agents/references/github-and-merge.md +46 -0
- graphify/skills/agents/references/hooks.md +33 -0
- graphify/skills/agents/references/query.md +311 -0
- graphify/skills/agents/references/transcribe.md +52 -0
- graphify/skills/agents/references/update.md +210 -0
- graphify/skills/amp/references/add-watch.md +56 -0
- graphify/skills/amp/references/exports.md +87 -0
- graphify/skills/amp/references/extraction-spec.md +70 -0
- graphify/skills/amp/references/github-and-merge.md +46 -0
- graphify/skills/amp/references/hooks.md +33 -0
- graphify/skills/amp/references/query.md +311 -0
- graphify/skills/amp/references/transcribe.md +52 -0
- graphify/skills/amp/references/update.md +210 -0
- graphify/skills/claude/references/add-watch.md +56 -0
- graphify/skills/claude/references/exports.md +87 -0
- graphify/skills/claude/references/extraction-spec.md +70 -0
- graphify/skills/claude/references/github-and-merge.md +46 -0
- graphify/skills/claude/references/hooks.md +33 -0
- graphify/skills/claude/references/query.md +311 -0
- graphify/skills/claude/references/transcribe.md +52 -0
- graphify/skills/claude/references/update.md +210 -0
- graphify/skills/claw/references/add-watch.md +56 -0
- graphify/skills/claw/references/exports.md +87 -0
- graphify/skills/claw/references/extraction-spec.md +31 -0
- graphify/skills/claw/references/github-and-merge.md +46 -0
- graphify/skills/claw/references/hooks.md +33 -0
- graphify/skills/claw/references/query.md +311 -0
- graphify/skills/claw/references/transcribe.md +52 -0
- graphify/skills/claw/references/update.md +210 -0
- graphify/skills/codex/references/add-watch.md +56 -0
- graphify/skills/codex/references/exports.md +87 -0
- graphify/skills/codex/references/extraction-spec.md +31 -0
- graphify/skills/codex/references/github-and-merge.md +46 -0
- graphify/skills/codex/references/hooks.md +33 -0
- graphify/skills/codex/references/query.md +311 -0
- graphify/skills/codex/references/transcribe.md +52 -0
- graphify/skills/codex/references/update.md +210 -0
- graphify/skills/copilot/references/add-watch.md +56 -0
- graphify/skills/copilot/references/exports.md +87 -0
- graphify/skills/copilot/references/extraction-spec.md +70 -0
- graphify/skills/copilot/references/github-and-merge.md +46 -0
- graphify/skills/copilot/references/hooks.md +33 -0
- graphify/skills/copilot/references/query.md +311 -0
- graphify/skills/copilot/references/transcribe.md +52 -0
- graphify/skills/copilot/references/update.md +210 -0
- graphify/skills/droid/references/add-watch.md +56 -0
- graphify/skills/droid/references/exports.md +87 -0
- graphify/skills/droid/references/extraction-spec.md +70 -0
- graphify/skills/droid/references/github-and-merge.md +46 -0
- graphify/skills/droid/references/hooks.md +33 -0
- graphify/skills/droid/references/query.md +311 -0
- graphify/skills/droid/references/transcribe.md +52 -0
- graphify/skills/droid/references/update.md +210 -0
- graphify/skills/kilo/references/add-watch.md +56 -0
- graphify/skills/kilo/references/exports.md +87 -0
- graphify/skills/kilo/references/extraction-spec.md +70 -0
- graphify/skills/kilo/references/github-and-merge.md +46 -0
- graphify/skills/kilo/references/hooks.md +33 -0
- graphify/skills/kilo/references/query.md +311 -0
- graphify/skills/kilo/references/transcribe.md +52 -0
- graphify/skills/kilo/references/update.md +210 -0
- graphify/skills/kiro/references/add-watch.md +56 -0
- graphify/skills/kiro/references/exports.md +87 -0
- graphify/skills/kiro/references/extraction-spec.md +31 -0
- graphify/skills/kiro/references/github-and-merge.md +46 -0
- graphify/skills/kiro/references/hooks.md +33 -0
- graphify/skills/kiro/references/query.md +311 -0
- graphify/skills/kiro/references/transcribe.md +52 -0
- graphify/skills/kiro/references/update.md +210 -0
- graphify/skills/opencode/references/add-watch.md +56 -0
- graphify/skills/opencode/references/exports.md +87 -0
- graphify/skills/opencode/references/extraction-spec.md +70 -0
- graphify/skills/opencode/references/github-and-merge.md +46 -0
- graphify/skills/opencode/references/hooks.md +33 -0
- graphify/skills/opencode/references/query.md +311 -0
- graphify/skills/opencode/references/transcribe.md +52 -0
- graphify/skills/opencode/references/update.md +210 -0
- graphify/skills/pi/references/add-watch.md +56 -0
- graphify/skills/pi/references/exports.md +87 -0
- graphify/skills/pi/references/extraction-spec.md +31 -0
- graphify/skills/pi/references/github-and-merge.md +46 -0
- graphify/skills/pi/references/hooks.md +33 -0
- graphify/skills/pi/references/query.md +311 -0
- graphify/skills/pi/references/transcribe.md +52 -0
- graphify/skills/pi/references/update.md +210 -0
- graphify/skills/trae/references/add-watch.md +56 -0
- graphify/skills/trae/references/exports.md +87 -0
- graphify/skills/trae/references/extraction-spec.md +70 -0
- graphify/skills/trae/references/github-and-merge.md +46 -0
- graphify/skills/trae/references/hooks.md +35 -0
- graphify/skills/trae/references/query.md +311 -0
- graphify/skills/trae/references/transcribe.md +52 -0
- graphify/skills/trae/references/update.md +210 -0
- graphify/skills/vscode/references/add-watch.md +56 -0
- graphify/skills/vscode/references/exports.md +87 -0
- graphify/skills/vscode/references/extraction-spec.md +70 -0
- graphify/skills/vscode/references/github-and-merge.md +46 -0
- graphify/skills/vscode/references/hooks.md +33 -0
- graphify/skills/vscode/references/query.md +311 -0
- graphify/skills/vscode/references/transcribe.md +52 -0
- graphify/skills/vscode/references/update.md +210 -0
- graphify/skills/windows/references/add-watch.md +56 -0
- graphify/skills/windows/references/exports.md +87 -0
- graphify/skills/windows/references/extraction-spec.md +70 -0
- graphify/skills/windows/references/github-and-merge.md +46 -0
- graphify/skills/windows/references/hooks.md +33 -0
- graphify/skills/windows/references/query.md +311 -0
- graphify/skills/windows/references/transcribe.md +52 -0
- graphify/skills/windows/references/update.md +210 -0
- graphify/symbol_resolution.py +556 -0
- graphify/transcribe.py +186 -0
- graphify/tree_html.py +603 -0
- graphify/validate.py +95 -0
- graphify/watch.py +2280 -0
- graphify/wiki.py +405 -0
- graphitect/__init__.py +28 -0
- graphitect/__main__.py +4 -0
- graphitect/_vendor/__init__.py +2 -0
- graphitect/_vendor/archify/LICENSE +22 -0
- graphitect/_vendor/archify/SKILL.md +137 -0
- graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
- graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
- graphitect/_vendor/archify/assets/template.html +14935 -0
- graphitect/_vendor/archify/bin/archify.mjs +2091 -0
- graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
- graphitect/_vendor/archify/bin/preview.mjs +653 -0
- graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
- graphitect/_vendor/archify/brand-marks/README.md +31 -0
- graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
- graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
- graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
- graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
- graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
- graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
- graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
- graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
- graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
- graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
- graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
- graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
- graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
- graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
- graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
- graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
- graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
- graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
- graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
- graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
- graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
- graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
- graphitect/_vendor/archify/package-lock.json +149 -0
- graphitect/_vendor/archify/package.json +39 -0
- graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
- graphitect/_vendor/archify/references/authoring-contract.md +243 -0
- graphitect/_vendor/archify/references/brand-marks.md +65 -0
- graphitect/_vendor/archify/references/delivery-contract.md +120 -0
- graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
- graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
- graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
- graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
- graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
- graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
- graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
- graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
- graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
- graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
- graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
- graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
- graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
- graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
- graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
- graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
- graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
- graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
- graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
- graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
- graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
- graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
- graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
- graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
- graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
- graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
- graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
- graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- graphitect/_vendor/archify/schemas/README.md +211 -0
- graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
- graphitect/_vendor/archify/schemas/common.schema.json +115 -0
- graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
- graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
- graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
- graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
- graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
- graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
- graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
- graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
- graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
- graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
- graphitect/_vendor/archify/skill-release.json +10 -0
- graphitect/cli.py +981 -0
- graphitect/deliver/__init__.py +5 -0
- graphitect/deliver/archify_adapter.py +1877 -0
- graphitect/deliver/archify_ir.py +160 -0
- graphitect/deliver/archify_repair.py +135 -0
- graphitect/deliver/doc_compiler.py +916 -0
- graphitect/ground/__init__.py +5 -0
- graphitect/ground/describe_source.py +27 -0
- graphitect/ground/fullread_source.py +56 -0
- graphitect/ground/graphify_source.py +107 -0
- graphitect/models.py +118 -0
- graphitect/skill/SKILL.md +80 -0
- graphitect/skill/agents/openai.yaml +4 -0
- graphitect/synthesize/__init__.py +5 -0
- graphitect/synthesize/engine.py +281 -0
- graphitect/synthesize/llm_backend.py +331 -0
- graphitect/synthesize/questions.py +139 -0
- graphitect/synthesize/rubric.py +104 -0
- graphitect-0.2.0.dist-info/METADATA +284 -0
- graphitect-0.2.0.dist-info/RECORD +336 -0
- graphitect-0.2.0.dist-info/WHEEL +5 -0
- graphitect-0.2.0.dist-info/entry_points.txt +2 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
- graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
- graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
- graphitect-0.2.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,3584 @@
|
|
|
1
|
+
"""resolution — moved verbatim from graphify/extract.py."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from graphify.extractors.models import LanguageConfig, _JS_CACHE_BYPASS_SUFFIXES, _NamespaceExportFact, _StarExportFact, _SymbolAliasFact, _SymbolDeclarationFact, _SymbolExportFact, _SymbolImportFact, _SymbolResolutionFacts, _SymbolUseFact, _WORKSPACE_PACKAGE_CACHE # noqa: E402,F401
|
|
7
|
+
from graphify.extractors.base import ( # noqa: F401
|
|
8
|
+
_LANGUAGE_BUILTIN_GLOBALS,
|
|
9
|
+
_file_stem,
|
|
10
|
+
_make_id,
|
|
11
|
+
_read_text,
|
|
12
|
+
)
|
|
13
|
+
import functools
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {}
|
|
22
|
+
|
|
23
|
+
# compilerOptions.baseUrl per config path, as an absolute dir (#2153).
|
|
24
|
+
_TSCONFIG_BASEURL_CACHE: "dict[str, Path | None]" = {}
|
|
25
|
+
|
|
26
|
+
_WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json")
|
|
27
|
+
|
|
28
|
+
# Nearest package.json `imports` map (Node subpath imports), keyed by the
|
|
29
|
+
# resolved start directory. Same lifetime rule as _TSCONFIG_ALIAS_CACHE: no
|
|
30
|
+
# mtime component, so extract() clears it per run.
|
|
31
|
+
_PACKAGE_IMPORTS_CACHE: "dict[str, tuple[Path, dict] | None]" = {}
|
|
32
|
+
|
|
33
|
+
_JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs", ".cjs")
|
|
34
|
+
|
|
35
|
+
_JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs")
|
|
36
|
+
|
|
37
|
+
def _resolve_js_import_path(candidate: Path) -> Path:
|
|
38
|
+
"""Resolve a JS/TS/Svelte import target to a local file when it exists."""
|
|
39
|
+
candidate = Path(os.path.normpath(candidate))
|
|
40
|
+
if candidate.is_file():
|
|
41
|
+
return candidate
|
|
42
|
+
|
|
43
|
+
# TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx.
|
|
44
|
+
if candidate.suffix == ".js":
|
|
45
|
+
ts_candidate = candidate.with_suffix(".ts")
|
|
46
|
+
if ts_candidate.is_file():
|
|
47
|
+
return ts_candidate
|
|
48
|
+
elif candidate.suffix == ".jsx":
|
|
49
|
+
tsx_candidate = candidate.with_suffix(".tsx")
|
|
50
|
+
if tsx_candidate.is_file():
|
|
51
|
+
return tsx_candidate
|
|
52
|
+
|
|
53
|
+
# Append extensions to the full filename, which covers extensionless imports,
|
|
54
|
+
# multi-dot helpers, and Svelte 5 rune files like Foo.svelte.ts.
|
|
55
|
+
for ext in _JS_RESOLVE_EXTS:
|
|
56
|
+
with_ext = candidate.parent / f"{candidate.name}{ext}"
|
|
57
|
+
if with_ext.is_file():
|
|
58
|
+
return with_ext
|
|
59
|
+
|
|
60
|
+
# Only fall back to directory indexes after file candidates lose.
|
|
61
|
+
if candidate.is_dir():
|
|
62
|
+
for index_name in _JS_INDEX_FILES:
|
|
63
|
+
index_candidate = candidate / index_name
|
|
64
|
+
if index_candidate.is_file():
|
|
65
|
+
return index_candidate
|
|
66
|
+
|
|
67
|
+
return candidate
|
|
68
|
+
|
|
69
|
+
def _strip_jsonc(text: str) -> str:
|
|
70
|
+
"""Strip // line comments, /* */ block comments, and trailing commas from JSONC.
|
|
71
|
+
|
|
72
|
+
Preserves string contents (including // and /* inside strings) by skipping over
|
|
73
|
+
quoted spans first. Required for tsconfig.json files generated by SvelteKit,
|
|
74
|
+
NestJS, Vite, T3, Astro, etc., which use JSONC by default (#700).
|
|
75
|
+
"""
|
|
76
|
+
# Remove block and line comments while leaving string literals untouched.
|
|
77
|
+
pattern = re.compile(
|
|
78
|
+
r'"(?:\\.|[^"\\])*"' # double-quoted string (with escapes)
|
|
79
|
+
r"|/\*.*?\*/" # /* block comment */
|
|
80
|
+
r"|//[^\n]*", # // line comment
|
|
81
|
+
re.DOTALL,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _replace(match: re.Match) -> str:
|
|
85
|
+
token = match.group(0)
|
|
86
|
+
if token.startswith('"'):
|
|
87
|
+
return token
|
|
88
|
+
return ""
|
|
89
|
+
|
|
90
|
+
stripped = pattern.sub(_replace, text)
|
|
91
|
+
# Remove trailing commas before } or ] (allowing whitespace between).
|
|
92
|
+
stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
|
|
93
|
+
return stripped
|
|
94
|
+
|
|
95
|
+
def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, list[str]]:
|
|
96
|
+
"""Recursively read path aliases from a tsconfig, following extends chains.
|
|
97
|
+
|
|
98
|
+
Child config paths override parent. Circular extends are detected via seen set.
|
|
99
|
+
npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk.
|
|
100
|
+
Handles JSONC (comments + trailing commas) which is the default tsconfig format
|
|
101
|
+
for SvelteKit, NestJS, Vite, T3, Astro, etc. (#700).
|
|
102
|
+
"""
|
|
103
|
+
if str(tsconfig) in seen:
|
|
104
|
+
return {}
|
|
105
|
+
seen.add(str(tsconfig))
|
|
106
|
+
try:
|
|
107
|
+
raw = tsconfig.read_text(encoding="utf-8")
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f" warning: could not read {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
|
|
110
|
+
return {}
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(raw)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(_strip_jsonc(raw))
|
|
116
|
+
except json.JSONDecodeError as e:
|
|
117
|
+
print(f" warning: failed to parse {tsconfig} as JSON/JSONC ({e.msg} at line {e.lineno} col {e.colno})", file=sys.stderr, flush=True)
|
|
118
|
+
return {}
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f" warning: failed to parse {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
|
|
121
|
+
return {}
|
|
122
|
+
|
|
123
|
+
aliases: dict[str, list[str]] = {}
|
|
124
|
+
# `extends` may be a string or, since TypeScript 5.0, an array of paths.
|
|
125
|
+
# For an array, parents are processed in order with later entries
|
|
126
|
+
# overriding earlier ones; the extending config (paths below) overrides
|
|
127
|
+
# all parents. Without the list branch, an array `extends` raised
|
|
128
|
+
# `AttributeError: 'list' object has no attribute 'startswith'`, which
|
|
129
|
+
# _safe_extract turned into a skip of the whole file.
|
|
130
|
+
extends = data.get("extends")
|
|
131
|
+
if isinstance(extends, str):
|
|
132
|
+
extends_list = [extends]
|
|
133
|
+
elif isinstance(extends, list):
|
|
134
|
+
extends_list = [e for e in extends if isinstance(e, str)]
|
|
135
|
+
else:
|
|
136
|
+
extends_list = []
|
|
137
|
+
for ext in extends_list:
|
|
138
|
+
# Skip scoped npm package configs (e.g. @tsconfig/svelte) — not on disk.
|
|
139
|
+
if not ext or ext.startswith("@"):
|
|
140
|
+
continue
|
|
141
|
+
extended_path = _resolve_cached((base_dir / ext))
|
|
142
|
+
if not extended_path.suffix:
|
|
143
|
+
extended_path = extended_path.with_suffix(".json")
|
|
144
|
+
if extended_path.exists():
|
|
145
|
+
aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen))
|
|
146
|
+
|
|
147
|
+
# tsconfig `paths` are resolved relative to `baseUrl` (itself relative to
|
|
148
|
+
# the tsconfig's directory), not the tsconfig directory directly. Honoring
|
|
149
|
+
# baseUrl is required for the common monorepo / NestJS layout where
|
|
150
|
+
# baseUrl points at a subdirectory, e.g. baseUrl "./src" with
|
|
151
|
+
# "@services/*": ["services/*"] must resolve to <dir>/src/services rather
|
|
152
|
+
# than <dir>/services. Defaults to "." so configs without baseUrl (paths
|
|
153
|
+
# relative to the tsconfig dir, the TS 4.1+ behavior) keep working.
|
|
154
|
+
compiler_options = data.get("compilerOptions", {})
|
|
155
|
+
base_url = compiler_options.get("baseUrl") or "."
|
|
156
|
+
paths_base = base_dir / base_url
|
|
157
|
+
paths = compiler_options.get("paths", {})
|
|
158
|
+
for alias, targets in paths.items():
|
|
159
|
+
if not targets:
|
|
160
|
+
continue
|
|
161
|
+
# Keep ALL targets in declared order — tsc tries each until one resolves
|
|
162
|
+
# on disk. Discarding the fallbacks (#1531) misresolved/dropped imports
|
|
163
|
+
# whose file lived at a non-first target. Preserve wildcard tokens in
|
|
164
|
+
# both sides until the resolver substitutes the captured segment, then
|
|
165
|
+
# normalizes the concrete path (#927). Empty/non-string entries are skipped.
|
|
166
|
+
target_patterns = [
|
|
167
|
+
str(paths_base / t)
|
|
168
|
+
for t in targets
|
|
169
|
+
if isinstance(t, str) and t
|
|
170
|
+
]
|
|
171
|
+
if target_patterns:
|
|
172
|
+
aliases[alias] = target_patterns
|
|
173
|
+
|
|
174
|
+
return aliases
|
|
175
|
+
|
|
176
|
+
def _read_json_config(path: Path) -> "dict | None":
|
|
177
|
+
"""Parse a tsconfig/jsconfig as JSON, falling back to JSONC (#2153).
|
|
178
|
+
|
|
179
|
+
Mirrors the read/parse handling in `_read_tsconfig_aliases`; returns None on
|
|
180
|
+
any unreadable or unparseable file so a malformed config degrades to "no
|
|
181
|
+
baseUrl" instead of raising.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
185
|
+
except OSError:
|
|
186
|
+
return None
|
|
187
|
+
for candidate in (raw, _strip_jsonc(raw)):
|
|
188
|
+
try:
|
|
189
|
+
data = json.loads(candidate)
|
|
190
|
+
except Exception:
|
|
191
|
+
continue
|
|
192
|
+
return data if isinstance(data, dict) else None
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
def _find_js_config(start_dir: Path) -> "tuple[Path, Path] | None":
|
|
196
|
+
"""Nearest tsconfig.json/jsconfig.json walking up from start_dir.
|
|
197
|
+
|
|
198
|
+
`jsconfig.json` is the plain-JS spelling of the same file (already indexed by
|
|
199
|
+
json_config.py) and was never probed here, so a Rails/webpacker project that
|
|
200
|
+
configures resolution in jsconfig.json got no aliases at all (#2153).
|
|
201
|
+
tsconfig.json wins when both sit in one directory, matching tsc and editors,
|
|
202
|
+
which consult jsconfig.json only when there is no tsconfig.json.
|
|
203
|
+
"""
|
|
204
|
+
current = _resolve_cached(start_dir)
|
|
205
|
+
for candidate in [current, *current.parents]:
|
|
206
|
+
for name in ("tsconfig.json", "jsconfig.json"):
|
|
207
|
+
config = candidate / name
|
|
208
|
+
if config.exists():
|
|
209
|
+
return config, candidate
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]:
|
|
213
|
+
"""Walk up from start_dir to find tsconfig/jsconfig.json and return compilerOptions.paths aliases.
|
|
214
|
+
|
|
215
|
+
Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included.
|
|
216
|
+
Returns a dict mapping alias patterns to ordered resolved target patterns;
|
|
217
|
+
wildcard tokens remain intact for substitution during resolution (#927).
|
|
218
|
+
Result is cached by config path string. The cache has no mtime/content
|
|
219
|
+
component, so extract() clears it per run (#2917); do not assume the entry
|
|
220
|
+
survives a config edit within a long-lived process.
|
|
221
|
+
"""
|
|
222
|
+
found = _find_js_config(start_dir)
|
|
223
|
+
if found is None:
|
|
224
|
+
return {}
|
|
225
|
+
config, candidate = found
|
|
226
|
+
key = str(config)
|
|
227
|
+
if key not in _TSCONFIG_ALIAS_CACHE:
|
|
228
|
+
_TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(config, candidate, seen=set())
|
|
229
|
+
return _TSCONFIG_ALIAS_CACHE[key]
|
|
230
|
+
|
|
231
|
+
def _load_tsconfig_base_url(start_dir: Path) -> "Path | None":
|
|
232
|
+
"""`compilerOptions.baseUrl` of the nearest config, as an absolute directory.
|
|
233
|
+
|
|
234
|
+
baseUrl was only ever used as the base that `paths` targets resolve against,
|
|
235
|
+
so a config declaring baseUrl and NO paths yielded an empty alias map and
|
|
236
|
+
every non-relative import went unresolved (#2153). Exposed separately so it
|
|
237
|
+
can act as a resolution root of last resort, after all declared aliases miss.
|
|
238
|
+
Returns None when no config declares baseUrl. Cached by config path with no
|
|
239
|
+
mtime component, so extract() clears it per run (#2917).
|
|
240
|
+
"""
|
|
241
|
+
found = _find_js_config(start_dir)
|
|
242
|
+
if found is None:
|
|
243
|
+
return None
|
|
244
|
+
config, candidate = found
|
|
245
|
+
key = str(config)
|
|
246
|
+
if key not in _TSCONFIG_BASEURL_CACHE:
|
|
247
|
+
base_url = None
|
|
248
|
+
data = _read_json_config(config)
|
|
249
|
+
if data is not None:
|
|
250
|
+
raw_base = data.get("compilerOptions", {}).get("baseUrl")
|
|
251
|
+
if isinstance(raw_base, str) and raw_base:
|
|
252
|
+
base_url = Path(os.path.normpath(candidate / raw_base))
|
|
253
|
+
_TSCONFIG_BASEURL_CACHE[key] = base_url
|
|
254
|
+
return _TSCONFIG_BASEURL_CACHE[key]
|
|
255
|
+
|
|
256
|
+
def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None":
|
|
257
|
+
"""Return (specificity, captured text, is_wildcard) when pattern matches raw.
|
|
258
|
+
|
|
259
|
+
Exact aliases win first. Wildcard aliases follow TypeScript's longest-prefix
|
|
260
|
+
rule. The final branch preserves Graphify's existing support for treating a
|
|
261
|
+
non-wildcard alias as a directory prefix, but only after real wildcard matches.
|
|
262
|
+
"""
|
|
263
|
+
if "*" in pattern:
|
|
264
|
+
if pattern.count("*") != 1:
|
|
265
|
+
return None
|
|
266
|
+
prefix, suffix = pattern.split("*", 1)
|
|
267
|
+
if not raw.startswith(prefix) or not raw.endswith(suffix):
|
|
268
|
+
return None
|
|
269
|
+
end = len(raw) - len(suffix) if suffix else len(raw)
|
|
270
|
+
if end < len(prefix):
|
|
271
|
+
return None
|
|
272
|
+
return (1, -len(prefix)), raw[len(prefix):end], True
|
|
273
|
+
|
|
274
|
+
if raw == pattern:
|
|
275
|
+
return (0, -len(pattern)), "", False
|
|
276
|
+
|
|
277
|
+
prefix = pattern.rstrip("/")
|
|
278
|
+
if prefix and raw.startswith(prefix + "/"):
|
|
279
|
+
return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]],
|
|
283
|
+
base_url: "Path | None" = None) -> "Path | None":
|
|
284
|
+
"""Resolve `raw` against the most specific matching tsconfig alias pattern.
|
|
285
|
+
|
|
286
|
+
Within that pattern, try targets in declared order and return the first whose
|
|
287
|
+
candidate resolves to a real file. If none exist, return the first candidate
|
|
288
|
+
so existing phantom/external-edge behavior stays unchanged.
|
|
289
|
+
|
|
290
|
+
`base_url` is a resolution root of last resort, tried only when NO declared
|
|
291
|
+
alias matches (#2153). It must not participate in the specificity contest:
|
|
292
|
+
a bare `*` alias would score (1, 0) and so beat a declared non-wildcard
|
|
293
|
+
directory-prefix alias at (2, -len), silently shadowing it and regressing
|
|
294
|
+
#1269. Unlike the alias path this returns a candidate only when it is a real
|
|
295
|
+
file on disk, so a genuine external package (`import React from 'react'`)
|
|
296
|
+
still resolves to nothing instead of a fabricated <baseUrl>/react edge.
|
|
297
|
+
"""
|
|
298
|
+
best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None
|
|
299
|
+
for pattern, targets in aliases.items():
|
|
300
|
+
match = _match_tsconfig_alias(raw, pattern)
|
|
301
|
+
if match is None:
|
|
302
|
+
continue
|
|
303
|
+
specificity, captured, is_wildcard = match
|
|
304
|
+
if best is None or specificity < best[0]:
|
|
305
|
+
best = specificity, captured, is_wildcard, targets
|
|
306
|
+
|
|
307
|
+
if best is None:
|
|
308
|
+
if base_url is not None:
|
|
309
|
+
candidate = Path(os.path.normpath(base_url / raw))
|
|
310
|
+
resolved = _resolve_js_import_path(candidate)
|
|
311
|
+
if resolved.is_file():
|
|
312
|
+
return resolved
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
_, captured, is_wildcard, targets = best
|
|
316
|
+
first = None
|
|
317
|
+
for target in targets:
|
|
318
|
+
if is_wildcard:
|
|
319
|
+
# TypeScript substitutes only when the matched star is non-empty.
|
|
320
|
+
substituted = target.replace("*", captured, 1) if captured else target
|
|
321
|
+
cand = Path(os.path.normpath(substituted))
|
|
322
|
+
else:
|
|
323
|
+
cand = Path(target)
|
|
324
|
+
if captured:
|
|
325
|
+
cand = Path(os.path.normpath(cand / captured))
|
|
326
|
+
resolved = _resolve_js_import_path(cand)
|
|
327
|
+
if resolved.is_file():
|
|
328
|
+
return resolved
|
|
329
|
+
if first is None:
|
|
330
|
+
first = cand
|
|
331
|
+
return first
|
|
332
|
+
|
|
333
|
+
def _find_workspace_root(start_dir: Path) -> Path | None:
|
|
334
|
+
current = _resolve_cached(start_dir)
|
|
335
|
+
for candidate in [current, *current.parents]:
|
|
336
|
+
if (candidate / "pnpm-workspace.yaml").exists():
|
|
337
|
+
return candidate
|
|
338
|
+
package_json = candidate / "package.json"
|
|
339
|
+
if package_json.is_file():
|
|
340
|
+
try:
|
|
341
|
+
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
342
|
+
except Exception:
|
|
343
|
+
continue
|
|
344
|
+
if "workspaces" in data:
|
|
345
|
+
return candidate
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
def _pnpm_workspace_globs(workspace_file: Path) -> list[str]:
|
|
349
|
+
globs: list[str] = []
|
|
350
|
+
in_packages = False
|
|
351
|
+
for raw_line in workspace_file.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
352
|
+
line = raw_line.strip()
|
|
353
|
+
if not line or line.startswith("#"):
|
|
354
|
+
continue
|
|
355
|
+
if line.startswith("packages:"):
|
|
356
|
+
in_packages = True
|
|
357
|
+
continue
|
|
358
|
+
if in_packages and line.startswith("-"):
|
|
359
|
+
value = line[1:].strip().strip("'\"")
|
|
360
|
+
if value and not value.startswith("!"):
|
|
361
|
+
globs.append(value)
|
|
362
|
+
continue
|
|
363
|
+
if in_packages and not raw_line.startswith((" ", "\t")):
|
|
364
|
+
break
|
|
365
|
+
return globs
|
|
366
|
+
|
|
367
|
+
def _workspace_globs(root: Path) -> list[str]:
|
|
368
|
+
pnpm_workspace = root / "pnpm-workspace.yaml"
|
|
369
|
+
if pnpm_workspace.exists():
|
|
370
|
+
return _pnpm_workspace_globs(pnpm_workspace)
|
|
371
|
+
|
|
372
|
+
package_json = root / "package.json"
|
|
373
|
+
try:
|
|
374
|
+
data = json.loads(package_json.read_text(encoding="utf-8"))
|
|
375
|
+
except Exception:
|
|
376
|
+
return []
|
|
377
|
+
|
|
378
|
+
workspaces = data.get("workspaces")
|
|
379
|
+
if isinstance(workspaces, list):
|
|
380
|
+
return [item for item in workspaces if isinstance(item, str) and not item.startswith("!")]
|
|
381
|
+
if isinstance(workspaces, dict):
|
|
382
|
+
packages = workspaces.get("packages")
|
|
383
|
+
if isinstance(packages, list):
|
|
384
|
+
return [item for item in packages if isinstance(item, str) and not item.startswith("!")]
|
|
385
|
+
return []
|
|
386
|
+
|
|
387
|
+
def _load_workspace_packages(start_dir: Path) -> dict[str, Path]:
|
|
388
|
+
root = _find_workspace_root(start_dir)
|
|
389
|
+
if root is None:
|
|
390
|
+
return {}
|
|
391
|
+
manifest_mtimes = tuple(
|
|
392
|
+
(name, (root / name).stat().st_mtime_ns)
|
|
393
|
+
for name in _WORKSPACE_MANIFEST_NAMES
|
|
394
|
+
if (root / name).is_file()
|
|
395
|
+
)
|
|
396
|
+
key = str((root, manifest_mtimes))
|
|
397
|
+
if key in _WORKSPACE_PACKAGE_CACHE:
|
|
398
|
+
return _WORKSPACE_PACKAGE_CACHE[key]
|
|
399
|
+
|
|
400
|
+
packages: dict[str, Path] = {}
|
|
401
|
+
for pattern in _workspace_globs(root):
|
|
402
|
+
package_dirs: list[Path] = [root] if pattern in (".", "./") else list(root.glob(pattern))
|
|
403
|
+
for package_dir in package_dirs:
|
|
404
|
+
manifest = package_dir / "package.json"
|
|
405
|
+
if not manifest.is_file():
|
|
406
|
+
continue
|
|
407
|
+
try:
|
|
408
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
409
|
+
except Exception:
|
|
410
|
+
continue
|
|
411
|
+
name = data.get("name")
|
|
412
|
+
if isinstance(name, str) and name:
|
|
413
|
+
packages[name] = package_dir
|
|
414
|
+
_WORKSPACE_PACKAGE_CACHE[key] = packages
|
|
415
|
+
return packages
|
|
416
|
+
|
|
417
|
+
_EXPORT_CONDITION_PRIORITY = (
|
|
418
|
+
"source", "import", "module", "svelte", "types", "require", "default",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _resolve_export_target(value: Any) -> str | None:
|
|
422
|
+
"""Resolve an `exports` map value (string or condition object) to a
|
|
423
|
+
relative target string, honouring _EXPORT_CONDITION_PRIORITY for objects
|
|
424
|
+
and recursing into nested condition objects."""
|
|
425
|
+
if isinstance(value, str):
|
|
426
|
+
return value
|
|
427
|
+
if isinstance(value, dict):
|
|
428
|
+
for cond in _EXPORT_CONDITION_PRIORITY:
|
|
429
|
+
v = value.get(cond)
|
|
430
|
+
if isinstance(v, str):
|
|
431
|
+
return v
|
|
432
|
+
if isinstance(v, dict):
|
|
433
|
+
nested = _resolve_export_target(v)
|
|
434
|
+
if nested:
|
|
435
|
+
return nested
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
def _contained_in_package(resolved: Path, package_dir: Path) -> bool:
|
|
439
|
+
"""Guard against `exports` targets that escape the package directory
|
|
440
|
+
(e.g. "./evil": "../../../etc/passwd"). Only accept paths that stay
|
|
441
|
+
within package_dir after resolution."""
|
|
442
|
+
try:
|
|
443
|
+
return _resolve_cached(resolved).is_relative_to(_resolve_cached(package_dir))
|
|
444
|
+
except ValueError:
|
|
445
|
+
return False
|
|
446
|
+
|
|
447
|
+
def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]:
|
|
448
|
+
manifest = package_dir / "package.json"
|
|
449
|
+
manifest_data: dict[str, Any] = {}
|
|
450
|
+
try:
|
|
451
|
+
manifest_data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
452
|
+
except Exception:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
if subpath:
|
|
456
|
+
# Consult the package's `exports` subpath map before the bare-path
|
|
457
|
+
# fallback (#1308): "./browser" -> conditions -> file, plus single
|
|
458
|
+
# wildcard "./*" patterns. Targets that escape the package dir are
|
|
459
|
+
# rejected; resolution then falls through to the bare path.
|
|
460
|
+
exports = manifest_data.get("exports")
|
|
461
|
+
if isinstance(exports, dict):
|
|
462
|
+
subpath_key = "./" + subpath
|
|
463
|
+
target = _resolve_export_target(exports.get(subpath_key))
|
|
464
|
+
if target:
|
|
465
|
+
candidate = package_dir / target
|
|
466
|
+
if _contained_in_package(candidate, package_dir):
|
|
467
|
+
return [candidate]
|
|
468
|
+
else:
|
|
469
|
+
for pattern, pattern_value in exports.items():
|
|
470
|
+
if "*" in pattern and pattern.count("*") == 1:
|
|
471
|
+
prefix, suffix = pattern.split("*", 1)
|
|
472
|
+
if (subpath_key.startswith(prefix)
|
|
473
|
+
and (not suffix or subpath_key.endswith(suffix))):
|
|
474
|
+
matched = subpath_key[len(prefix):len(subpath_key) - len(suffix) if suffix else None]
|
|
475
|
+
resolved = _resolve_export_target(pattern_value)
|
|
476
|
+
if resolved and "*" in resolved:
|
|
477
|
+
candidate = package_dir / resolved.replace("*", matched)
|
|
478
|
+
if _contained_in_package(candidate, package_dir):
|
|
479
|
+
return [candidate]
|
|
480
|
+
return [package_dir / subpath]
|
|
481
|
+
|
|
482
|
+
exports = manifest_data.get("exports")
|
|
483
|
+
if isinstance(exports, str):
|
|
484
|
+
return [package_dir / exports]
|
|
485
|
+
if isinstance(exports, dict):
|
|
486
|
+
dot_target = _resolve_export_target(exports.get("."))
|
|
487
|
+
if dot_target:
|
|
488
|
+
return [package_dir / dot_target]
|
|
489
|
+
|
|
490
|
+
candidates: list[Path] = []
|
|
491
|
+
for key in ("svelte", "module", "main", "types"):
|
|
492
|
+
value = manifest_data.get(key)
|
|
493
|
+
if isinstance(value, str):
|
|
494
|
+
candidates.append(package_dir / value)
|
|
495
|
+
candidates.append(package_dir / "src/index")
|
|
496
|
+
candidates.append(package_dir / "index")
|
|
497
|
+
return candidates
|
|
498
|
+
|
|
499
|
+
def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None:
|
|
500
|
+
packages = _load_workspace_packages(start_dir)
|
|
501
|
+
for package_name, package_dir in packages.items():
|
|
502
|
+
if raw == package_name:
|
|
503
|
+
subpath = ""
|
|
504
|
+
elif raw.startswith(package_name + "/"):
|
|
505
|
+
subpath = raw[len(package_name) + 1:]
|
|
506
|
+
else:
|
|
507
|
+
continue
|
|
508
|
+
for candidate in _package_entry_candidates(package_dir, subpath):
|
|
509
|
+
resolved = _resolve_js_import_path(candidate)
|
|
510
|
+
if resolved.is_file():
|
|
511
|
+
return resolved
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
def _find_js_project_anchor(start_dir: Path) -> Path:
|
|
515
|
+
"""Discover the project root anchor for unmapped JS/TS convention aliases (#3357).
|
|
516
|
+
|
|
517
|
+
Walks upward from start_dir looking for:
|
|
518
|
+
1. Nearest directory containing package.json or pnpm-workspace.yaml
|
|
519
|
+
2. Nearest directory containing a VCS marker (_find_vcs_root)
|
|
520
|
+
3. Fallback: start_dir itself
|
|
521
|
+
"""
|
|
522
|
+
from graphify.detect import _find_vcs_root
|
|
523
|
+
|
|
524
|
+
current = _resolve_cached(start_dir)
|
|
525
|
+
home = Path.home()
|
|
526
|
+
|
|
527
|
+
for candidate in [current, *current.parents]:
|
|
528
|
+
if candidate == home:
|
|
529
|
+
break
|
|
530
|
+
if (candidate / "package.json").is_file() or (candidate / "pnpm-workspace.yaml").is_file():
|
|
531
|
+
return candidate
|
|
532
|
+
|
|
533
|
+
vcs_root = _find_vcs_root(start_dir)
|
|
534
|
+
if vcs_root is not None:
|
|
535
|
+
return vcs_root
|
|
536
|
+
|
|
537
|
+
return current
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _load_package_imports(start_dir: Path) -> "tuple[Path, dict] | None":
|
|
541
|
+
"""Nearest package.json `imports` map walking up from start_dir.
|
|
542
|
+
|
|
543
|
+
Node subpath imports (https://nodejs.org/api/packages.html#subpath-imports):
|
|
544
|
+
keys start with `#`, values are relative targets or condition objects, and
|
|
545
|
+
the map applies to every file inside that package. AdonisJS 6 scaffolds
|
|
546
|
+
`"#services/*": "./app/services/*.js"` and imports through it everywhere;
|
|
547
|
+
without this map ~90% of an Adonis app's imports resolved to nothing and
|
|
548
|
+
`affected` saw only the handful of files using relative paths. Unlike
|
|
549
|
+
tsconfig `paths`, a nested tsconfig (e.g. `inertia/tsconfig.json`) cannot
|
|
550
|
+
shadow it, which matches how Node and tsc actually resolve these.
|
|
551
|
+
|
|
552
|
+
Returns (package_dir, imports) or None. The nearest package.json wins even
|
|
553
|
+
when it has no `imports` (Node never walks past the enclosing package).
|
|
554
|
+
"""
|
|
555
|
+
current = _resolve_cached(start_dir)
|
|
556
|
+
key = str(current)
|
|
557
|
+
if key in _PACKAGE_IMPORTS_CACHE:
|
|
558
|
+
return _PACKAGE_IMPORTS_CACHE[key]
|
|
559
|
+
result: "tuple[Path, dict] | None" = None
|
|
560
|
+
for candidate in [current, *current.parents]:
|
|
561
|
+
manifest = candidate / "package.json"
|
|
562
|
+
if manifest.is_file():
|
|
563
|
+
data = _read_json_config(manifest)
|
|
564
|
+
imports = data.get("imports") if isinstance(data, dict) else None
|
|
565
|
+
if isinstance(imports, dict) and imports:
|
|
566
|
+
result = (candidate, imports)
|
|
567
|
+
break
|
|
568
|
+
_PACKAGE_IMPORTS_CACHE[key] = result
|
|
569
|
+
return result
|
|
570
|
+
|
|
571
|
+
def _match_subpath_import(raw: str, pattern: str) -> "tuple[int, str, bool] | None":
|
|
572
|
+
"""Node semantics for an `imports` key: exact match, or a single `*` wildcard.
|
|
573
|
+
|
|
574
|
+
Stricter than _match_tsconfig_alias on purpose: `#utils` must NOT match
|
|
575
|
+
`#utils/x` (Node treats a non-wildcard key as an exact specifier). Returns
|
|
576
|
+
(specificity, captured, is_wildcard); lower specificity wins, longest
|
|
577
|
+
literal prefix first among wildcards, mirroring Node's pattern ranking.
|
|
578
|
+
"""
|
|
579
|
+
if "*" in pattern:
|
|
580
|
+
if pattern.count("*") != 1:
|
|
581
|
+
return None
|
|
582
|
+
prefix, suffix = pattern.split("*", 1)
|
|
583
|
+
if not raw.startswith(prefix) or not raw.endswith(suffix):
|
|
584
|
+
return None
|
|
585
|
+
end = len(raw) - len(suffix) if suffix else len(raw)
|
|
586
|
+
if end < len(prefix):
|
|
587
|
+
return None
|
|
588
|
+
return -len(prefix), raw[len(prefix):end], True
|
|
589
|
+
if raw == pattern:
|
|
590
|
+
return -(len(pattern) + 1_000_000), "", False
|
|
591
|
+
return None
|
|
592
|
+
|
|
593
|
+
def _resolve_package_import(raw: str, start_dir: Path) -> "Path | None":
|
|
594
|
+
"""Resolve a `#subpath` specifier through package.json `imports` to a local file.
|
|
595
|
+
|
|
596
|
+
Only local targets (`./…`) are followed: a value naming an external package
|
|
597
|
+
(`"#dep": "dep-node-native"`) is a third-party dependency and must keep the
|
|
598
|
+
existing external-reference behavior in _resolve_js_import_target.
|
|
599
|
+
"""
|
|
600
|
+
if not raw.startswith("#"):
|
|
601
|
+
return None
|
|
602
|
+
loaded = _load_package_imports(start_dir)
|
|
603
|
+
if loaded is None:
|
|
604
|
+
return None
|
|
605
|
+
package_dir, imports = loaded
|
|
606
|
+
best: "tuple[int, str, bool, object] | None" = None
|
|
607
|
+
for pattern, value in imports.items():
|
|
608
|
+
if not isinstance(pattern, str) or not pattern.startswith("#"):
|
|
609
|
+
continue
|
|
610
|
+
match = _match_subpath_import(raw, pattern)
|
|
611
|
+
if match is None:
|
|
612
|
+
continue
|
|
613
|
+
specificity, captured, is_wildcard = match
|
|
614
|
+
if best is None or specificity < best[0]:
|
|
615
|
+
best = (specificity, captured, is_wildcard, value)
|
|
616
|
+
if best is None:
|
|
617
|
+
return None
|
|
618
|
+
_, captured, is_wildcard, value = best
|
|
619
|
+
target = _resolve_export_target(value)
|
|
620
|
+
if not isinstance(target, str) or not target.startswith("./"):
|
|
621
|
+
return None
|
|
622
|
+
if is_wildcard:
|
|
623
|
+
target = target.replace("*", captured, 1) if captured else target
|
|
624
|
+
candidate = Path(os.path.normpath(package_dir / target))
|
|
625
|
+
resolved = _resolve_js_import_path(candidate)
|
|
626
|
+
return resolved if resolved.is_file() else None
|
|
627
|
+
|
|
628
|
+
def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> Path | None:
|
|
629
|
+
"""Resolve a JS/TS module path or specifier to a local source file.
|
|
630
|
+
|
|
631
|
+
With a Path argument this preserves the path-based helper API used by
|
|
632
|
+
import-extension tests. With a string plus start_dir it resolves JS/TS
|
|
633
|
+
module specifiers including relative paths, tsconfig aliases, and workspace
|
|
634
|
+
packages.
|
|
635
|
+
"""
|
|
636
|
+
if isinstance(raw, Path):
|
|
637
|
+
return _resolve_js_import_path(raw)
|
|
638
|
+
if start_dir is None:
|
|
639
|
+
return _resolve_js_import_path(Path(raw))
|
|
640
|
+
if raw.startswith("."):
|
|
641
|
+
return _resolve_js_import_path(start_dir / raw)
|
|
642
|
+
|
|
643
|
+
aliases = _load_tsconfig_aliases(start_dir)
|
|
644
|
+
hit = _resolve_tsconfig_alias(raw, aliases,
|
|
645
|
+
base_url=_load_tsconfig_base_url(start_dir))
|
|
646
|
+
if hit is not None:
|
|
647
|
+
return _resolve_js_import_path(hit)
|
|
648
|
+
|
|
649
|
+
# Node subpath imports (`#services/foo` via package.json `imports`). Tried
|
|
650
|
+
# after tsconfig `paths` so an explicit alias keeps precedence (#1269).
|
|
651
|
+
if raw.startswith("#"):
|
|
652
|
+
hit = _resolve_package_import(raw, start_dir)
|
|
653
|
+
if hit is not None:
|
|
654
|
+
return hit
|
|
655
|
+
|
|
656
|
+
workspace_hit = _resolve_workspace_import(raw, start_dir)
|
|
657
|
+
if workspace_hit is not None:
|
|
658
|
+
return workspace_hit
|
|
659
|
+
|
|
660
|
+
# Unmapped `@/` project-root convention fallback (#3357).
|
|
661
|
+
# Active ONLY when NO tsconfig.json or jsconfig.json exists anywhere in the upward tree.
|
|
662
|
+
if raw.startswith("@/") and _find_js_config(start_dir) is None:
|
|
663
|
+
subpath = raw[2:]
|
|
664
|
+
if subpath:
|
|
665
|
+
anchor = _find_js_project_anchor(start_dir)
|
|
666
|
+
if (anchor / "src").is_dir():
|
|
667
|
+
cand = _resolve_js_import_path(anchor / "src" / subpath)
|
|
668
|
+
if cand.is_file():
|
|
669
|
+
return cand
|
|
670
|
+
cand = _resolve_js_import_path(anchor / subpath)
|
|
671
|
+
if cand.is_file():
|
|
672
|
+
return cand
|
|
673
|
+
|
|
674
|
+
return None
|
|
675
|
+
|
|
676
|
+
def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None":
|
|
677
|
+
"""Resolve a JS/TS import path string to (target_nid, resolved_path).
|
|
678
|
+
|
|
679
|
+
Handles relative paths, tsconfig path aliases, workspace packages, and
|
|
680
|
+
bare/scoped imports.
|
|
681
|
+
Returns None if `raw` is empty.
|
|
682
|
+
"""
|
|
683
|
+
if not raw:
|
|
684
|
+
return None
|
|
685
|
+
resolved_path = _resolve_js_module_path(raw, Path(str_path).parent)
|
|
686
|
+
if resolved_path is not None:
|
|
687
|
+
return _make_id(str(resolved_path)), resolved_path
|
|
688
|
+
module_name = raw.split("/")[-1]
|
|
689
|
+
if not module_name:
|
|
690
|
+
return None
|
|
691
|
+
# Unresolved: relative/absolute, tsconfig-alias and workspace resolution have
|
|
692
|
+
# all run and failed, so this is an external package (or a dangling local
|
|
693
|
+
# path). Namespace the id with the "ref" prefix — the J-4 convention already
|
|
694
|
+
# used for tsconfig `extends`/`$ref` externals — so it can NEVER collapse to
|
|
695
|
+
# the same _make_id as a local file/symbol node. Without it, the bare
|
|
696
|
+
# last-segment id (e.g. "tailwindcss/colors" -> "colors") collides with any
|
|
697
|
+
# unrelated local file of that stem via build.py's pre-migration alias index,
|
|
698
|
+
# producing a confident (EXTRACTED) cross-language phantom imports_from edge
|
|
699
|
+
# (#1638). The ref-namespaced target has no node, so build drops it as an
|
|
700
|
+
# external reference — the correct outcome for a third-party import.
|
|
701
|
+
return _make_id("ref", raw), None
|
|
702
|
+
|
|
703
|
+
def _resolve_c_include_path(raw: str, str_path: str) -> "Path | None":
|
|
704
|
+
"""Resolve a quoted #include path to a real file on disk.
|
|
705
|
+
|
|
706
|
+
Searches relative to the including file's directory. Returns None for
|
|
707
|
+
system headers (<...>) or paths that don't exist on disk.
|
|
708
|
+
"""
|
|
709
|
+
if not raw:
|
|
710
|
+
return None
|
|
711
|
+
candidate = _resolve_cached((Path(str_path).parent / raw))
|
|
712
|
+
if candidate.is_file():
|
|
713
|
+
return candidate
|
|
714
|
+
return None
|
|
715
|
+
|
|
716
|
+
def _resolve_lua_import_target(raw_module: str, str_path: str) -> str:
|
|
717
|
+
"""Resolve a Lua require() module name to a node id.
|
|
718
|
+
|
|
719
|
+
Lua module names use dots as path separators: `require("pkg.b")` looks for
|
|
720
|
+
`pkg/b.lua` (or `pkg/b/init.lua`) relative to a package root. We probe the
|
|
721
|
+
importing file's directory and walk upward looking for a matching file on
|
|
722
|
+
disk; if found, the returned id matches the file node id `_extract_generic`
|
|
723
|
+
assigns to that file (`_make_id(str(path))`), so the edge lands on a real
|
|
724
|
+
node. When nothing matches, fall back to `_make_id` of the full dotted
|
|
725
|
+
module name so cross-file resolution can still complete via the symbol
|
|
726
|
+
resolution pass instead of dropping the edge entirely (#1075).
|
|
727
|
+
"""
|
|
728
|
+
if not raw_module:
|
|
729
|
+
return ""
|
|
730
|
+
rel = raw_module.replace(".", "/")
|
|
731
|
+
try:
|
|
732
|
+
start_dir = Path(str_path).parent
|
|
733
|
+
except Exception:
|
|
734
|
+
start_dir = None
|
|
735
|
+
if start_dir is not None:
|
|
736
|
+
probe = start_dir
|
|
737
|
+
# Walk up a few levels so requires from nested files still resolve when
|
|
738
|
+
# the package root is above the importing file.
|
|
739
|
+
for _ in range(6):
|
|
740
|
+
for suffix in (".lua", ".luau"):
|
|
741
|
+
cand = probe / f"{rel}{suffix}"
|
|
742
|
+
if cand.is_file():
|
|
743
|
+
return _make_id(str(cand))
|
|
744
|
+
for suffix in (".lua", ".luau"):
|
|
745
|
+
cand = probe / rel / f"init{suffix}"
|
|
746
|
+
if cand.is_file():
|
|
747
|
+
return _make_id(str(cand))
|
|
748
|
+
if probe.parent == probe:
|
|
749
|
+
break
|
|
750
|
+
probe = probe.parent
|
|
751
|
+
return _make_id(raw_module)
|
|
752
|
+
|
|
753
|
+
_VUE_SCRIPT_RE = re.compile(
|
|
754
|
+
r"""(<script\b(?:"[^"]*"|'[^']*'|[^>"'])*>)([\s\S]*?)(</script\s*>)""",
|
|
755
|
+
re.IGNORECASE,
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
_VUE_SCRIPT_LANG_RE = re.compile(
|
|
759
|
+
r"""\blang\s*=\s*['"]?([A-Za-z]+)['"]?""", re.IGNORECASE
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
def _vue_mask_non_script(src: str) -> tuple[str, str | None]:
|
|
763
|
+
"""Blank everything outside ``<script>`` bodies, keeping ``\\r``/``\\n``.
|
|
764
|
+
|
|
765
|
+
Replaces template/style/tags with spaces so a JS/TS grammar sees only the
|
|
766
|
+
script, while preserved newlines keep line numbers accurate. Returns
|
|
767
|
+
``(masked_source, lang)``; ``lang`` is the first block's declared ``lang``.
|
|
768
|
+
"""
|
|
769
|
+
def _blank(s: str) -> str:
|
|
770
|
+
return re.sub(r"[^\r\n]", " ", s)
|
|
771
|
+
|
|
772
|
+
out: list[str] = []
|
|
773
|
+
pos = 0
|
|
774
|
+
lang: str | None = None
|
|
775
|
+
for m in _VUE_SCRIPT_RE.finditer(src):
|
|
776
|
+
out.append(_blank(src[pos:m.start()])) # markup/style before this block
|
|
777
|
+
out.append(_blank(m.group(1))) # <script …> open tag
|
|
778
|
+
out.append(m.group(2)) # script body, verbatim
|
|
779
|
+
out.append(_blank(m.group(3))) # </script> close tag
|
|
780
|
+
pos = m.end()
|
|
781
|
+
if lang is None:
|
|
782
|
+
lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1))
|
|
783
|
+
if lang_m:
|
|
784
|
+
lang = lang_m.group(1).lower()
|
|
785
|
+
out.append(_blank(src[pos:]))
|
|
786
|
+
return "".join(out), lang
|
|
787
|
+
|
|
788
|
+
@functools.lru_cache(maxsize=65536)
|
|
789
|
+
def _cached_source_key(source_file: str, root_str: str, _cwd: str) -> str:
|
|
790
|
+
"""Resolve-and-relativize one source path, memoized (#perf).
|
|
791
|
+
|
|
792
|
+
``_resolve_cached(Path)`` walks the path through ``nt._getfinalpathname`` /
|
|
793
|
+
``readlink`` syscalls, and ``_disambiguate_colliding_node_ids`` calls
|
|
794
|
+
``_source_key`` once per node, per edge endpoint and per raw_call — tens
|
|
795
|
+
of thousands of calls for a few hundred distinct ``source_file`` strings.
|
|
796
|
+
On a 364-file corpus this pass alone spent 15s (34% of a sequential
|
|
797
|
+
extract) re-resolving the same strings. ``_cwd`` is part of the key
|
|
798
|
+
because a relative ``source_file`` resolves against the current working
|
|
799
|
+
directory, so a chdir between calls must miss the cache rather than
|
|
800
|
+
replay a stale resolution.
|
|
801
|
+
"""
|
|
802
|
+
source_path = Path(source_file)
|
|
803
|
+
try:
|
|
804
|
+
return str(source_path.resolve().relative_to(root_str))
|
|
805
|
+
except Exception:
|
|
806
|
+
return str(source_path)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _source_key(source_file: str, root: Path) -> str:
|
|
810
|
+
if not source_file:
|
|
811
|
+
return ""
|
|
812
|
+
return _cached_source_key(source_file, str(root), os.getcwd())
|
|
813
|
+
|
|
814
|
+
def _node_disambiguation_source_key(node: dict, root: Path) -> str:
|
|
815
|
+
source_file = str(node.get("source_file", ""))
|
|
816
|
+
if source_file:
|
|
817
|
+
return _source_key(source_file, root)
|
|
818
|
+
return _source_key(str(node.get("origin_file", "")), root)
|
|
819
|
+
|
|
820
|
+
def _disambiguate_colliding_node_ids(
|
|
821
|
+
nodes: list[dict],
|
|
822
|
+
edges: list[dict],
|
|
823
|
+
raw_calls: list[dict],
|
|
824
|
+
root: Path,
|
|
825
|
+
) -> None:
|
|
826
|
+
"""Rewrite only colliding node IDs, using source path as the disambiguator.
|
|
827
|
+
|
|
828
|
+
Module anchor nodes (#1327) are exempt: ``import CoreKit`` from three files
|
|
829
|
+
yields three ``type=module`` nodes with the same id but different
|
|
830
|
+
source_files. Those are the *same* module, not distinct same-named symbols,
|
|
831
|
+
so they must collapse to one shared node — disambiguating them by path would
|
|
832
|
+
scatter a single module across N file-qualified duplicates.
|
|
833
|
+
"""
|
|
834
|
+
by_id: dict[str, list[dict]] = {}
|
|
835
|
+
for node in nodes:
|
|
836
|
+
if node.get("type") in ("module", "namespace"):
|
|
837
|
+
continue
|
|
838
|
+
nid = node.get("id")
|
|
839
|
+
if isinstance(nid, str) and nid:
|
|
840
|
+
by_id.setdefault(nid, []).append(node)
|
|
841
|
+
|
|
842
|
+
remap: dict[tuple[str, str], str] = {}
|
|
843
|
+
ambiguous_ids: set[str] = set()
|
|
844
|
+
for old_id, group in by_id.items():
|
|
845
|
+
source_keys = {_node_disambiguation_source_key(node, root) for node in group}
|
|
846
|
+
if len(group) < 2 or len(source_keys) < 2:
|
|
847
|
+
continue
|
|
848
|
+
ambiguous_ids.add(old_id)
|
|
849
|
+
# Salt the colliding id with the *path* it came from. The naive salt is
|
|
850
|
+
# ``_make_id(source_key, old_id)`` — source_key is the raw repo-relative
|
|
851
|
+
# path. But _make_id collapses every separator, so two DISTINCT paths
|
|
852
|
+
# whose only difference is a separator-vs-inner-punctuation swap
|
|
853
|
+
# (``a/b/c.md`` vs ``a.b/c.md``, ``foo/bar_baz.md`` vs ``foo_bar/baz.md``)
|
|
854
|
+
# normalize to the SAME salted id and still collide (#1522 — the residual
|
|
855
|
+
# of #1504 the 0.9.0 full-path stem didn't reach). When that happens,
|
|
856
|
+
# append a short stable hash of the *raw* source_key, which IS injective
|
|
857
|
+
# over distinct paths, so the colliders separate. Computed in code from
|
|
858
|
+
# source_file (never trusted from the LLM), so AST↔semantic parity holds.
|
|
859
|
+
naive: dict[str, str] = {} # source_key -> _make_id(source_key, old_id)
|
|
860
|
+
for source_key in source_keys:
|
|
861
|
+
if source_key:
|
|
862
|
+
naive[source_key] = _make_id(source_key, old_id)
|
|
863
|
+
# source_keys that, after normalization, are not unique among themselves.
|
|
864
|
+
seen: dict[str, int] = {}
|
|
865
|
+
for nid in naive.values():
|
|
866
|
+
seen[nid] = seen.get(nid, 0) + 1
|
|
867
|
+
needs_hash = {sk for sk, nid in naive.items() if seen.get(nid, 0) > 1}
|
|
868
|
+
for node in group:
|
|
869
|
+
source_key = _node_disambiguation_source_key(node, root)
|
|
870
|
+
if not source_key:
|
|
871
|
+
continue
|
|
872
|
+
if source_key in needs_hash:
|
|
873
|
+
salt = hashlib.sha1(source_key.encode("utf-8")).hexdigest()[:6]
|
|
874
|
+
new_id = _make_id(source_key, old_id, salt)
|
|
875
|
+
else:
|
|
876
|
+
new_id = naive.get(source_key) or _make_id(source_key, old_id)
|
|
877
|
+
remap[(old_id, source_key)] = new_id
|
|
878
|
+
if new_id != old_id:
|
|
879
|
+
node["id"] = new_id
|
|
880
|
+
|
|
881
|
+
if not remap:
|
|
882
|
+
# No colliding ids to salt apart, but the transient `target_file` hint an
|
|
883
|
+
# importer stamps on every resolved import (#1814) still has to be dropped
|
|
884
|
+
# here — this early exit skips the edge loop below, so without it a
|
|
885
|
+
# non-colliding import would carry its absolute path into graph.json.
|
|
886
|
+
for edge in edges:
|
|
887
|
+
edge.pop("target_file", None)
|
|
888
|
+
return
|
|
889
|
+
|
|
890
|
+
unambiguous_remaps: dict[str, str] = {}
|
|
891
|
+
for old_id, group in by_id.items():
|
|
892
|
+
if old_id in ambiguous_ids:
|
|
893
|
+
continue
|
|
894
|
+
candidates = {
|
|
895
|
+
node["id"] for node in group
|
|
896
|
+
if isinstance(node.get("id"), str) and node["id"] != old_id
|
|
897
|
+
}
|
|
898
|
+
if len(candidates) == 1:
|
|
899
|
+
unambiguous_remaps[old_id] = next(iter(candidates))
|
|
900
|
+
|
|
901
|
+
# A C/ObjC/C++ `#include "foo.h"` / `#import "foo.h"` resolves to the header's
|
|
902
|
+
# file node, but `foo.h` and its sibling `foo.c`/`foo.m`/`foo.cpp` collapse to
|
|
903
|
+
# the same `foo` file id, so disambiguation salts them apart by path. A
|
|
904
|
+
# cross-file import edge from a THIRD file carries neither salt's source_key, so
|
|
905
|
+
# the (target, edge_source_key) lookup misses and the edge dangles on the now
|
|
906
|
+
# dead `foo` id. Repoint those import edges to the HEADER variant (the include
|
|
907
|
+
# always targeted the header), keyed by the original colliding id (#1475).
|
|
908
|
+
_HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx")
|
|
909
|
+
header_remaps: dict[str, str] = {}
|
|
910
|
+
for old_id in ambiguous_ids:
|
|
911
|
+
for node in by_id.get(old_id, []):
|
|
912
|
+
sk = _node_disambiguation_source_key(node, root)
|
|
913
|
+
if sk and Path(sk).suffix.lower() in _HEADER_SUFFIXES:
|
|
914
|
+
new_id = remap.get((old_id, sk))
|
|
915
|
+
if new_id:
|
|
916
|
+
header_remaps[old_id] = new_id
|
|
917
|
+
break
|
|
918
|
+
|
|
919
|
+
for edge in edges:
|
|
920
|
+
edge_source_key = _source_key(str(edge.get("source_file", "")), root)
|
|
921
|
+
source_key = (edge.get("source", ""), edge_source_key)
|
|
922
|
+
# An import/re-export edge's target is a FILE node that can collapse with a
|
|
923
|
+
# same-basename cross-extension sibling (foo.ts vs foo.mjs, #1814). Keying
|
|
924
|
+
# its target salt by the IMPORTER's own source_file mis-points it back at the
|
|
925
|
+
# importer's variant (a self-loop). When the emitter stamped the resolved
|
|
926
|
+
# target file, key the target salt by THAT file so the salt lands on the
|
|
927
|
+
# correct sibling. Generalizes the #1475 C/ObjC header carve-out (below) to
|
|
928
|
+
# every language and to re_exports. `pop` it as we consume it: this is the
|
|
929
|
+
# hint's only reader, and its absolute path must not persist into graph.json.
|
|
930
|
+
target_file = edge.pop("target_file", None)
|
|
931
|
+
if target_file and edge.get("relation") in ("imports", "imports_from", "re_exports"):
|
|
932
|
+
target_edge_key = _source_key(str(target_file), root)
|
|
933
|
+
else:
|
|
934
|
+
target_edge_key = edge_source_key
|
|
935
|
+
target_key = (edge.get("target", ""), target_edge_key)
|
|
936
|
+
if source_key in remap:
|
|
937
|
+
edge["source"] = remap[source_key]
|
|
938
|
+
elif edge.get("source") in unambiguous_remaps:
|
|
939
|
+
edge["source"] = unambiguous_remaps[str(edge["source"])]
|
|
940
|
+
# imports/imports_from always target a header file, so they must resolve to
|
|
941
|
+
# the header variant BEFORE the same-source-file salt is considered. Keying
|
|
942
|
+
# the import target by the importer's own source file mis-points a `.m`
|
|
943
|
+
# importing its own `.h` back at itself (self-loop), and is wrong for any
|
|
944
|
+
# cross-file import whose importer shares the colliding id (#1475).
|
|
945
|
+
if (edge.get("relation") in ("imports", "imports_from")
|
|
946
|
+
and edge.get("target") in header_remaps):
|
|
947
|
+
edge["target"] = header_remaps[str(edge["target"])]
|
|
948
|
+
elif target_key in remap:
|
|
949
|
+
edge["target"] = remap[target_key]
|
|
950
|
+
elif edge.get("target") in unambiguous_remaps:
|
|
951
|
+
edge["target"] = unambiguous_remaps[str(edge["target"])]
|
|
952
|
+
|
|
953
|
+
for raw_call in raw_calls:
|
|
954
|
+
call_source_key = _source_key(str(raw_call.get("source_file", "")), root)
|
|
955
|
+
caller_key = (raw_call.get("caller_nid", ""), call_source_key)
|
|
956
|
+
if caller_key in remap:
|
|
957
|
+
raw_call["caller_nid"] = remap[caller_key]
|
|
958
|
+
elif raw_call.get("caller_nid") in unambiguous_remaps:
|
|
959
|
+
raw_call["caller_nid"] = unambiguous_remaps[str(raw_call["caller_nid"])]
|
|
960
|
+
|
|
961
|
+
def _is_type_like_definition(node: dict) -> bool:
|
|
962
|
+
if node.get("type") == "namespace":
|
|
963
|
+
return False
|
|
964
|
+
label = str(node.get("label", "")).strip()
|
|
965
|
+
if not label:
|
|
966
|
+
return False
|
|
967
|
+
if label.endswith(")") or label.startswith("."):
|
|
968
|
+
return False
|
|
969
|
+
if "." in label:
|
|
970
|
+
return False
|
|
971
|
+
return node.get("file_type") == "code"
|
|
972
|
+
|
|
973
|
+
@functools.lru_cache(maxsize=65536)
|
|
974
|
+
def _cached_realpath(path_str: str, _cwd: str) -> Path:
|
|
975
|
+
return Path(path_str).resolve()
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def _resolve_cached(path: "Path | str") -> Path:
|
|
979
|
+
"""``Path.resolve()`` with a per-(path, cwd) memo (#perf).
|
|
980
|
+
|
|
981
|
+
The symbol-resolution passes resolve the same few hundred corpus paths
|
|
982
|
+
once per FACT — per import, per export, per use, per node — which on
|
|
983
|
+
Windows walks ``nt._getfinalpathname`` every time: 44k resolve calls and
|
|
984
|
+
~15s of a sequential 364-file extract after the source-key memo alone.
|
|
985
|
+
Results are stable within a run (files do not move mid-extract); the cwd
|
|
986
|
+
is part of the key so a relative path resolved after a chdir misses the
|
|
987
|
+
cache instead of replaying a stale answer. Raises exactly like
|
|
988
|
+
``Path.resolve()`` — callers keep their own try/except — and an
|
|
989
|
+
exception is never cached.
|
|
990
|
+
"""
|
|
991
|
+
return _cached_realpath(str(path), os.getcwd())
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _js_source_path(source_file: str, root: Path) -> Path | None:
|
|
995
|
+
if not source_file:
|
|
996
|
+
return None
|
|
997
|
+
path = Path(source_file)
|
|
998
|
+
if not path.is_absolute():
|
|
999
|
+
path = root / path
|
|
1000
|
+
try:
|
|
1001
|
+
return _resolve_cached(path)
|
|
1002
|
+
except Exception:
|
|
1003
|
+
return path
|
|
1004
|
+
|
|
1005
|
+
def _apply_symbol_resolution_facts(
|
|
1006
|
+
paths: list[Path],
|
|
1007
|
+
nodes: list[dict],
|
|
1008
|
+
edges: list[dict],
|
|
1009
|
+
root: Path,
|
|
1010
|
+
facts: _SymbolResolutionFacts,
|
|
1011
|
+
) -> None:
|
|
1012
|
+
"""Apply language-provided import/export/use facts to graph edges."""
|
|
1013
|
+
if not (
|
|
1014
|
+
facts.declarations
|
|
1015
|
+
or facts.imports
|
|
1016
|
+
or facts.aliases
|
|
1017
|
+
or facts.exports
|
|
1018
|
+
or facts.star_exports
|
|
1019
|
+
or facts.namespace_exports
|
|
1020
|
+
or facts.uses
|
|
1021
|
+
or facts.module_imports
|
|
1022
|
+
):
|
|
1023
|
+
return
|
|
1024
|
+
|
|
1025
|
+
path_by_resolved = {_resolve_cached(path): path for path in paths}
|
|
1026
|
+
source_file_id = {_resolve_cached(path): _make_id(str(path)) for path in paths}
|
|
1027
|
+
symbol_nodes: dict[tuple[Path, str], str] = {}
|
|
1028
|
+
# Member nodes (`.method()` labels) share their bare name with top-level
|
|
1029
|
+
# symbols once the leading dot is stripped. A module can only re-export
|
|
1030
|
+
# top-level bindings, so a star-export walk must never bind an imported
|
|
1031
|
+
# name to a class/interface member (#3436). Track those keys separately
|
|
1032
|
+
# and never let a member shadow a same-named top-level symbol.
|
|
1033
|
+
member_symbol_keys: set[tuple[Path, str]] = set()
|
|
1034
|
+
for node in nodes:
|
|
1035
|
+
source_path = _js_source_path(str(node.get("source_file", "")), root)
|
|
1036
|
+
if source_path is None:
|
|
1037
|
+
continue
|
|
1038
|
+
raw_label = str(node.get("label", "")).strip()
|
|
1039
|
+
label = raw_label.strip("()").lstrip(".")
|
|
1040
|
+
if not label or not node.get("id"):
|
|
1041
|
+
continue
|
|
1042
|
+
key = (source_path, label)
|
|
1043
|
+
if raw_label.startswith("."):
|
|
1044
|
+
if key in symbol_nodes:
|
|
1045
|
+
continue
|
|
1046
|
+
member_symbol_keys.add(key)
|
|
1047
|
+
else:
|
|
1048
|
+
member_symbol_keys.discard(key)
|
|
1049
|
+
symbol_nodes[key] = str(node["id"])
|
|
1050
|
+
|
|
1051
|
+
def ensure_symbol_node(path: Path, name: str, line: int) -> str:
|
|
1052
|
+
resolved_path = _resolve_cached(path)
|
|
1053
|
+
existing = symbol_nodes.get((resolved_path, name))
|
|
1054
|
+
if existing is not None:
|
|
1055
|
+
return existing
|
|
1056
|
+
node_id = _make_id(_file_stem(path), name)
|
|
1057
|
+
symbol_nodes[(resolved_path, name)] = node_id
|
|
1058
|
+
nodes.append({
|
|
1059
|
+
"id": node_id,
|
|
1060
|
+
"label": name,
|
|
1061
|
+
"file_type": "code",
|
|
1062
|
+
"source_file": str(path),
|
|
1063
|
+
"source_location": f"L{line}",
|
|
1064
|
+
})
|
|
1065
|
+
return node_id
|
|
1066
|
+
|
|
1067
|
+
existing_edges = {
|
|
1068
|
+
(
|
|
1069
|
+
str(edge.get("source")),
|
|
1070
|
+
str(edge.get("target")),
|
|
1071
|
+
str(edge.get("relation")),
|
|
1072
|
+
str(edge.get("context") or ""),
|
|
1073
|
+
)
|
|
1074
|
+
for edge in edges
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path, target_file: str | None = None, local_alias: str | None = None, type_only: bool = False) -> None:
|
|
1078
|
+
key = (source, target, relation, context or "")
|
|
1079
|
+
if key in existing_edges:
|
|
1080
|
+
return
|
|
1081
|
+
existing_edges.add(key)
|
|
1082
|
+
edge = {
|
|
1083
|
+
"source": source,
|
|
1084
|
+
"target": target,
|
|
1085
|
+
"relation": relation,
|
|
1086
|
+
"context": context,
|
|
1087
|
+
"confidence": "EXTRACTED",
|
|
1088
|
+
"source_file": str(source_path),
|
|
1089
|
+
"source_location": f"L{line}",
|
|
1090
|
+
"weight": 1.0,
|
|
1091
|
+
}
|
|
1092
|
+
# A re-export edge's target is a FILE node that can collapse with a
|
|
1093
|
+
# same-basename cross-extension sibling; stamp the resolved target file so
|
|
1094
|
+
# the id-disambiguation salt is keyed by the TARGET, not the importer (#1814).
|
|
1095
|
+
if target_file is not None:
|
|
1096
|
+
edge["target_file"] = target_file
|
|
1097
|
+
# The local name this import bound in the importing file, when it differs
|
|
1098
|
+
# from the target's own name (`from pkg import mod as alias`) -- lets the
|
|
1099
|
+
# cross-file member-call resolver match `alias.func()` (#2082).
|
|
1100
|
+
if local_alias is not None:
|
|
1101
|
+
edge["local_alias"] = local_alias
|
|
1102
|
+
# Erased at compile time (#3123): Import Cycles skips these edges.
|
|
1103
|
+
if type_only:
|
|
1104
|
+
edge["type_only"] = True
|
|
1105
|
+
edges.append(edge)
|
|
1106
|
+
|
|
1107
|
+
for declaration in facts.declarations:
|
|
1108
|
+
ensure_symbol_node(declaration.file_path, declaration.name, declaration.line)
|
|
1109
|
+
|
|
1110
|
+
local_aliases_by_file: dict[Path, dict[str, tuple[Path, str]]] = {}
|
|
1111
|
+
for import_fact in facts.imports:
|
|
1112
|
+
file_path = _resolve_cached(import_fact.file_path)
|
|
1113
|
+
local_aliases_by_file.setdefault(file_path, {})[import_fact.local_name] = (
|
|
1114
|
+
_resolve_cached(import_fact.target_path),
|
|
1115
|
+
import_fact.imported_name,
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
pending_aliases_by_file: dict[Path, list[_SymbolAliasFact]] = {}
|
|
1119
|
+
for alias_fact in facts.aliases:
|
|
1120
|
+
pending_aliases_by_file.setdefault(_resolve_cached(alias_fact.file_path), []).append(alias_fact)
|
|
1121
|
+
|
|
1122
|
+
for file_path, aliases in pending_aliases_by_file.items():
|
|
1123
|
+
local_aliases = local_aliases_by_file.setdefault(file_path, {})
|
|
1124
|
+
changed = True
|
|
1125
|
+
while changed:
|
|
1126
|
+
changed = False
|
|
1127
|
+
for alias_fact in aliases:
|
|
1128
|
+
if alias_fact.alias in local_aliases:
|
|
1129
|
+
continue
|
|
1130
|
+
origin = local_aliases.get(alias_fact.target_name)
|
|
1131
|
+
if origin is not None:
|
|
1132
|
+
local_aliases[alias_fact.alias] = origin
|
|
1133
|
+
changed = True
|
|
1134
|
+
|
|
1135
|
+
named_exports_by_file: dict[Path, dict[str, tuple[Path, str]]] = {}
|
|
1136
|
+
export_origins: dict[tuple[Path, str], set[tuple[Path, str]]] = {}
|
|
1137
|
+
star_exports_by_file: dict[Path, list[Path]] = {}
|
|
1138
|
+
|
|
1139
|
+
for star_fact in facts.star_exports:
|
|
1140
|
+
source_path = _resolve_cached(star_fact.file_path)
|
|
1141
|
+
target_path = _resolve_cached(star_fact.target_path)
|
|
1142
|
+
star_exports_by_file.setdefault(source_path, []).append(target_path)
|
|
1143
|
+
source_id = source_file_id.get(source_path)
|
|
1144
|
+
if source_id is not None:
|
|
1145
|
+
add_edge(
|
|
1146
|
+
source_id,
|
|
1147
|
+
_make_id(str(path_by_resolved.get(target_path, target_path))),
|
|
1148
|
+
"re_exports",
|
|
1149
|
+
"export",
|
|
1150
|
+
star_fact.line,
|
|
1151
|
+
star_fact.file_path,
|
|
1152
|
+
target_file=str(path_by_resolved.get(target_path, target_path)),
|
|
1153
|
+
type_only=star_fact.type_only,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
for namespace_fact in facts.namespace_exports:
|
|
1157
|
+
source_path = _resolve_cached(namespace_fact.file_path)
|
|
1158
|
+
target_path = _resolve_cached(namespace_fact.target_path)
|
|
1159
|
+
namespace_id = ensure_symbol_node(
|
|
1160
|
+
namespace_fact.file_path,
|
|
1161
|
+
namespace_fact.exported_name,
|
|
1162
|
+
namespace_fact.line,
|
|
1163
|
+
)
|
|
1164
|
+
named_exports_by_file.setdefault(source_path, {})[
|
|
1165
|
+
namespace_fact.exported_name
|
|
1166
|
+
] = (source_path, namespace_fact.exported_name)
|
|
1167
|
+
source_id = source_file_id.get(source_path)
|
|
1168
|
+
if source_id is not None:
|
|
1169
|
+
add_edge(
|
|
1170
|
+
source_id,
|
|
1171
|
+
namespace_id,
|
|
1172
|
+
"contains",
|
|
1173
|
+
"namespace_export",
|
|
1174
|
+
namespace_fact.line,
|
|
1175
|
+
namespace_fact.file_path,
|
|
1176
|
+
)
|
|
1177
|
+
add_edge(
|
|
1178
|
+
source_id,
|
|
1179
|
+
_make_id(str(path_by_resolved.get(target_path, target_path))),
|
|
1180
|
+
"re_exports",
|
|
1181
|
+
"export",
|
|
1182
|
+
namespace_fact.line,
|
|
1183
|
+
namespace_fact.file_path,
|
|
1184
|
+
target_file=str(path_by_resolved.get(target_path, target_path)),
|
|
1185
|
+
type_only=namespace_fact.type_only,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
for export_fact in facts.exports:
|
|
1189
|
+
file_path = _resolve_cached(export_fact.file_path)
|
|
1190
|
+
origin: tuple[Path, str] | None = None
|
|
1191
|
+
if export_fact.target_path is not None and export_fact.target_name is not None:
|
|
1192
|
+
origin = (_resolve_cached(export_fact.target_path), export_fact.target_name)
|
|
1193
|
+
elif export_fact.local_name is not None:
|
|
1194
|
+
origin = local_aliases_by_file.get(file_path, {}).get(export_fact.local_name)
|
|
1195
|
+
if origin is None and (file_path, export_fact.local_name) in symbol_nodes:
|
|
1196
|
+
origin = (file_path, export_fact.local_name)
|
|
1197
|
+
if origin is None:
|
|
1198
|
+
# An explicit export remains evidence even when extraction did not
|
|
1199
|
+
# materialize its binding. Do not mistake it for an absent export.
|
|
1200
|
+
if export_fact.local_name is not None:
|
|
1201
|
+
export_origins.setdefault((file_path, export_fact.exported_name), set()).add(
|
|
1202
|
+
(file_path, export_fact.local_name)
|
|
1203
|
+
)
|
|
1204
|
+
continue
|
|
1205
|
+
export_origins.setdefault((file_path, export_fact.exported_name), set()).add(origin)
|
|
1206
|
+
named_exports_by_file.setdefault(file_path, {})[export_fact.exported_name] = origin
|
|
1207
|
+
if origin[0] != file_path:
|
|
1208
|
+
source_id = source_file_id.get(file_path)
|
|
1209
|
+
if source_id is not None:
|
|
1210
|
+
add_edge(
|
|
1211
|
+
source_id,
|
|
1212
|
+
_make_id(str(path_by_resolved.get(origin[0], origin[0]))),
|
|
1213
|
+
"re_exports",
|
|
1214
|
+
"export",
|
|
1215
|
+
export_fact.line,
|
|
1216
|
+
export_fact.file_path,
|
|
1217
|
+
target_file=str(path_by_resolved.get(origin[0], origin[0])),
|
|
1218
|
+
type_only=export_fact.type_only,
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tuple[Path, str]] | None = None) -> tuple[Path, str]:
|
|
1222
|
+
target_path = _resolve_cached(target_path)
|
|
1223
|
+
key = (target_path, imported_name)
|
|
1224
|
+
if seen is None:
|
|
1225
|
+
seen = set()
|
|
1226
|
+
if key in seen:
|
|
1227
|
+
return key
|
|
1228
|
+
seen.add(key)
|
|
1229
|
+
origin = named_exports_by_file.get(target_path, {}).get(imported_name)
|
|
1230
|
+
if origin is not None:
|
|
1231
|
+
return resolve_exported_origin(origin[0], origin[1], seen)
|
|
1232
|
+
for star_target in star_exports_by_file.get(target_path, []):
|
|
1233
|
+
star_key = (star_target, imported_name)
|
|
1234
|
+
if star_key in symbol_nodes and star_key not in member_symbol_keys:
|
|
1235
|
+
return star_key
|
|
1236
|
+
resolved = resolve_exported_origin(star_target, imported_name, seen)
|
|
1237
|
+
if resolved in symbol_nodes and resolved not in member_symbol_keys:
|
|
1238
|
+
return resolved
|
|
1239
|
+
return key
|
|
1240
|
+
|
|
1241
|
+
def exported_candidates(
|
|
1242
|
+
key: tuple[Path, str], seen: frozenset[tuple[Path, str]]
|
|
1243
|
+
) -> tuple[set[tuple[Path, str]], bool]:
|
|
1244
|
+
# Distinguish a cycle cutoff from an absent export. A cycle contributes
|
|
1245
|
+
# no origin; a named export of an unrepresented binding remains unknown.
|
|
1246
|
+
if key in seen:
|
|
1247
|
+
return set(), True
|
|
1248
|
+
seen = seen | {key}
|
|
1249
|
+
origins = export_origins.get(key)
|
|
1250
|
+
candidates: set[tuple[Path, str]] = set()
|
|
1251
|
+
cyclic = False
|
|
1252
|
+
if origins is None:
|
|
1253
|
+
for path in star_exports_by_file.get(key[0], []):
|
|
1254
|
+
branch, branch_cyclic = exported_candidates((path, key[1]), seen)
|
|
1255
|
+
candidates.update(branch)
|
|
1256
|
+
cyclic |= branch_cyclic
|
|
1257
|
+
return candidates, cyclic
|
|
1258
|
+
for origin in origins:
|
|
1259
|
+
if origin == key:
|
|
1260
|
+
candidates.add(origin)
|
|
1261
|
+
else:
|
|
1262
|
+
branch, branch_cyclic = exported_candidates(origin, seen)
|
|
1263
|
+
candidates.update(branch)
|
|
1264
|
+
cyclic |= branch_cyclic
|
|
1265
|
+
if not branch and not branch_cyclic:
|
|
1266
|
+
candidates.add(origin)
|
|
1267
|
+
return candidates, cyclic
|
|
1268
|
+
|
|
1269
|
+
# The structural extractor names the immediate barrel's symbol. Resolve
|
|
1270
|
+
# that exact authored export site through the shared import/export facts,
|
|
1271
|
+
# including `import {x}; export {x}` bridges with no local declaration.
|
|
1272
|
+
export_sites: dict[tuple[Path, str, Path], list[_SymbolExportFact]] = {}
|
|
1273
|
+
for export_fact in facts.exports:
|
|
1274
|
+
if export_fact.target_path is not None and export_fact.target_name is not None:
|
|
1275
|
+
site = (
|
|
1276
|
+
_resolve_cached(export_fact.file_path),
|
|
1277
|
+
f"L{export_fact.line}",
|
|
1278
|
+
_resolve_cached(export_fact.target_path),
|
|
1279
|
+
)
|
|
1280
|
+
export_sites.setdefault(site, []).append(export_fact)
|
|
1281
|
+
owned_ids = {node.get("id") for node in nodes}
|
|
1282
|
+
for edge in edges:
|
|
1283
|
+
if edge.get("relation") != "re_exports" or edge.get("target") in owned_ids:
|
|
1284
|
+
continue
|
|
1285
|
+
target_file = edge.get("target_file")
|
|
1286
|
+
source_path = _js_source_path(str(edge.get("source_file", "")), root)
|
|
1287
|
+
if not target_file or source_path is None:
|
|
1288
|
+
continue
|
|
1289
|
+
target_path = Path(target_file)
|
|
1290
|
+
site = (source_path, str(edge.get("source_location", "")), _resolve_cached(target_path))
|
|
1291
|
+
for export_fact in export_sites.get(site, []):
|
|
1292
|
+
expected = _make_id(_file_stem(target_path), export_fact.target_name)
|
|
1293
|
+
if edge.get("target") != expected:
|
|
1294
|
+
continue
|
|
1295
|
+
candidates, _ = exported_candidates(
|
|
1296
|
+
(_resolve_cached(export_fact.target_path), export_fact.target_name), frozenset()
|
|
1297
|
+
)
|
|
1298
|
+
if len(candidates) == 1:
|
|
1299
|
+
origin = next(iter(candidates))
|
|
1300
|
+
target_id = symbol_nodes.get(origin)
|
|
1301
|
+
if target_id in owned_ids:
|
|
1302
|
+
edge["target"] = target_id
|
|
1303
|
+
edge["target_file"] = str(path_by_resolved.get(origin[0], origin[0]))
|
|
1304
|
+
break
|
|
1305
|
+
|
|
1306
|
+
for import_fact in facts.imports:
|
|
1307
|
+
source_id = source_file_id.get(_resolve_cached(import_fact.file_path))
|
|
1308
|
+
if source_id is None:
|
|
1309
|
+
continue
|
|
1310
|
+
origin_path, origin_symbol = resolve_exported_origin(
|
|
1311
|
+
import_fact.target_path,
|
|
1312
|
+
import_fact.imported_name,
|
|
1313
|
+
)
|
|
1314
|
+
target_id = symbol_nodes.get((origin_path, origin_symbol))
|
|
1315
|
+
if target_id is None:
|
|
1316
|
+
continue
|
|
1317
|
+
add_edge(
|
|
1318
|
+
source_id,
|
|
1319
|
+
target_id,
|
|
1320
|
+
"imports",
|
|
1321
|
+
"import",
|
|
1322
|
+
import_fact.line,
|
|
1323
|
+
import_fact.file_path,
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
# #1146: emit file-to-file imports_from edges for package-form submodule imports.
|
|
1327
|
+
for from_path, to_path, line, local_name in facts.module_imports:
|
|
1328
|
+
try:
|
|
1329
|
+
from_rel = from_path.relative_to(root)
|
|
1330
|
+
to_rel = to_path.relative_to(root)
|
|
1331
|
+
except ValueError:
|
|
1332
|
+
continue
|
|
1333
|
+
source_id = _make_id(_file_stem(from_rel))
|
|
1334
|
+
target_id = _make_id(_file_stem(to_rel))
|
|
1335
|
+
add_edge(
|
|
1336
|
+
source_id, target_id, "imports_from", "submodule_import", line, from_path,
|
|
1337
|
+
local_alias=local_name if local_name != to_path.stem else None,
|
|
1338
|
+
)
|
|
1339
|
+
|
|
1340
|
+
# #2262 producer guard: never emit a `calls` use-edge from a source id
|
|
1341
|
+
# that owns no node. All node appends (ensure_symbol_node, declarations,
|
|
1342
|
+
# namespace exports) happened above, so the owned set is complete here.
|
|
1343
|
+
# A node-less caller id can never be canonicalized by the extract()
|
|
1344
|
+
# remaps (they learn only from nodes), so an absolute-derived one would
|
|
1345
|
+
# leak the machine/scan-path slug into the edge source. Reattribute the
|
|
1346
|
+
# edge to the caller's FILE node — the true file-level dependency
|
|
1347
|
+
# survives, and the file id is exactly what the #2231 remap
|
|
1348
|
+
# canonicalizes — or drop it when no file node id is available.
|
|
1349
|
+
owned = {str(n.get("id")) for n in nodes}
|
|
1350
|
+
for use_fact in facts.uses:
|
|
1351
|
+
file_path = _resolve_cached(use_fact.file_path)
|
|
1352
|
+
target_id = None
|
|
1353
|
+
unresolved_origin = local_aliases_by_file.get(file_path, {}).get(use_fact.local_name)
|
|
1354
|
+
if unresolved_origin is not None:
|
|
1355
|
+
origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin)
|
|
1356
|
+
target_id = symbol_nodes.get((origin_path, origin_symbol))
|
|
1357
|
+
if target_id is None and use_fact.relation in ("inherits", "implements"):
|
|
1358
|
+
# Same-file fallback for HERITAGE only: a base declared in the same
|
|
1359
|
+
# file (`class X extends Y`, `interface A extends B`) has no import
|
|
1360
|
+
# alias, so resolve it directly against the file's own symbol nodes.
|
|
1361
|
+
# Scoped to heritage because same-file calls/uses already resolve via
|
|
1362
|
+
# the dedicated call-graph pass; widening this would duplicate those
|
|
1363
|
+
# edges. Import resolution still takes precedence (#1095).
|
|
1364
|
+
target_id = symbol_nodes.get((file_path, use_fact.local_name))
|
|
1365
|
+
if target_id is None:
|
|
1366
|
+
continue
|
|
1367
|
+
source_id = use_fact.source_id
|
|
1368
|
+
# Structural walking can omit named-function-local declarations while
|
|
1369
|
+
# materializing callback-local ones. Only actual node ownership decides
|
|
1370
|
+
# whether a type relationship has a represented source declaration.
|
|
1371
|
+
if use_fact.relation in ("inherits", "implements", "references") and source_id not in owned:
|
|
1372
|
+
continue
|
|
1373
|
+
if use_fact.relation == "calls" and source_id not in owned:
|
|
1374
|
+
source_id = source_file_id.get(file_path)
|
|
1375
|
+
if source_id is None:
|
|
1376
|
+
continue
|
|
1377
|
+
add_edge(
|
|
1378
|
+
source_id,
|
|
1379
|
+
target_id,
|
|
1380
|
+
use_fact.relation,
|
|
1381
|
+
use_fact.context,
|
|
1382
|
+
use_fact.line,
|
|
1383
|
+
use_fact.file_path,
|
|
1384
|
+
)
|
|
1385
|
+
|
|
1386
|
+
def _parse_js_tree(path: Path):
|
|
1387
|
+
try:
|
|
1388
|
+
from tree_sitter import Language, Parser
|
|
1389
|
+
# .vue embeds the script in non-JS markup; mask it out and parse the
|
|
1390
|
+
# <script> with TS.
|
|
1391
|
+
vue_lang: str | None = None
|
|
1392
|
+
if path.suffix == ".vue":
|
|
1393
|
+
masked, vue_lang = _vue_mask_non_script(
|
|
1394
|
+
path.read_text(encoding="utf-8", errors="replace")
|
|
1395
|
+
)
|
|
1396
|
+
source = masked.encode("utf-8")
|
|
1397
|
+
else:
|
|
1398
|
+
source = path.read_bytes()
|
|
1399
|
+
use_ts = path.suffix in (".ts", ".mts", ".cts") or (
|
|
1400
|
+
path.suffix == ".vue" and vue_lang not in ("js", "jsx")
|
|
1401
|
+
)
|
|
1402
|
+
if path.suffix == ".tsx":
|
|
1403
|
+
# .tsx must use the JSX-aware TSX grammar, mirroring the engine's
|
|
1404
|
+
# _TSX_CONFIG (ts_language_fn="language_tsx"). Parsing .tsx with
|
|
1405
|
+
# language_typescript misparses JSX, and tree-sitter's error
|
|
1406
|
+
# recovery floats nested arrow components up to top level —
|
|
1407
|
+
# _js_top_level_function_bodies then mints caller ids for callers
|
|
1408
|
+
# that own no node, leaking absolute-path slugs into calls-edge
|
|
1409
|
+
# sources (#2262).
|
|
1410
|
+
import tree_sitter_typescript as tstypescript
|
|
1411
|
+
language = Language(tstypescript.language_tsx())
|
|
1412
|
+
elif use_ts:
|
|
1413
|
+
import tree_sitter_typescript as tstypescript
|
|
1414
|
+
language = Language(tstypescript.language_typescript())
|
|
1415
|
+
else:
|
|
1416
|
+
import tree_sitter_javascript as tsjavascript
|
|
1417
|
+
language = Language(tsjavascript.language())
|
|
1418
|
+
parser = Parser(language)
|
|
1419
|
+
return source, parser.parse(source).root_node
|
|
1420
|
+
except Exception:
|
|
1421
|
+
return None
|
|
1422
|
+
|
|
1423
|
+
def _walk_js_tree(node):
|
|
1424
|
+
# Iterative DFS avoids Python's O(depth) generator-chain overhead.
|
|
1425
|
+
# Recursive yield-from creates one generator frame per level — at 26+
|
|
1426
|
+
# levels deep each leaf's value had to propagate through 26 frames.
|
|
1427
|
+
stack = [node]
|
|
1428
|
+
while stack:
|
|
1429
|
+
n = stack.pop()
|
|
1430
|
+
yield n
|
|
1431
|
+
stack.extend(reversed(n.children))
|
|
1432
|
+
|
|
1433
|
+
def _js_module_specifier(node, source: bytes) -> str | None:
|
|
1434
|
+
source_node = node.child_by_field_name("source")
|
|
1435
|
+
if source_node is None:
|
|
1436
|
+
for child in node.children:
|
|
1437
|
+
if child.type == "string":
|
|
1438
|
+
source_node = child
|
|
1439
|
+
break
|
|
1440
|
+
if source_node is None:
|
|
1441
|
+
return None
|
|
1442
|
+
raw = _read_text(source_node, source).strip()
|
|
1443
|
+
return raw.strip("'\"`") or None
|
|
1444
|
+
|
|
1445
|
+
def _js_named_specifiers(node, source: bytes, specifier_type: str) -> list[tuple[str, str]]:
|
|
1446
|
+
pairs: list[tuple[str, str]] = []
|
|
1447
|
+
for child in _walk_js_tree(node):
|
|
1448
|
+
if child.type != specifier_type:
|
|
1449
|
+
continue
|
|
1450
|
+
name_node = child.child_by_field_name("name")
|
|
1451
|
+
if name_node is None:
|
|
1452
|
+
continue
|
|
1453
|
+
alias_node = child.child_by_field_name("alias")
|
|
1454
|
+
name = _read_text(name_node, source)
|
|
1455
|
+
exposed = _read_text(alias_node, source) if alias_node is not None else name
|
|
1456
|
+
if name and exposed:
|
|
1457
|
+
pairs.append((name, exposed))
|
|
1458
|
+
return pairs
|
|
1459
|
+
|
|
1460
|
+
def _js_export_clause(node):
|
|
1461
|
+
for child in node.children:
|
|
1462
|
+
if child.type == "export_clause":
|
|
1463
|
+
return child
|
|
1464
|
+
return None
|
|
1465
|
+
|
|
1466
|
+
def _js_export_statement_is_star(node) -> bool:
|
|
1467
|
+
return any(child.type == "*" for child in node.children)
|
|
1468
|
+
|
|
1469
|
+
def _js_namespace_export_name(node, source: bytes) -> str | None:
|
|
1470
|
+
for child in node.children:
|
|
1471
|
+
if child.type != "namespace_export":
|
|
1472
|
+
continue
|
|
1473
|
+
for sub in child.children:
|
|
1474
|
+
if sub.type == "identifier":
|
|
1475
|
+
return _read_text(sub, source) or None
|
|
1476
|
+
return None
|
|
1477
|
+
|
|
1478
|
+
def _js_lexical_aliases(node, source: bytes) -> list[tuple[str, str]]:
|
|
1479
|
+
aliases: list[tuple[str, str]] = []
|
|
1480
|
+
if node.type != "lexical_declaration":
|
|
1481
|
+
return aliases
|
|
1482
|
+
for child in node.children:
|
|
1483
|
+
if child.type != "variable_declarator":
|
|
1484
|
+
continue
|
|
1485
|
+
name_node = child.child_by_field_name("name")
|
|
1486
|
+
value_node = child.child_by_field_name("value")
|
|
1487
|
+
if (
|
|
1488
|
+
name_node is not None
|
|
1489
|
+
and value_node is not None
|
|
1490
|
+
and value_node.type in ("identifier", "type_identifier")
|
|
1491
|
+
):
|
|
1492
|
+
aliases.append((_read_text(name_node, source), _read_text(value_node, source)))
|
|
1493
|
+
return aliases
|
|
1494
|
+
|
|
1495
|
+
def _js_exported_declaration_names(node, source: bytes) -> list[str]:
|
|
1496
|
+
names: list[str] = []
|
|
1497
|
+
declaration = node.child_by_field_name("declaration")
|
|
1498
|
+
if declaration is None:
|
|
1499
|
+
return names
|
|
1500
|
+
|
|
1501
|
+
if declaration.type in ("lexical_declaration", "variable_declaration"):
|
|
1502
|
+
# Preserve legacy aggregate-pattern facts for identifier-valued lexical
|
|
1503
|
+
# declarations; individual destructured bindings are a separate feature.
|
|
1504
|
+
names.extend(alias for alias, _target in _js_lexical_aliases(declaration, source))
|
|
1505
|
+
for declarator in declaration.named_children:
|
|
1506
|
+
if declarator.type != "variable_declarator":
|
|
1507
|
+
continue
|
|
1508
|
+
name_node = declarator.child_by_field_name("name")
|
|
1509
|
+
if name_node is not None and name_node.type == "identifier":
|
|
1510
|
+
name = _read_text(name_node, source)
|
|
1511
|
+
if name not in names:
|
|
1512
|
+
names.append(name)
|
|
1513
|
+
return names
|
|
1514
|
+
|
|
1515
|
+
if declaration.type in (
|
|
1516
|
+
"class_declaration",
|
|
1517
|
+
"abstract_class_declaration",
|
|
1518
|
+
"interface_declaration",
|
|
1519
|
+
"type_alias_declaration",
|
|
1520
|
+
"function_declaration",
|
|
1521
|
+
):
|
|
1522
|
+
name_node = declaration.child_by_field_name("name")
|
|
1523
|
+
if name_node is not None:
|
|
1524
|
+
names.append(_read_text(name_node, source))
|
|
1525
|
+
return names
|
|
1526
|
+
|
|
1527
|
+
def _js_default_import_name(node, source: bytes) -> str | None:
|
|
1528
|
+
"""Local binding of a default import: the `Foo` in `import Foo from './x'`.
|
|
1529
|
+
|
|
1530
|
+
The default binding is a bare identifier child of the import_clause (named
|
|
1531
|
+
imports live in a `named_imports` node, namespace imports in a
|
|
1532
|
+
`namespace_import` node), so it is also picked up from the mixed form
|
|
1533
|
+
`import Foo, { Bar } from './x'`.
|
|
1534
|
+
"""
|
|
1535
|
+
for child in node.children:
|
|
1536
|
+
if child.type == "import_clause":
|
|
1537
|
+
for sub in child.children:
|
|
1538
|
+
if sub.type == "identifier":
|
|
1539
|
+
return _read_text(sub, source)
|
|
1540
|
+
return None
|
|
1541
|
+
|
|
1542
|
+
def _js_default_export_name(node, source: bytes) -> str | None:
|
|
1543
|
+
"""Local name of a default export, or None for anonymous defaults.
|
|
1544
|
+
|
|
1545
|
+
Handles `export default class Foo {}`, `export default function foo() {}`,
|
|
1546
|
+
`export default abstract class Foo {}` (name on the `declaration` field) and
|
|
1547
|
+
`export default Foo` (an identifier on the `value` field). Anonymous defaults
|
|
1548
|
+
(`export default class {}`, `export default {...}`) have no resolvable symbol
|
|
1549
|
+
and return None.
|
|
1550
|
+
"""
|
|
1551
|
+
if not any(child.type == "default" for child in node.children):
|
|
1552
|
+
return None
|
|
1553
|
+
declaration = node.child_by_field_name("declaration")
|
|
1554
|
+
if declaration is not None:
|
|
1555
|
+
name_node = declaration.child_by_field_name("name")
|
|
1556
|
+
return _read_text(name_node, source) if name_node is not None else None
|
|
1557
|
+
value = node.child_by_field_name("value")
|
|
1558
|
+
if value is not None and value.type == "identifier":
|
|
1559
|
+
return _read_text(value, source)
|
|
1560
|
+
return None
|
|
1561
|
+
|
|
1562
|
+
def _js_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]:
|
|
1563
|
+
bodies: list[tuple[str, object]] = []
|
|
1564
|
+
stem = _file_stem(path)
|
|
1565
|
+
for node in root_node.children:
|
|
1566
|
+
if node.type == "function_declaration":
|
|
1567
|
+
name_node = node.child_by_field_name("name")
|
|
1568
|
+
body = node.child_by_field_name("body")
|
|
1569
|
+
if name_node is not None and body is not None:
|
|
1570
|
+
bodies.append((_make_id(stem, _read_text(name_node, source)), body))
|
|
1571
|
+
continue
|
|
1572
|
+
if node.type != "lexical_declaration":
|
|
1573
|
+
continue
|
|
1574
|
+
for child in node.children:
|
|
1575
|
+
if child.type != "variable_declarator":
|
|
1576
|
+
continue
|
|
1577
|
+
name_node = child.child_by_field_name("name")
|
|
1578
|
+
value_node = child.child_by_field_name("value")
|
|
1579
|
+
if (
|
|
1580
|
+
name_node is not None
|
|
1581
|
+
and value_node is not None
|
|
1582
|
+
and value_node.type == "arrow_function"
|
|
1583
|
+
):
|
|
1584
|
+
bodies.append((_make_id(stem, _read_text(name_node, source)), value_node))
|
|
1585
|
+
return bodies
|
|
1586
|
+
|
|
1587
|
+
def _js_call_identifier(node, source: bytes) -> str | None:
|
|
1588
|
+
if node.type != "call_expression":
|
|
1589
|
+
return None
|
|
1590
|
+
function_node = node.child_by_field_name("function")
|
|
1591
|
+
if function_node is None:
|
|
1592
|
+
for child in node.children:
|
|
1593
|
+
if child.is_named:
|
|
1594
|
+
function_node = child
|
|
1595
|
+
break
|
|
1596
|
+
if function_node is not None and function_node.type in ("identifier", "type_identifier"):
|
|
1597
|
+
return _read_text(function_node, source)
|
|
1598
|
+
return None
|
|
1599
|
+
|
|
1600
|
+
_JS_PRIMITIVE_TYPES = frozenset({
|
|
1601
|
+
"string", "number", "boolean", "any", "unknown", "void", "never",
|
|
1602
|
+
"object", "null", "undefined", "bigint", "symbol", "this",
|
|
1603
|
+
})
|
|
1604
|
+
|
|
1605
|
+
def _ts_heritage_clause_entries(clause_node, source: bytes) -> list[str]:
|
|
1606
|
+
"""Return base/interface type names from an extends_clause or implements_clause."""
|
|
1607
|
+
out: list[str] = []
|
|
1608
|
+
for child in clause_node.children:
|
|
1609
|
+
if not child.is_named:
|
|
1610
|
+
continue
|
|
1611
|
+
if child.type in ("identifier", "type_identifier"):
|
|
1612
|
+
name = _read_text(child, source)
|
|
1613
|
+
if name:
|
|
1614
|
+
out.append(name)
|
|
1615
|
+
elif child.type == "generic_type":
|
|
1616
|
+
name_node = child.child_by_field_name("name")
|
|
1617
|
+
if name_node is None:
|
|
1618
|
+
for sub in child.children:
|
|
1619
|
+
if sub.type in ("type_identifier", "nested_type_identifier", "identifier"):
|
|
1620
|
+
name_node = sub
|
|
1621
|
+
break
|
|
1622
|
+
if name_node is not None:
|
|
1623
|
+
text = _read_text(name_node, source).rsplit(".", 1)[-1]
|
|
1624
|
+
if text:
|
|
1625
|
+
out.append(text)
|
|
1626
|
+
elif child.type == "nested_type_identifier":
|
|
1627
|
+
text = _read_text(child, source).rsplit(".", 1)[-1]
|
|
1628
|
+
if text:
|
|
1629
|
+
out.append(text)
|
|
1630
|
+
return out
|
|
1631
|
+
|
|
1632
|
+
def _ts_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
|
|
1633
|
+
"""Walk a TS type annotation tree; append (name, role) tuples.
|
|
1634
|
+
|
|
1635
|
+
role is 'type' for the outermost type position and 'generic_arg' for entries
|
|
1636
|
+
that appear inside `type_arguments`.
|
|
1637
|
+
"""
|
|
1638
|
+
if node is None:
|
|
1639
|
+
return
|
|
1640
|
+
t = node.type
|
|
1641
|
+
if t == "type_annotation":
|
|
1642
|
+
for c in node.children:
|
|
1643
|
+
if c.is_named:
|
|
1644
|
+
_ts_collect_type_refs(c, source, generic, out)
|
|
1645
|
+
return
|
|
1646
|
+
if t in ("type_identifier", "identifier"):
|
|
1647
|
+
name = _read_text(node, source)
|
|
1648
|
+
if name and name not in _JS_PRIMITIVE_TYPES:
|
|
1649
|
+
out.append((name, "generic_arg" if generic else "type"))
|
|
1650
|
+
return
|
|
1651
|
+
if t == "nested_type_identifier":
|
|
1652
|
+
tail = _read_text(node, source).rsplit(".", 1)[-1]
|
|
1653
|
+
if tail and tail not in _JS_PRIMITIVE_TYPES:
|
|
1654
|
+
out.append((tail, "generic_arg" if generic else "type"))
|
|
1655
|
+
return
|
|
1656
|
+
if t == "generic_type":
|
|
1657
|
+
name_node = node.child_by_field_name("name")
|
|
1658
|
+
if name_node is not None:
|
|
1659
|
+
text = _read_text(name_node, source).rsplit(".", 1)[-1]
|
|
1660
|
+
if text and text not in _JS_PRIMITIVE_TYPES:
|
|
1661
|
+
out.append((text, "generic_arg" if generic else "type"))
|
|
1662
|
+
else:
|
|
1663
|
+
for c in node.children:
|
|
1664
|
+
if c.type in ("type_identifier", "nested_type_identifier"):
|
|
1665
|
+
text = _read_text(c, source).rsplit(".", 1)[-1]
|
|
1666
|
+
if text and text not in _JS_PRIMITIVE_TYPES:
|
|
1667
|
+
out.append((text, "generic_arg" if generic else "type"))
|
|
1668
|
+
break
|
|
1669
|
+
for c in node.children:
|
|
1670
|
+
if c.type == "type_arguments":
|
|
1671
|
+
for sub in c.children:
|
|
1672
|
+
if sub.is_named:
|
|
1673
|
+
_ts_collect_type_refs(sub, source, True, out)
|
|
1674
|
+
return
|
|
1675
|
+
if node.is_named:
|
|
1676
|
+
for c in node.children:
|
|
1677
|
+
if c.is_named:
|
|
1678
|
+
_ts_collect_type_refs(c, source, generic, out)
|
|
1679
|
+
|
|
1680
|
+
def _ts_walk_class_members(class_node, source: bytes, path: Path, class_nid: str,
|
|
1681
|
+
facts: _SymbolResolutionFacts) -> None:
|
|
1682
|
+
"""Emit type-relation and type-reference use facts for a class declaration node."""
|
|
1683
|
+
line = class_node.start_point[0] + 1
|
|
1684
|
+
for child in class_node.children:
|
|
1685
|
+
if child.type == "class_heritage":
|
|
1686
|
+
saw_clause = False
|
|
1687
|
+
for clause in child.children:
|
|
1688
|
+
if clause.type == "extends_clause":
|
|
1689
|
+
saw_clause = True
|
|
1690
|
+
for name in _ts_heritage_clause_entries(clause, source):
|
|
1691
|
+
facts.uses.append(
|
|
1692
|
+
_SymbolUseFact(path, class_nid, name, "inherits", "type",
|
|
1693
|
+
clause.start_point[0] + 1)
|
|
1694
|
+
)
|
|
1695
|
+
elif clause.type == "implements_clause":
|
|
1696
|
+
saw_clause = True
|
|
1697
|
+
for name in _ts_heritage_clause_entries(clause, source):
|
|
1698
|
+
facts.uses.append(
|
|
1699
|
+
_SymbolUseFact(path, class_nid, name, "implements", "type",
|
|
1700
|
+
clause.start_point[0] + 1)
|
|
1701
|
+
)
|
|
1702
|
+
if not saw_clause:
|
|
1703
|
+
# The JavaScript grammar carries the base directly under
|
|
1704
|
+
# class_heritage (`extends Base` -> [extends, identifier]) with no
|
|
1705
|
+
# extends_clause/implements_clause wrapper like the TypeScript
|
|
1706
|
+
# grammar. Treat the heritage node itself as the extends clause so
|
|
1707
|
+
# `class Derived extends Base {}` in a .js file still emits an
|
|
1708
|
+
# inherits edge. Mirrors the extends_type_clause branch below.
|
|
1709
|
+
for name in _ts_heritage_clause_entries(child, source):
|
|
1710
|
+
facts.uses.append(
|
|
1711
|
+
_SymbolUseFact(path, class_nid, name, "inherits", "type",
|
|
1712
|
+
child.start_point[0] + 1)
|
|
1713
|
+
)
|
|
1714
|
+
elif child.type == "extends_type_clause":
|
|
1715
|
+
# Interface heritage (`interface A extends B, C`) is an
|
|
1716
|
+
# extends_type_clause node, NOT a class_heritage. Its base entries
|
|
1717
|
+
# are the same node types extends_clause holds, so the helper is
|
|
1718
|
+
# reusable. Without this branch interface inheritance is dropped (#1095).
|
|
1719
|
+
for name in _ts_heritage_clause_entries(child, source):
|
|
1720
|
+
facts.uses.append(
|
|
1721
|
+
_SymbolUseFact(path, class_nid, name, "inherits", "type",
|
|
1722
|
+
child.start_point[0] + 1)
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
body = class_node.child_by_field_name("body")
|
|
1726
|
+
if body is None:
|
|
1727
|
+
return
|
|
1728
|
+
|
|
1729
|
+
for member in body.children:
|
|
1730
|
+
m_line = member.start_point[0] + 1
|
|
1731
|
+
if member.type in ("method_definition", "method_signature", "abstract_method_signature"):
|
|
1732
|
+
name_node = member.child_by_field_name("name")
|
|
1733
|
+
if name_node is None:
|
|
1734
|
+
continue
|
|
1735
|
+
method_name = _read_text(name_node, source)
|
|
1736
|
+
method_nid = _make_id(class_nid, method_name)
|
|
1737
|
+
params = member.child_by_field_name("parameters")
|
|
1738
|
+
if params is not None:
|
|
1739
|
+
for p in params.children:
|
|
1740
|
+
if p.type not in ("required_parameter", "optional_parameter"):
|
|
1741
|
+
continue
|
|
1742
|
+
type_anno = p.child_by_field_name("type")
|
|
1743
|
+
if type_anno is None:
|
|
1744
|
+
continue
|
|
1745
|
+
refs: list[tuple[str, str]] = []
|
|
1746
|
+
_ts_collect_type_refs(type_anno, source, False, refs)
|
|
1747
|
+
for name, role in refs:
|
|
1748
|
+
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
|
|
1749
|
+
facts.uses.append(
|
|
1750
|
+
_SymbolUseFact(path, method_nid, name, "references", ctx, m_line)
|
|
1751
|
+
)
|
|
1752
|
+
return_type = member.child_by_field_name("return_type")
|
|
1753
|
+
if return_type is not None:
|
|
1754
|
+
refs = []
|
|
1755
|
+
_ts_collect_type_refs(return_type, source, False, refs)
|
|
1756
|
+
for name, role in refs:
|
|
1757
|
+
ctx = "generic_arg" if role == "generic_arg" else "return_type"
|
|
1758
|
+
facts.uses.append(
|
|
1759
|
+
_SymbolUseFact(path, method_nid, name, "references", ctx, m_line)
|
|
1760
|
+
)
|
|
1761
|
+
elif member.type in ("public_field_definition", "property_signature"):
|
|
1762
|
+
type_anno = None
|
|
1763
|
+
for c in member.children:
|
|
1764
|
+
if c.type == "type_annotation":
|
|
1765
|
+
type_anno = c
|
|
1766
|
+
break
|
|
1767
|
+
if type_anno is None:
|
|
1768
|
+
continue
|
|
1769
|
+
refs = []
|
|
1770
|
+
_ts_collect_type_refs(type_anno, source, False, refs)
|
|
1771
|
+
for name, role in refs:
|
|
1772
|
+
ctx = "generic_arg" if role == "generic_arg" else "field"
|
|
1773
|
+
facts.uses.append(
|
|
1774
|
+
_SymbolUseFact(path, class_nid, name, "references", ctx, m_line)
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolutionFacts) -> None:
|
|
1778
|
+
js_paths = [
|
|
1779
|
+
path for path in paths
|
|
1780
|
+
if path.suffix in _JS_CACHE_BYPASS_SUFFIXES
|
|
1781
|
+
]
|
|
1782
|
+
if not js_paths:
|
|
1783
|
+
return
|
|
1784
|
+
|
|
1785
|
+
trees: dict[Path, tuple[bytes, object]] = {}
|
|
1786
|
+
|
|
1787
|
+
for path in js_paths:
|
|
1788
|
+
resolved_path = _resolve_cached(path)
|
|
1789
|
+
parsed = _parse_js_tree(path)
|
|
1790
|
+
if parsed is None:
|
|
1791
|
+
continue
|
|
1792
|
+
source, root_node = parsed
|
|
1793
|
+
trees[resolved_path] = parsed
|
|
1794
|
+
|
|
1795
|
+
for node in _walk_js_tree(root_node):
|
|
1796
|
+
if node.type == "export_statement":
|
|
1797
|
+
for name in _js_exported_declaration_names(node, source):
|
|
1798
|
+
facts.declarations.append(
|
|
1799
|
+
_SymbolDeclarationFact(path, name, node.start_point[0] + 1)
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
if node.type != "import_statement":
|
|
1803
|
+
continue
|
|
1804
|
+
raw_module = _js_module_specifier(node, source)
|
|
1805
|
+
if raw_module is None:
|
|
1806
|
+
continue
|
|
1807
|
+
target_path = _resolve_js_module_path(raw_module, path.parent)
|
|
1808
|
+
if target_path is None:
|
|
1809
|
+
continue
|
|
1810
|
+
target_path = _resolve_cached(target_path)
|
|
1811
|
+
for imported_name, local_name in _js_named_specifiers(node, source, "import_specifier"):
|
|
1812
|
+
facts.imports.append(
|
|
1813
|
+
_SymbolImportFact(
|
|
1814
|
+
path,
|
|
1815
|
+
local_name,
|
|
1816
|
+
target_path,
|
|
1817
|
+
imported_name,
|
|
1818
|
+
node.start_point[0] + 1,
|
|
1819
|
+
)
|
|
1820
|
+
)
|
|
1821
|
+
default_local = _js_default_import_name(node, source)
|
|
1822
|
+
if default_local is not None:
|
|
1823
|
+
facts.imports.append(
|
|
1824
|
+
_SymbolImportFact(
|
|
1825
|
+
path,
|
|
1826
|
+
default_local,
|
|
1827
|
+
target_path,
|
|
1828
|
+
"default",
|
|
1829
|
+
node.start_point[0] + 1,
|
|
1830
|
+
)
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
for node in _walk_js_tree(root_node):
|
|
1834
|
+
for alias, target in _js_lexical_aliases(node, source):
|
|
1835
|
+
facts.aliases.append(
|
|
1836
|
+
_SymbolAliasFact(path, alias, target, node.start_point[0] + 1)
|
|
1837
|
+
)
|
|
1838
|
+
|
|
1839
|
+
for path in js_paths:
|
|
1840
|
+
resolved_path = _resolve_cached(path)
|
|
1841
|
+
parsed = trees.get(resolved_path)
|
|
1842
|
+
if parsed is None:
|
|
1843
|
+
continue
|
|
1844
|
+
source, root_node = parsed
|
|
1845
|
+
|
|
1846
|
+
for node in _walk_js_tree(root_node):
|
|
1847
|
+
if node.type != "export_statement":
|
|
1848
|
+
continue
|
|
1849
|
+
|
|
1850
|
+
raw_module = _js_module_specifier(node, source)
|
|
1851
|
+
export_clause = _js_export_clause(node)
|
|
1852
|
+
# `export type { X } from ...` / `export type * from ...`: the
|
|
1853
|
+
# statement-level `type` keyword is a bare anonymous child; the
|
|
1854
|
+
# default binding NAMED type sits inside the clause instead (#3123).
|
|
1855
|
+
stmt_type_only = any(
|
|
1856
|
+
child.type == "type" and not child.is_named
|
|
1857
|
+
for child in node.children
|
|
1858
|
+
)
|
|
1859
|
+
if raw_module is not None:
|
|
1860
|
+
target_path = _resolve_js_module_path(raw_module, path.parent)
|
|
1861
|
+
if target_path is None:
|
|
1862
|
+
continue
|
|
1863
|
+
target_path = _resolve_cached(target_path)
|
|
1864
|
+
namespace_name = _js_namespace_export_name(node, source)
|
|
1865
|
+
if namespace_name is not None:
|
|
1866
|
+
facts.namespace_exports.append(
|
|
1867
|
+
_NamespaceExportFact(
|
|
1868
|
+
path,
|
|
1869
|
+
namespace_name,
|
|
1870
|
+
target_path,
|
|
1871
|
+
node.start_point[0] + 1,
|
|
1872
|
+
type_only=stmt_type_only,
|
|
1873
|
+
)
|
|
1874
|
+
)
|
|
1875
|
+
elif _js_export_statement_is_star(node):
|
|
1876
|
+
facts.star_exports.append(
|
|
1877
|
+
_StarExportFact(path, target_path, node.start_point[0] + 1,
|
|
1878
|
+
type_only=stmt_type_only)
|
|
1879
|
+
)
|
|
1880
|
+
if export_clause is not None:
|
|
1881
|
+
for original_name, exported_name in _js_named_specifiers(
|
|
1882
|
+
export_clause, source, "export_specifier"
|
|
1883
|
+
):
|
|
1884
|
+
facts.exports.append(
|
|
1885
|
+
_SymbolExportFact(
|
|
1886
|
+
path,
|
|
1887
|
+
exported_name,
|
|
1888
|
+
node.start_point[0] + 1,
|
|
1889
|
+
target_path=target_path,
|
|
1890
|
+
target_name=original_name,
|
|
1891
|
+
type_only=stmt_type_only,
|
|
1892
|
+
)
|
|
1893
|
+
)
|
|
1894
|
+
continue
|
|
1895
|
+
|
|
1896
|
+
if export_clause is not None:
|
|
1897
|
+
for local_name, exported_name in _js_named_specifiers(
|
|
1898
|
+
export_clause, source, "export_specifier"
|
|
1899
|
+
):
|
|
1900
|
+
facts.exports.append(
|
|
1901
|
+
_SymbolExportFact(
|
|
1902
|
+
path,
|
|
1903
|
+
exported_name,
|
|
1904
|
+
node.start_point[0] + 1,
|
|
1905
|
+
local_name=local_name,
|
|
1906
|
+
)
|
|
1907
|
+
)
|
|
1908
|
+
continue
|
|
1909
|
+
|
|
1910
|
+
for exported_name in _js_exported_declaration_names(node, source):
|
|
1911
|
+
facts.exports.append(
|
|
1912
|
+
_SymbolExportFact(
|
|
1913
|
+
path,
|
|
1914
|
+
exported_name,
|
|
1915
|
+
node.start_point[0] + 1,
|
|
1916
|
+
local_name=exported_name,
|
|
1917
|
+
)
|
|
1918
|
+
)
|
|
1919
|
+
|
|
1920
|
+
# `export default class Foo {}` / `export default foo` exposes the
|
|
1921
|
+
# symbol under the name "default"; record that so a default import
|
|
1922
|
+
# (imported_name="default") resolves to it. `export { X as default }`
|
|
1923
|
+
# is already handled via the export_clause path above.
|
|
1924
|
+
default_name = _js_default_export_name(node, source)
|
|
1925
|
+
if default_name is not None:
|
|
1926
|
+
facts.exports.append(
|
|
1927
|
+
_SymbolExportFact(
|
|
1928
|
+
path,
|
|
1929
|
+
"default",
|
|
1930
|
+
node.start_point[0] + 1,
|
|
1931
|
+
local_name=default_name,
|
|
1932
|
+
)
|
|
1933
|
+
)
|
|
1934
|
+
|
|
1935
|
+
for path in js_paths:
|
|
1936
|
+
resolved_path = _resolve_cached(path)
|
|
1937
|
+
parsed = trees.get(resolved_path)
|
|
1938
|
+
if parsed is None:
|
|
1939
|
+
continue
|
|
1940
|
+
source, root_node = parsed
|
|
1941
|
+
for source_id, body in _js_top_level_function_bodies(path, root_node, source):
|
|
1942
|
+
for node in _walk_js_tree(body):
|
|
1943
|
+
imported_name = _js_call_identifier(node, source)
|
|
1944
|
+
if imported_name is None:
|
|
1945
|
+
continue
|
|
1946
|
+
facts.uses.append(
|
|
1947
|
+
_SymbolUseFact(
|
|
1948
|
+
path,
|
|
1949
|
+
source_id,
|
|
1950
|
+
imported_name,
|
|
1951
|
+
"calls",
|
|
1952
|
+
"call",
|
|
1953
|
+
node.start_point[0] + 1,
|
|
1954
|
+
)
|
|
1955
|
+
)
|
|
1956
|
+
|
|
1957
|
+
for path in js_paths:
|
|
1958
|
+
resolved_path = _resolve_cached(path)
|
|
1959
|
+
parsed = trees.get(resolved_path)
|
|
1960
|
+
if parsed is None:
|
|
1961
|
+
continue
|
|
1962
|
+
source, root_node = parsed
|
|
1963
|
+
stem = _file_stem(path)
|
|
1964
|
+
for node in _walk_js_tree(root_node):
|
|
1965
|
+
if node.type not in (
|
|
1966
|
+
"class_declaration",
|
|
1967
|
+
"abstract_class_declaration",
|
|
1968
|
+
"interface_declaration",
|
|
1969
|
+
):
|
|
1970
|
+
continue
|
|
1971
|
+
name_node = node.child_by_field_name("name")
|
|
1972
|
+
if name_node is None:
|
|
1973
|
+
continue
|
|
1974
|
+
class_name = _read_text(name_node, source)
|
|
1975
|
+
if not class_name:
|
|
1976
|
+
continue
|
|
1977
|
+
class_nid = _make_id(stem, class_name)
|
|
1978
|
+
_ts_walk_class_members(node, source, path, class_nid, facts)
|
|
1979
|
+
|
|
1980
|
+
@functools.lru_cache(maxsize=2048)
|
|
1981
|
+
def _parse_python_tree_cached(path_str: str, _mtime_ns: int, _size: int):
|
|
1982
|
+
import tree_sitter_python as tspython
|
|
1983
|
+
from tree_sitter import Language, Parser
|
|
1984
|
+
source = Path(path_str).read_bytes()
|
|
1985
|
+
parser = Parser(Language(tspython.language()))
|
|
1986
|
+
return source, parser.parse(source).root_node
|
|
1987
|
+
|
|
1988
|
+
|
|
1989
|
+
def _parse_python_tree(path: Path):
|
|
1990
|
+
"""Parse one Python file to ``(source, root_node)``, memoized (#perf).
|
|
1991
|
+
|
|
1992
|
+
The Python symbol-resolution facts pass and the cross-file import pass each
|
|
1993
|
+
parse the entire ``.py`` corpus, back to back, from the main process after
|
|
1994
|
+
the workers return — so every file was tree-sitter-parsed (and read from
|
|
1995
|
+
disk) twice for no reason. Keying the memo on ``(path, mtime_ns, size)``
|
|
1996
|
+
lets the second pass reuse the first pass's tree while still re-parsing a
|
|
1997
|
+
file that changed between runs (watch mode). Both passes only read the
|
|
1998
|
+
tree, so sharing one parse is behaviour-preserving. Returns ``None`` on any
|
|
1999
|
+
error, exactly as before — callers already treat that as "skip this file".
|
|
2000
|
+
"""
|
|
2001
|
+
try:
|
|
2002
|
+
st = path.stat()
|
|
2003
|
+
return _parse_python_tree_cached(str(path), st.st_mtime_ns, st.st_size)
|
|
2004
|
+
except Exception:
|
|
2005
|
+
return None
|
|
2006
|
+
|
|
2007
|
+
def _walk_python_tree(node):
|
|
2008
|
+
"""Preorder walk of a tree-sitter tree, iteratively.
|
|
2009
|
+
|
|
2010
|
+
The recursive ``yield from`` form built one suspended generator frame per
|
|
2011
|
+
ancestor and re-propagated every node up the whole chain — ~25M frame
|
|
2012
|
+
resumptions on a 364-file corpus for ~2.8M actual nodes. An explicit stack
|
|
2013
|
+
yields each node exactly once in the identical preorder (children pushed
|
|
2014
|
+
reversed so the first child pops first). Same rewrite, same reasoning as
|
|
2015
|
+
``_walk_js_tree`` above.
|
|
2016
|
+
"""
|
|
2017
|
+
stack = [node]
|
|
2018
|
+
while stack:
|
|
2019
|
+
current = stack.pop()
|
|
2020
|
+
yield current
|
|
2021
|
+
stack.extend(reversed(current.children))
|
|
2022
|
+
|
|
2023
|
+
def _python_import_from_module(node, source: bytes) -> tuple[int, str] | None:
|
|
2024
|
+
level = 0
|
|
2025
|
+
module_name = ""
|
|
2026
|
+
for child in node.children:
|
|
2027
|
+
if child.type == "import":
|
|
2028
|
+
break
|
|
2029
|
+
if child.type == "relative_import":
|
|
2030
|
+
raw = _read_text(child, source)
|
|
2031
|
+
level = len(raw) - len(raw.lstrip("."))
|
|
2032
|
+
remainder = raw.lstrip(".")
|
|
2033
|
+
if remainder:
|
|
2034
|
+
module_name = remainder
|
|
2035
|
+
for sub in child.children:
|
|
2036
|
+
if sub.type == "dotted_name":
|
|
2037
|
+
module_name = _read_text(sub, source)
|
|
2038
|
+
elif child.type == "dotted_name":
|
|
2039
|
+
module_name = _read_text(child, source)
|
|
2040
|
+
if level == 0 and not module_name:
|
|
2041
|
+
return None
|
|
2042
|
+
return level, module_name
|
|
2043
|
+
|
|
2044
|
+
def _python_imported_names(node, source: bytes) -> list[tuple[str, str]]:
|
|
2045
|
+
names: list[tuple[str, str]] = []
|
|
2046
|
+
past_import = False
|
|
2047
|
+
for child in node.children:
|
|
2048
|
+
if child.type == "import":
|
|
2049
|
+
past_import = True
|
|
2050
|
+
continue
|
|
2051
|
+
if not past_import:
|
|
2052
|
+
continue
|
|
2053
|
+
if child.type == "dotted_name":
|
|
2054
|
+
name = _read_text(child, source)
|
|
2055
|
+
names.append((name, name.split(".")[-1]))
|
|
2056
|
+
elif child.type == "aliased_import":
|
|
2057
|
+
name_node = child.child_by_field_name("name")
|
|
2058
|
+
alias_node = child.child_by_field_name("alias")
|
|
2059
|
+
if name_node is None:
|
|
2060
|
+
continue
|
|
2061
|
+
name = _read_text(name_node, source)
|
|
2062
|
+
local = _read_text(alias_node, source) if alias_node is not None else name.split(".")[-1]
|
|
2063
|
+
names.append((name, local))
|
|
2064
|
+
return names
|
|
2065
|
+
|
|
2066
|
+
def _probe_python_module_candidate(candidate: Path) -> Path | None:
|
|
2067
|
+
"""Resolve one module-path candidate to a .py file (dir+__init__, exact, or
|
|
2068
|
+
with a .py suffix), or None."""
|
|
2069
|
+
if candidate.is_dir():
|
|
2070
|
+
init_path = candidate / "__init__.py"
|
|
2071
|
+
if init_path.is_file():
|
|
2072
|
+
return init_path
|
|
2073
|
+
if candidate.is_file():
|
|
2074
|
+
return candidate
|
|
2075
|
+
if not candidate.name:
|
|
2076
|
+
return None
|
|
2077
|
+
py_candidate = candidate.with_suffix(".py")
|
|
2078
|
+
if py_candidate.is_file():
|
|
2079
|
+
return py_candidate
|
|
2080
|
+
return None
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def _resolve_python_module_path(module_name: str, current_path: Path, root: Path, level: int) -> Path | None:
|
|
2084
|
+
if level > 0:
|
|
2085
|
+
base = current_path.parent
|
|
2086
|
+
for _ in range(level - 1):
|
|
2087
|
+
base = base.parent
|
|
2088
|
+
candidate = base / module_name.replace(".", "/") if module_name else base
|
|
2089
|
+
return _probe_python_module_candidate(candidate)
|
|
2090
|
+
|
|
2091
|
+
# Absolute import. Probe the scan root first (unchanged for the common
|
|
2092
|
+
# root-is-package-root layout), then walk up from the importing file toward
|
|
2093
|
+
# the root so a `src/` (or otherwise nested) package root resolves regardless
|
|
2094
|
+
# of where the scan started — `import pkg.mod` from src/pkg/app.py must find
|
|
2095
|
+
# src/pkg/mod.py whether the scan root is the repo or src/ (#2072). Mirrors
|
|
2096
|
+
# the upward walk already used for Lua (_resolve_lua_import_target, #1075).
|
|
2097
|
+
rel = module_name.replace(".", "/")
|
|
2098
|
+
hit = _probe_python_module_candidate(root / rel)
|
|
2099
|
+
if hit is not None:
|
|
2100
|
+
return hit
|
|
2101
|
+
for anc in current_path.parents:
|
|
2102
|
+
try:
|
|
2103
|
+
anc.relative_to(root)
|
|
2104
|
+
except ValueError:
|
|
2105
|
+
break # left the scan root; stop walking up
|
|
2106
|
+
if anc == root:
|
|
2107
|
+
continue # already probed root/rel above
|
|
2108
|
+
# Only probe sys.path-root candidates — dirs that are NOT themselves part
|
|
2109
|
+
# of a package. Probing a package dir would resolve an absolute
|
|
2110
|
+
# `from helpers import x` to a sibling in the current package (Python-2
|
|
2111
|
+
# implicit-relative semantics), fabricating edges to what may be an
|
|
2112
|
+
# external dependency (#2072 review). A src-layout root (src/, no
|
|
2113
|
+
# __init__.py) is still probed.
|
|
2114
|
+
if (anc / "__init__.py").is_file():
|
|
2115
|
+
continue
|
|
2116
|
+
cand = _probe_python_module_candidate(anc / rel)
|
|
2117
|
+
if cand is not None:
|
|
2118
|
+
return cand
|
|
2119
|
+
return None
|
|
2120
|
+
|
|
2121
|
+
def _resolve_python_namespace_dir(module_name: str, current_path: Path, root: Path, level: int) -> "Path | None":
|
|
2122
|
+
"""The directory a ``from <module> import ...`` names when that module is a
|
|
2123
|
+
PEP 420 namespace package: a directory under the scan root with no
|
|
2124
|
+
``__init__.py``. ``_resolve_python_module_path`` returns None for it (there is
|
|
2125
|
+
no module file to probe), so a package that omits ``__init__.py`` -- which
|
|
2126
|
+
``python -m pkg.mod`` runs without complaint -- had every ``from . import
|
|
2127
|
+
sibling`` dropped whole, and with it every ``sibling.func()`` call the #1883
|
|
2128
|
+
module arm would otherwise have resolved: the most-called functions in such a
|
|
2129
|
+
repo carried in-degree 0. Mirrors that resolver's walk (relative base, then
|
|
2130
|
+
scan root, then sys.path-root ancestors) and returns only a directory that
|
|
2131
|
+
exists inside the root."""
|
|
2132
|
+
def _namespace(candidate: Path) -> "Path | None":
|
|
2133
|
+
if not candidate.is_dir() or (candidate / "__init__.py").is_file():
|
|
2134
|
+
return None
|
|
2135
|
+
try:
|
|
2136
|
+
_resolve_cached(candidate).relative_to(_resolve_cached(root))
|
|
2137
|
+
except ValueError:
|
|
2138
|
+
return None
|
|
2139
|
+
return candidate
|
|
2140
|
+
|
|
2141
|
+
if level > 0:
|
|
2142
|
+
base = current_path.parent
|
|
2143
|
+
for _ in range(level - 1):
|
|
2144
|
+
base = base.parent
|
|
2145
|
+
return _namespace(base / module_name.replace(".", "/") if module_name else base)
|
|
2146
|
+
if not module_name:
|
|
2147
|
+
return None
|
|
2148
|
+
rel = module_name.replace(".", "/")
|
|
2149
|
+
hit = _namespace(root / rel)
|
|
2150
|
+
if hit is not None:
|
|
2151
|
+
return hit
|
|
2152
|
+
for anc in current_path.parents:
|
|
2153
|
+
try:
|
|
2154
|
+
anc.relative_to(root)
|
|
2155
|
+
except ValueError:
|
|
2156
|
+
break # left the scan root; stop walking up
|
|
2157
|
+
if anc == root or (anc / "__init__.py").is_file():
|
|
2158
|
+
continue # root already probed; a package dir is not a sys.path root (#2072)
|
|
2159
|
+
hit = _namespace(anc / rel)
|
|
2160
|
+
if hit is not None:
|
|
2161
|
+
return hit
|
|
2162
|
+
return None
|
|
2163
|
+
|
|
2164
|
+
def _python_top_level_function_bodies(path: Path, root_node, source: bytes) -> list[tuple[str, object]]:
|
|
2165
|
+
bodies: list[tuple[str, object]] = []
|
|
2166
|
+
stem = _file_stem(path)
|
|
2167
|
+
for node in root_node.children:
|
|
2168
|
+
if node.type != "function_definition":
|
|
2169
|
+
continue
|
|
2170
|
+
name_node = node.child_by_field_name("name")
|
|
2171
|
+
body = node.child_by_field_name("body")
|
|
2172
|
+
if name_node is not None and body is not None:
|
|
2173
|
+
bodies.append((_make_id(stem, _read_text(name_node, source)), body))
|
|
2174
|
+
return bodies
|
|
2175
|
+
|
|
2176
|
+
def _python_call_identifier(node, source: bytes) -> str | None:
|
|
2177
|
+
if node.type != "call":
|
|
2178
|
+
return None
|
|
2179
|
+
function_node = node.child_by_field_name("function")
|
|
2180
|
+
if function_node is not None and function_node.type == "identifier":
|
|
2181
|
+
return _read_text(function_node, source)
|
|
2182
|
+
return None
|
|
2183
|
+
|
|
2184
|
+
def _collect_python_symbol_resolution_facts(
|
|
2185
|
+
paths: list[Path],
|
|
2186
|
+
root: Path,
|
|
2187
|
+
facts: _SymbolResolutionFacts,
|
|
2188
|
+
) -> None:
|
|
2189
|
+
py_paths = [path for path in paths if path.suffix == ".py"]
|
|
2190
|
+
if not py_paths:
|
|
2191
|
+
return
|
|
2192
|
+
|
|
2193
|
+
trees: dict[Path, tuple[bytes, object]] = {}
|
|
2194
|
+
for path in py_paths:
|
|
2195
|
+
parsed = _parse_python_tree(path)
|
|
2196
|
+
if parsed is None:
|
|
2197
|
+
continue
|
|
2198
|
+
source, root_node = parsed
|
|
2199
|
+
trees[_resolve_cached(path)] = parsed
|
|
2200
|
+
|
|
2201
|
+
for node in _walk_python_tree(root_node):
|
|
2202
|
+
if node.type != "import_from_statement":
|
|
2203
|
+
continue
|
|
2204
|
+
module = _python_import_from_module(node, source)
|
|
2205
|
+
if module is None:
|
|
2206
|
+
continue
|
|
2207
|
+
level, module_name = module
|
|
2208
|
+
target_path = _resolve_python_module_path(module_name, path, root, level)
|
|
2209
|
+
if target_path is not None:
|
|
2210
|
+
# #1146: `from pkg import submod` — if the target is a package
|
|
2211
|
+
# (__init__.py) and an imported name matches a submodule file on
|
|
2212
|
+
# disk, emit a file-level import edge to that submodule rather
|
|
2213
|
+
# than only to the package.
|
|
2214
|
+
pkg_dir = target_path.parent if target_path.name == "__init__.py" else None
|
|
2215
|
+
else:
|
|
2216
|
+
# A PEP 420 namespace package: the module names a directory with
|
|
2217
|
+
# no __init__.py, so there is no module file to resolve to, but
|
|
2218
|
+
# the names it imports can still be submodule files on disk.
|
|
2219
|
+
# Without this branch `from . import brain` in such a package
|
|
2220
|
+
# emitted nothing, and `brain.think()` never became an edge.
|
|
2221
|
+
pkg_dir = _resolve_python_namespace_dir(module_name, path, root, level)
|
|
2222
|
+
if pkg_dir is None:
|
|
2223
|
+
continue
|
|
2224
|
+
for imported_name, local_name in _python_imported_names(node, source):
|
|
2225
|
+
line = node.start_point[0] + 1
|
|
2226
|
+
if pkg_dir is not None:
|
|
2227
|
+
sub_py = pkg_dir / f"{imported_name}.py"
|
|
2228
|
+
sub_pkg = pkg_dir / imported_name / "__init__.py"
|
|
2229
|
+
submodule = sub_py if sub_py.is_file() else (sub_pkg if sub_pkg.is_file() else None)
|
|
2230
|
+
if submodule is not None:
|
|
2231
|
+
facts.module_imports.append((path, submodule, line, local_name))
|
|
2232
|
+
continue
|
|
2233
|
+
if target_path is None:
|
|
2234
|
+
continue # a namespace package owns no symbols of its own to bind
|
|
2235
|
+
facts.imports.append(
|
|
2236
|
+
_SymbolImportFact(path, local_name, target_path, imported_name, line)
|
|
2237
|
+
)
|
|
2238
|
+
if path.name == "__init__.py":
|
|
2239
|
+
facts.exports.append(
|
|
2240
|
+
_SymbolExportFact(
|
|
2241
|
+
path,
|
|
2242
|
+
local_name,
|
|
2243
|
+
line,
|
|
2244
|
+
target_path=target_path,
|
|
2245
|
+
target_name=imported_name,
|
|
2246
|
+
)
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2249
|
+
for path in py_paths:
|
|
2250
|
+
parsed = trees.get(_resolve_cached(path))
|
|
2251
|
+
if parsed is None:
|
|
2252
|
+
continue
|
|
2253
|
+
source, root_node = parsed
|
|
2254
|
+
for source_id, body in _python_top_level_function_bodies(path, root_node, source):
|
|
2255
|
+
for node in _walk_python_tree(body):
|
|
2256
|
+
imported_name = _python_call_identifier(node, source)
|
|
2257
|
+
if imported_name is None:
|
|
2258
|
+
continue
|
|
2259
|
+
facts.uses.append(
|
|
2260
|
+
_SymbolUseFact(
|
|
2261
|
+
path,
|
|
2262
|
+
source_id,
|
|
2263
|
+
imported_name,
|
|
2264
|
+
"calls",
|
|
2265
|
+
"call",
|
|
2266
|
+
node.start_point[0] + 1,
|
|
2267
|
+
)
|
|
2268
|
+
)
|
|
2269
|
+
|
|
2270
|
+
def _augment_symbol_resolution_edges(
|
|
2271
|
+
paths: list[Path],
|
|
2272
|
+
nodes: list[dict],
|
|
2273
|
+
edges: list[dict],
|
|
2274
|
+
root: Path,
|
|
2275
|
+
) -> None:
|
|
2276
|
+
facts = _SymbolResolutionFacts()
|
|
2277
|
+
_collect_js_symbol_resolution_facts(paths, facts)
|
|
2278
|
+
_collect_python_symbol_resolution_facts(paths, root, facts)
|
|
2279
|
+
_apply_symbol_resolution_facts(paths, nodes, edges, root, facts)
|
|
2280
|
+
|
|
2281
|
+
def _resolve_cross_file_imports(
|
|
2282
|
+
per_file: list[dict],
|
|
2283
|
+
paths: list[Path],
|
|
2284
|
+
all_nodes: list[dict] | None = None,
|
|
2285
|
+
all_edges: list[dict] | None = None,
|
|
2286
|
+
) -> list[dict]:
|
|
2287
|
+
"""
|
|
2288
|
+
Two-pass import resolution: turn file-level imports into class-level edges.
|
|
2289
|
+
|
|
2290
|
+
Pass 1 - build a global map: class/function name → node_id, per stem.
|
|
2291
|
+
Pass 2 - for each `from .module import Name`, look up Name in the global
|
|
2292
|
+
map and add a direct INFERRED edge from each class in the
|
|
2293
|
+
importing file to the imported entity.
|
|
2294
|
+
|
|
2295
|
+
This turns:
|
|
2296
|
+
auth.py --imports_from--> models.py (obvious, filtered out)
|
|
2297
|
+
Into:
|
|
2298
|
+
DigestAuth --uses--> Response [INFERRED] (cross-file, interesting!)
|
|
2299
|
+
BasicAuth --uses--> Request [INFERRED]
|
|
2300
|
+
"""
|
|
2301
|
+
try:
|
|
2302
|
+
import tree_sitter_python # noqa: F401 (availability check only)
|
|
2303
|
+
except ImportError:
|
|
2304
|
+
return []
|
|
2305
|
+
|
|
2306
|
+
# Pass 1: _file_stem(path) → {ClassName: node_id}
|
|
2307
|
+
# Keyed by directory-qualified stem (e.g. "auth_models") to avoid collisions
|
|
2308
|
+
# when multiple files share the same filename in different directories.
|
|
2309
|
+
# A secondary bare-stem index handles absolute imports where only the module
|
|
2310
|
+
# name is known — first writer wins when names collide (inherently ambiguous).
|
|
2311
|
+
stem_to_entities: dict[str, dict[str, str]] = {}
|
|
2312
|
+
bare_to_qualified: dict[str, str] = {}
|
|
2313
|
+
for file_result in per_file:
|
|
2314
|
+
for node in file_result.get("nodes", []):
|
|
2315
|
+
src = node.get("source_file", "")
|
|
2316
|
+
if not src:
|
|
2317
|
+
continue
|
|
2318
|
+
src_path = Path(src)
|
|
2319
|
+
fq_stem = _file_stem(src_path)
|
|
2320
|
+
label = node.get("label", "")
|
|
2321
|
+
nid = node.get("id", "")
|
|
2322
|
+
# Index class-level entities only. Function/method labels end in "()"
|
|
2323
|
+
# so are excluded by the `endswith(")")` filter; file nodes end in ".py";
|
|
2324
|
+
# private/internal labels start with "_"; rationale nodes carry
|
|
2325
|
+
# file_type=="rationale" and must never participate in cross-file
|
|
2326
|
+
# import resolution (#563).
|
|
2327
|
+
if (
|
|
2328
|
+
label
|
|
2329
|
+
and not label.endswith((")", ".py"))
|
|
2330
|
+
and "_" not in label[:1]
|
|
2331
|
+
and node.get("file_type") != "rationale"
|
|
2332
|
+
):
|
|
2333
|
+
stem_to_entities.setdefault(fq_stem, {})[label] = nid
|
|
2334
|
+
if src_path.stem not in bare_to_qualified:
|
|
2335
|
+
bare_to_qualified[src_path.stem] = fq_stem
|
|
2336
|
+
|
|
2337
|
+
# Pass 2: for each file, find `from .X import A, B, C`, then attribute the
|
|
2338
|
+
# `uses` edge to the specific local symbol (class OR function) whose body
|
|
2339
|
+
# actually references the imported name — not to every class that merely
|
|
2340
|
+
# shares the file (#2652). The edge is anchored at the real reference, not
|
|
2341
|
+
# the import line, so `source_location` points at genuine corroboration.
|
|
2342
|
+
new_edges: list[dict] = []
|
|
2343
|
+
node_by_id = {node["id"]: node for node in all_nodes if node.get("id")} if all_nodes else {}
|
|
2344
|
+
repointed_from: set[str] = set()
|
|
2345
|
+
TYPE_REPOINT_RELATIONS = frozenset({"references", "inherits", "implements", "extends"})
|
|
2346
|
+
|
|
2347
|
+
for file_result, path in zip(per_file, paths):
|
|
2348
|
+
str_path = str(path)
|
|
2349
|
+
file_srcs = {n.get("source_file") for n in file_result.get("nodes", []) if n.get("source_file")}
|
|
2350
|
+
file_srcs.add(str_path)
|
|
2351
|
+
file_srcs.add(path.as_posix())
|
|
2352
|
+
|
|
2353
|
+
# Map each local symbol (class or function) to its node id, keyed by the
|
|
2354
|
+
# bare symbol name. Function labels end in "()"; the file node ends in
|
|
2355
|
+
# ".py"; rationale nodes never import (#563). First writer wins on a
|
|
2356
|
+
# name collision (inherently ambiguous within a file).
|
|
2357
|
+
name_to_nid: dict[str, str] = {}
|
|
2358
|
+
for n in file_result.get("nodes", []):
|
|
2359
|
+
if n.get("source_file") != str_path or n.get("file_type") == "rationale":
|
|
2360
|
+
continue
|
|
2361
|
+
label = n.get("label", "")
|
|
2362
|
+
if not label or label.endswith(".py"):
|
|
2363
|
+
continue
|
|
2364
|
+
sym_name = label[:-2] if label.endswith("()") else label
|
|
2365
|
+
if sym_name and sym_name not in name_to_nid:
|
|
2366
|
+
name_to_nid[sym_name] = n["id"]
|
|
2367
|
+
if not name_to_nid:
|
|
2368
|
+
continue
|
|
2369
|
+
|
|
2370
|
+
# Parse imports from this file (shared with the facts pass via the
|
|
2371
|
+
# mtime-keyed memo, so each .py is parsed once across both passes).
|
|
2372
|
+
parsed = _parse_python_tree(path)
|
|
2373
|
+
if parsed is None:
|
|
2374
|
+
continue
|
|
2375
|
+
source, root_node = parsed
|
|
2376
|
+
|
|
2377
|
+
# local_name -> target node id (local_name honours `import X as Y`, so a
|
|
2378
|
+
# reference to the alias in the body still attributes correctly).
|
|
2379
|
+
import_targets: dict[str, str] = {}
|
|
2380
|
+
# referenced name -> {source symbol nid: first reference line}
|
|
2381
|
+
ref_sources: dict[str, dict[str, int]] = {}
|
|
2382
|
+
|
|
2383
|
+
def _text(n) -> str:
|
|
2384
|
+
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
|
|
2385
|
+
|
|
2386
|
+
def resolve_import(node) -> None:
|
|
2387
|
+
# Find the module name - handles both absolute and relative imports.
|
|
2388
|
+
# Relative: `from .models import X` → relative_import → dotted_name
|
|
2389
|
+
# Absolute: `from models import X` → module_name field
|
|
2390
|
+
# target_fq is the directory-qualified stem used as the key in
|
|
2391
|
+
# stem_to_entities. Relative imports are resolved exactly via the
|
|
2392
|
+
# importing file's directory; absolute imports fall back to the
|
|
2393
|
+
# bare-stem secondary index (first-writer-wins when names collide).
|
|
2394
|
+
target_fq: str | None = None
|
|
2395
|
+
for child in node.children:
|
|
2396
|
+
if child.type == "relative_import":
|
|
2397
|
+
prefix_text = ""
|
|
2398
|
+
dotted_text = ""
|
|
2399
|
+
for sub in child.children:
|
|
2400
|
+
if sub.type == "import_prefix":
|
|
2401
|
+
prefix_text = _text(sub)
|
|
2402
|
+
elif sub.type == "dotted_name":
|
|
2403
|
+
dotted_text = _text(sub)
|
|
2404
|
+
dots = prefix_text.count(".") if prefix_text else 1
|
|
2405
|
+
cur_dir = path.parent
|
|
2406
|
+
for _ in range(dots - 1):
|
|
2407
|
+
cur_dir = cur_dir.parent
|
|
2408
|
+
if dotted_text:
|
|
2409
|
+
candidate = cur_dir.joinpath(*dotted_text.split(".")).with_suffix(".py")
|
|
2410
|
+
else:
|
|
2411
|
+
candidate = cur_dir / "__init__.py"
|
|
2412
|
+
target_fq = _file_stem(candidate)
|
|
2413
|
+
break
|
|
2414
|
+
if child.type == "dotted_name" and target_fq is None:
|
|
2415
|
+
dotted_name = _text(child)
|
|
2416
|
+
dotted_as_path = "/".join(dotted_name.split("."))
|
|
2417
|
+
if dotted_as_path in stem_to_entities:
|
|
2418
|
+
target_fq = dotted_as_path
|
|
2419
|
+
else:
|
|
2420
|
+
suffix_matches = [
|
|
2421
|
+
fq for fq in stem_to_entities
|
|
2422
|
+
if fq.endswith(f"/{dotted_as_path}") or fq.endswith(f"\\{dotted_as_path}")
|
|
2423
|
+
]
|
|
2424
|
+
if len(suffix_matches) == 1:
|
|
2425
|
+
target_fq = suffix_matches[0]
|
|
2426
|
+
else:
|
|
2427
|
+
bare = dotted_name.split(".")[-1]
|
|
2428
|
+
target_fq = bare_to_qualified.get(bare)
|
|
2429
|
+
|
|
2430
|
+
if not target_fq or target_fq not in stem_to_entities:
|
|
2431
|
+
return
|
|
2432
|
+
|
|
2433
|
+
# Imported names come AFTER the 'import' keyword token. For
|
|
2434
|
+
# `import X as Y` the target is found via X but the body uses Y.
|
|
2435
|
+
past_import_kw = False
|
|
2436
|
+
for child in node.children:
|
|
2437
|
+
if child.type == "import":
|
|
2438
|
+
past_import_kw = True
|
|
2439
|
+
continue
|
|
2440
|
+
if not past_import_kw:
|
|
2441
|
+
continue
|
|
2442
|
+
imported_name: str | None = None
|
|
2443
|
+
local_name: str | None = None
|
|
2444
|
+
if child.type == "dotted_name":
|
|
2445
|
+
imported_name = local_name = _text(child)
|
|
2446
|
+
elif child.type == "aliased_import":
|
|
2447
|
+
name_node = child.child_by_field_name("name")
|
|
2448
|
+
alias_node = child.child_by_field_name("alias")
|
|
2449
|
+
if name_node is not None:
|
|
2450
|
+
imported_name = _text(name_node)
|
|
2451
|
+
local_name = _text(alias_node) if alias_node is not None else imported_name
|
|
2452
|
+
if not imported_name or not local_name:
|
|
2453
|
+
continue
|
|
2454
|
+
tgt_nid = stem_to_entities[target_fq].get(imported_name)
|
|
2455
|
+
if tgt_nid:
|
|
2456
|
+
import_targets[local_name] = tgt_nid
|
|
2457
|
+
|
|
2458
|
+
def visit(node, current_nid: str | None) -> None:
|
|
2459
|
+
# Identifiers inside an import statement are the import itself, not a
|
|
2460
|
+
# real use — resolve the import here and don't descend into it.
|
|
2461
|
+
if node.type == "import_from_statement":
|
|
2462
|
+
resolve_import(node)
|
|
2463
|
+
return
|
|
2464
|
+
# Attribute references to the top-level symbol that contains them: a
|
|
2465
|
+
# class is a unit (a reference inside one of its methods counts for
|
|
2466
|
+
# the class, matching the documented DigestAuth->Response edge), and
|
|
2467
|
+
# a module-level function is its own source. Only set at module scope
|
|
2468
|
+
# (current_nid is None) so nested defs never override the container.
|
|
2469
|
+
if current_nid is None and node.type in ("class_definition", "function_definition"):
|
|
2470
|
+
name_node = node.child_by_field_name("name")
|
|
2471
|
+
if name_node is not None:
|
|
2472
|
+
mapped = name_to_nid.get(_text(name_node))
|
|
2473
|
+
if mapped is not None:
|
|
2474
|
+
current_nid = mapped
|
|
2475
|
+
if node.type == "identifier" and current_nid is not None:
|
|
2476
|
+
slot = ref_sources.setdefault(_text(node), {})
|
|
2477
|
+
slot.setdefault(current_nid, node.start_point[0] + 1)
|
|
2478
|
+
for child in node.children:
|
|
2479
|
+
visit(child, current_nid)
|
|
2480
|
+
|
|
2481
|
+
visit(root_node, None)
|
|
2482
|
+
|
|
2483
|
+
for name, tgt_nid in import_targets.items():
|
|
2484
|
+
for src_nid, line in ref_sources.get(name, {}).items():
|
|
2485
|
+
if src_nid == tgt_nid:
|
|
2486
|
+
continue
|
|
2487
|
+
new_edges.append({
|
|
2488
|
+
"source": src_nid,
|
|
2489
|
+
"target": tgt_nid,
|
|
2490
|
+
"relation": "uses",
|
|
2491
|
+
"confidence": "INFERRED",
|
|
2492
|
+
# 0.95 = "direct structural evidence (named cross-file
|
|
2493
|
+
# reference)" from the extraction-spec rubric, which is
|
|
2494
|
+
# exactly what this edge is: a name this file imports, then
|
|
2495
|
+
# references. Omitting the score entirely fell through to the
|
|
2496
|
+
# 0.5 default the same rubric forbids outright (#2813).
|
|
2497
|
+
"confidence_score": 0.95,
|
|
2498
|
+
"source_file": str_path,
|
|
2499
|
+
"source_location": f"L{line}",
|
|
2500
|
+
"weight": 0.8,
|
|
2501
|
+
})
|
|
2502
|
+
|
|
2503
|
+
# Repoint AST type-reference and inheritance edges from sourceless stubs
|
|
2504
|
+
# to the exact imported target definition (#3252).
|
|
2505
|
+
if all_edges and import_targets:
|
|
2506
|
+
for edge in all_edges:
|
|
2507
|
+
if edge.get("source_file") not in file_srcs:
|
|
2508
|
+
continue
|
|
2509
|
+
if edge.get("relation") not in TYPE_REPOINT_RELATIONS:
|
|
2510
|
+
continue
|
|
2511
|
+
tgt = edge.get("target")
|
|
2512
|
+
tgt_node = node_by_id.get(tgt)
|
|
2513
|
+
if not tgt_node or tgt_node.get("source_file"):
|
|
2514
|
+
continue
|
|
2515
|
+
stub_label = tgt_node.get("label", "")
|
|
2516
|
+
resolved_id = import_targets.get(stub_label)
|
|
2517
|
+
if resolved_id and resolved_id != tgt:
|
|
2518
|
+
edge["target"] = resolved_id
|
|
2519
|
+
repointed_from.add(tgt)
|
|
2520
|
+
|
|
2521
|
+
if all_nodes is not None and all_edges is not None and repointed_from:
|
|
2522
|
+
still_referenced = {e.get("source") for e in all_edges} | {e.get("target") for e in all_edges}
|
|
2523
|
+
all_nodes[:] = [
|
|
2524
|
+
node for node in all_nodes
|
|
2525
|
+
if node.get("id") not in repointed_from or node.get("id") in still_referenced
|
|
2526
|
+
]
|
|
2527
|
+
|
|
2528
|
+
return new_edges
|
|
2529
|
+
|
|
2530
|
+
_DECLDEF_HEADER_SUFFIXES = frozenset({".h", ".hpp", ".hh", ".hxx"})
|
|
2531
|
+
|
|
2532
|
+
_DECLDEF_IMPL_SUFFIXES = frozenset({".m", ".mm", ".cpp", ".cc", ".cxx", ".c"})
|
|
2533
|
+
|
|
2534
|
+
def _decldef_class_stem(source_file: str) -> tuple[str, str] | None:
|
|
2535
|
+
"""Return ``(dir, base_stem)`` for a header/impl source file, else None.
|
|
2536
|
+
|
|
2537
|
+
The base stem strips an ObjC category suffix (``Foo+Cat.m`` -> ``Foo``) so a
|
|
2538
|
+
category implementation pairs with its ``Foo.h`` declaration. Files with an
|
|
2539
|
+
extension that is neither a header nor an impl extension return None and are
|
|
2540
|
+
never considered for the merge.
|
|
2541
|
+
"""
|
|
2542
|
+
if not source_file:
|
|
2543
|
+
return None
|
|
2544
|
+
p = Path(source_file)
|
|
2545
|
+
suffix = p.suffix.lower()
|
|
2546
|
+
if suffix not in _DECLDEF_HEADER_SUFFIXES and suffix not in _DECLDEF_IMPL_SUFFIXES:
|
|
2547
|
+
return None
|
|
2548
|
+
stem = p.stem.split("+", 1)[0] # ObjC category: Foo+Cat -> Foo
|
|
2549
|
+
if not stem:
|
|
2550
|
+
return None
|
|
2551
|
+
return (str(p.parent), stem)
|
|
2552
|
+
|
|
2553
|
+
def _source_stem(node: dict) -> str:
|
|
2554
|
+
"""Filename stem of a node's ``source_file`` (``""`` when it has none)."""
|
|
2555
|
+
return Path(str(node.get("source_file", ""))).stem
|
|
2556
|
+
|
|
2557
|
+
|
|
2558
|
+
def _merge_decl_def_classes(
|
|
2559
|
+
all_nodes: list[dict],
|
|
2560
|
+
all_edges: list[dict],
|
|
2561
|
+
) -> None:
|
|
2562
|
+
"""Merge a class (and its methods) declared in a header with its definition in
|
|
2563
|
+
a sibling impl file into ONE node, for C/C++/ObjC (#1547, #1556).
|
|
2564
|
+
|
|
2565
|
+
A class declared in ``Foo.h`` (``class Foo`` / ``@interface Foo``) and defined
|
|
2566
|
+
in the sibling ``Foo.cpp`` / ``Foo.m`` (``@implementation Foo``, plus — after
|
|
2567
|
+
the C++ qualified-name fix — out-of-class method definitions ``Foo::bar``)
|
|
2568
|
+
produces TWO nodes per symbol. Both are keyed off the file *stem*, and
|
|
2569
|
+
``_file_stem`` drops the extension, so the header symbol and its impl
|
|
2570
|
+
counterpart get the IDENTICAL id and differ only in ``source_file`` and label
|
|
2571
|
+
(the C++ def label is ``Foo::bar()`` vs the decl's ``bar``; the ObjC impl class
|
|
2572
|
+
label equals the interface's). Left alone, ``_disambiguate_colliding_node_ids``
|
|
2573
|
+
SPLITS those id-collisions apart by path, fragmenting one class into two def
|
|
2574
|
+
nodes — which then trips every resolver's single-definition god-node guard
|
|
2575
|
+
(``len(defs) != 1`` -> bail), cascading into lost .h<->.m/.cpp linkage and dead
|
|
2576
|
+
cross-file calls.
|
|
2577
|
+
|
|
2578
|
+
This pass runs BEFORE disambiguation and collapses each such id-collision to
|
|
2579
|
+
ONE node — the header (declaration) variant, consistent with the #1475
|
|
2580
|
+
header_remaps direction — so disambiguation sees a single source_file per id
|
|
2581
|
+
and leaves it alone, and the downstream resolvers see ONE definition. Because
|
|
2582
|
+
the colliding nodes already share an id, no edge re-pointing is needed: every
|
|
2583
|
+
edge that referenced the impl symbol already points at the surviving id. We
|
|
2584
|
+
only drop the redundant duplicate node and prefer the header's label — but
|
|
2585
|
+
the dropped impl node's provenance is preserved on the survivor as
|
|
2586
|
+
``definition_file`` / ``definition_location``, so the definition site is
|
|
2587
|
+
still reachable from the merged node.
|
|
2588
|
+
|
|
2589
|
+
GOD-NODE GUARDS (false merges are the main risk):
|
|
2590
|
+
|
|
2591
|
+
* Collapse fires ONLY when every node in an id-collision group comes from a
|
|
2592
|
+
SIBLING header/impl set — same directory, same base stem (ObjC categories
|
|
2593
|
+
``Foo+Cat.m`` compare by the stem before ``+``), header extension paired
|
|
2594
|
+
with impl extension — AND the group contains exactly ONE header file.
|
|
2595
|
+
* Two unrelated ``class Logger`` in DIFFERENT directories never collide on id
|
|
2596
|
+
(the id embeds the full file stem / directory path), so they are never
|
|
2597
|
+
grouped and never merge. Two same-named classes in the SAME directory but
|
|
2598
|
+
different base stems likewise key to different ids. Any id-collision that
|
|
2599
|
+
is NOT a clean single-header sibling set is left untouched for
|
|
2600
|
+
disambiguation to split (the conservative default).
|
|
2601
|
+
|
|
2602
|
+
The class and its method/field members fold in together: members are keyed
|
|
2603
|
+
``_make_id(class_id, name)`` (ObjC) or, for an out-of-class C++ definition,
|
|
2604
|
+
``_make_id(stem, "Foo::bar")`` which normalizes to the same id as the in-class
|
|
2605
|
+
member ``_make_id(class_id, "bar")``. So every decl/def member pair is itself an
|
|
2606
|
+
id-collision across the same sibling file set and collapses by the same rule.
|
|
2607
|
+
"""
|
|
2608
|
+
# Group every code node by id, recording the distinct source files involved.
|
|
2609
|
+
by_id: dict[str, list[dict]] = {}
|
|
2610
|
+
for n in all_nodes:
|
|
2611
|
+
if n.get("file_type") != "code":
|
|
2612
|
+
continue
|
|
2613
|
+
nid = n.get("id")
|
|
2614
|
+
sf = str(n.get("source_file", ""))
|
|
2615
|
+
if not isinstance(nid, str) or not nid or not sf:
|
|
2616
|
+
continue
|
|
2617
|
+
by_id.setdefault(nid, []).append(n)
|
|
2618
|
+
|
|
2619
|
+
# Identify, per surviving id, which node to keep (header preferred). We can't
|
|
2620
|
+
# mutate all_nodes mid-scan, so collect a set of node object ids to drop.
|
|
2621
|
+
drop_objs: set[int] = set()
|
|
2622
|
+
for nid, group in by_id.items():
|
|
2623
|
+
if len(group) < 2:
|
|
2624
|
+
continue
|
|
2625
|
+
# The distinct source files of this collision must form a clean sibling
|
|
2626
|
+
# header/impl set with exactly one header. Each file must parse as a
|
|
2627
|
+
# header/impl file (others -> bail), share one directory + base stem.
|
|
2628
|
+
sibling_keys: set[tuple[str, str]] = set()
|
|
2629
|
+
headers: list[dict] = []
|
|
2630
|
+
ok = True
|
|
2631
|
+
for node in group:
|
|
2632
|
+
sf = str(node.get("source_file", ""))
|
|
2633
|
+
ds = _decldef_class_stem(sf)
|
|
2634
|
+
if ds is None:
|
|
2635
|
+
ok = False
|
|
2636
|
+
break
|
|
2637
|
+
sibling_keys.add(ds)
|
|
2638
|
+
if Path(sf).suffix.lower() in _DECLDEF_HEADER_SUFFIXES:
|
|
2639
|
+
headers.append(node)
|
|
2640
|
+
if not ok:
|
|
2641
|
+
continue
|
|
2642
|
+
# All from one (dir, base_stem) sibling family. Pick the declaring header.
|
|
2643
|
+
# Usually there is exactly one. An ObjC class whose members are split across
|
|
2644
|
+
# categories has several (`Foo.h`, `Foo+Cat.h`) — fold those too, keeping the
|
|
2645
|
+
# BASE header (the stem with no `+`), or the lowest-sorting category header
|
|
2646
|
+
# when the base class lives outside the corpus (`NSString+Trim.h`). Two
|
|
2647
|
+
# NON-category headers still bail to disambiguation, as before, so an
|
|
2648
|
+
# unrelated `Foo.h` / `Foo.hpp` pair is untouched.
|
|
2649
|
+
if len(sibling_keys) != 1 or not headers:
|
|
2650
|
+
continue
|
|
2651
|
+
if len(headers) == 1:
|
|
2652
|
+
keeper = headers[0]
|
|
2653
|
+
else:
|
|
2654
|
+
base_headers = [h for h in headers if "+" not in _source_stem(h)]
|
|
2655
|
+
if len(base_headers) > 1:
|
|
2656
|
+
continue
|
|
2657
|
+
keeper = base_headers[0] if base_headers else min(headers, key=_source_stem)
|
|
2658
|
+
# The keeper is the DECLARATION, so without this the graph reports the
|
|
2659
|
+
# header as the symbol's only location and the definition site — the file
|
|
2660
|
+
# and line a reader actually wants — is discarded with the dropped node.
|
|
2661
|
+
# Recorded as separate attributes: the survivor's id, label and
|
|
2662
|
+
# source_file are untouched, so no existing graph is re-keyed, no edge
|
|
2663
|
+
# moves, and the single-definition guarantee downstream resolvers rely on
|
|
2664
|
+
# is unchanged. Chosen deterministically (lowest source_file, then
|
|
2665
|
+
# location) so an ObjC class whose members are split across several
|
|
2666
|
+
# category impls does not depend on node arrival order.
|
|
2667
|
+
impls = sorted(
|
|
2668
|
+
(n for n in group
|
|
2669
|
+
if n is not keeper
|
|
2670
|
+
and Path(str(n.get("source_file", ""))).suffix.lower()
|
|
2671
|
+
in _DECLDEF_IMPL_SUFFIXES),
|
|
2672
|
+
key=lambda n: (str(n.get("source_file", "")),
|
|
2673
|
+
str(n.get("source_location", ""))),
|
|
2674
|
+
)
|
|
2675
|
+
if impls:
|
|
2676
|
+
definition = impls[0]
|
|
2677
|
+
if definition.get("source_file"):
|
|
2678
|
+
keeper["definition_file"] = definition["source_file"]
|
|
2679
|
+
if definition.get("source_location"):
|
|
2680
|
+
keeper["definition_location"] = definition["source_location"]
|
|
2681
|
+
|
|
2682
|
+
for node in group:
|
|
2683
|
+
if node is not keeper:
|
|
2684
|
+
drop_objs.add(id(node))
|
|
2685
|
+
|
|
2686
|
+
if not drop_objs:
|
|
2687
|
+
return
|
|
2688
|
+
|
|
2689
|
+
# Drop the redundant duplicate nodes. The surviving (header) node keeps its
|
|
2690
|
+
# own label/source_file (and now carries the impl's definition_file/
|
|
2691
|
+
# definition_location); edges are unchanged because the id is identical. Then
|
|
2692
|
+
# de-dup any now-identical edges (e.g. the impl file's `contains`/`method`
|
|
2693
|
+
# edge that duplicates the header's after the collapse).
|
|
2694
|
+
all_nodes[:] = [n for n in all_nodes if id(n) not in drop_objs]
|
|
2695
|
+
|
|
2696
|
+
seen_keys: set[tuple] = set()
|
|
2697
|
+
rewritten: list[dict] = []
|
|
2698
|
+
for e in all_edges:
|
|
2699
|
+
src = e.get("source")
|
|
2700
|
+
tgt = e.get("target")
|
|
2701
|
+
if src == tgt:
|
|
2702
|
+
continue
|
|
2703
|
+
k = (src, tgt, e.get("relation"), e.get("context"))
|
|
2704
|
+
if k in seen_keys:
|
|
2705
|
+
continue
|
|
2706
|
+
seen_keys.add(k)
|
|
2707
|
+
rewritten.append(e)
|
|
2708
|
+
all_edges[:] = rewritten
|
|
2709
|
+
|
|
2710
|
+
def _resolve_cross_file_java_imports(
|
|
2711
|
+
per_file: list[dict],
|
|
2712
|
+
paths: list[Path],
|
|
2713
|
+
) -> list[dict]:
|
|
2714
|
+
"""Two-pass Java import resolution.
|
|
2715
|
+
|
|
2716
|
+
Pass 1: build a global index {ClassName: [(node_id, package), ...]} across
|
|
2717
|
+
all Java nodes (packages come from a re-parse; node metadata doesn't carry
|
|
2718
|
+
them).
|
|
2719
|
+
Pass 2: re-parse each Java file; for every `import a.b.C;`, resolve C against
|
|
2720
|
+
the index, skipping candidates whose defining file declares a different
|
|
2721
|
+
package — an external `org.springframework.stereotype.Component` must not
|
|
2722
|
+
link to a local `com.example.model.Component` (#2504). Wildcard and stdlib
|
|
2723
|
+
imports produce no edge.
|
|
2724
|
+
"""
|
|
2725
|
+
try:
|
|
2726
|
+
import tree_sitter_java as tsjava
|
|
2727
|
+
from tree_sitter import Language, Parser
|
|
2728
|
+
except ImportError:
|
|
2729
|
+
return []
|
|
2730
|
+
|
|
2731
|
+
language = Language(tsjava.language())
|
|
2732
|
+
parser = Parser(language)
|
|
2733
|
+
|
|
2734
|
+
# Pre-pass: declared package per source_file string (and parsed trees for
|
|
2735
|
+
# pass 2, so each file is only parsed once).
|
|
2736
|
+
parsed: dict[str, tuple[bytes, object]] = {}
|
|
2737
|
+
pkg_by_src: dict[str, str] = {}
|
|
2738
|
+
for path, file_result in zip(paths, per_file):
|
|
2739
|
+
try:
|
|
2740
|
+
source = path.read_bytes()
|
|
2741
|
+
tree = parser.parse(source)
|
|
2742
|
+
except Exception:
|
|
2743
|
+
continue
|
|
2744
|
+
parsed[str(path)] = (source, tree)
|
|
2745
|
+
pkg = ""
|
|
2746
|
+
for child in tree.root_node.children:
|
|
2747
|
+
if child.type == "package_declaration":
|
|
2748
|
+
pkg = _read_text(child, source).strip()[len("package"):].strip().rstrip(";").strip()
|
|
2749
|
+
break
|
|
2750
|
+
pkg_by_src[str(path)] = pkg
|
|
2751
|
+
for node in file_result.get("nodes", []):
|
|
2752
|
+
src = node.get("source_file")
|
|
2753
|
+
if src:
|
|
2754
|
+
pkg_by_src.setdefault(src, pkg)
|
|
2755
|
+
|
|
2756
|
+
def _pkg_matches(imp_pkg: str, tgt_pkg: str) -> bool:
|
|
2757
|
+
if imp_pkg == tgt_pkg:
|
|
2758
|
+
return True
|
|
2759
|
+
# `import p.Outer.Inner` against a nested type defined in package p:
|
|
2760
|
+
# the leftover segments must all be type-like (uppercase-first), which
|
|
2761
|
+
# conventional lowercase external packages can never satisfy.
|
|
2762
|
+
if tgt_pkg:
|
|
2763
|
+
if not imp_pkg.startswith(tgt_pkg + "."):
|
|
2764
|
+
return False
|
|
2765
|
+
rest = imp_pkg[len(tgt_pkg) + 1:]
|
|
2766
|
+
else:
|
|
2767
|
+
rest = imp_pkg
|
|
2768
|
+
return bool(rest) and all(seg[:1].isupper() for seg in rest.split("."))
|
|
2769
|
+
|
|
2770
|
+
# Pass 1: class-name → (node_id, package) index (only internal,
|
|
2771
|
+
# uppercase-starting names)
|
|
2772
|
+
name_to_ids: dict[str, list[tuple[str, str]]] = {}
|
|
2773
|
+
for file_result in per_file:
|
|
2774
|
+
for node in file_result.get("nodes", []):
|
|
2775
|
+
label = node.get("label", "")
|
|
2776
|
+
nid = node.get("id", "")
|
|
2777
|
+
src = node.get("source_file", "")
|
|
2778
|
+
if not label or not nid or not src:
|
|
2779
|
+
continue
|
|
2780
|
+
if label.endswith(")") or label.endswith(".java"):
|
|
2781
|
+
continue
|
|
2782
|
+
if not label[0].isalpha() or not label[0].isupper():
|
|
2783
|
+
continue
|
|
2784
|
+
name_to_ids.setdefault(label, []).append((nid, pkg_by_src.get(src, "")))
|
|
2785
|
+
|
|
2786
|
+
# Pass 2: resolve imports to real node IDs
|
|
2787
|
+
new_edges: list[dict] = []
|
|
2788
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
2789
|
+
for path in paths:
|
|
2790
|
+
file_nid = _make_id(str(path))
|
|
2791
|
+
entry = parsed.get(str(path))
|
|
2792
|
+
if entry is None:
|
|
2793
|
+
continue
|
|
2794
|
+
source, tree = entry
|
|
2795
|
+
|
|
2796
|
+
def walk(n) -> None:
|
|
2797
|
+
if n.type == "import_declaration":
|
|
2798
|
+
raw = _read_text(n, source).strip()
|
|
2799
|
+
body = raw[len("import"):].strip().rstrip(";").strip()
|
|
2800
|
+
if body.startswith("static "):
|
|
2801
|
+
body = body[len("static "):].strip()
|
|
2802
|
+
if body.endswith(".*"):
|
|
2803
|
+
return
|
|
2804
|
+
parts = body.split(".")
|
|
2805
|
+
if not parts:
|
|
2806
|
+
return
|
|
2807
|
+
last = parts[-1]
|
|
2808
|
+
imp_pkg = ".".join(parts[:-1])
|
|
2809
|
+
if last and last[0].islower() and len(parts) >= 2:
|
|
2810
|
+
last = parts[-2]
|
|
2811
|
+
imp_pkg = ".".join(parts[:-2])
|
|
2812
|
+
at_line = n.start_point[0] + 1
|
|
2813
|
+
for tgt_nid, tgt_pkg in name_to_ids.get(last, []):
|
|
2814
|
+
if tgt_nid == file_nid:
|
|
2815
|
+
continue
|
|
2816
|
+
if not _pkg_matches(imp_pkg, tgt_pkg):
|
|
2817
|
+
continue
|
|
2818
|
+
key = (file_nid, tgt_nid)
|
|
2819
|
+
if key in seen_pairs:
|
|
2820
|
+
continue
|
|
2821
|
+
seen_pairs.add(key)
|
|
2822
|
+
new_edges.append({
|
|
2823
|
+
"source": file_nid,
|
|
2824
|
+
"target": tgt_nid,
|
|
2825
|
+
"relation": "imports",
|
|
2826
|
+
"confidence": "EXTRACTED",
|
|
2827
|
+
"confidence_score": 1.0,
|
|
2828
|
+
"source_file": str(path),
|
|
2829
|
+
"source_location": f"L{at_line}",
|
|
2830
|
+
"weight": 1.0,
|
|
2831
|
+
})
|
|
2832
|
+
for child in n.children:
|
|
2833
|
+
walk(child)
|
|
2834
|
+
|
|
2835
|
+
walk(tree.root_node)
|
|
2836
|
+
|
|
2837
|
+
return new_edges
|
|
2838
|
+
|
|
2839
|
+
|
|
2840
|
+
def _go_import_path_for_file(
|
|
2841
|
+
source_file: str | Path,
|
|
2842
|
+
root: Path,
|
|
2843
|
+
module_cache: dict[Path, str | None] | None = None,
|
|
2844
|
+
) -> str | None:
|
|
2845
|
+
"""Return the canonical Go import path for a source file inside a module."""
|
|
2846
|
+
cache = module_cache if module_cache is not None else {}
|
|
2847
|
+
path = Path(source_file)
|
|
2848
|
+
if not path.is_absolute():
|
|
2849
|
+
path = root / path
|
|
2850
|
+
try:
|
|
2851
|
+
directory = _resolve_cached(path).parent
|
|
2852
|
+
except OSError:
|
|
2853
|
+
directory = path.absolute().parent
|
|
2854
|
+
|
|
2855
|
+
module_dir: Path | None = None
|
|
2856
|
+
module_path: str | None = None
|
|
2857
|
+
for candidate in (directory, *directory.parents):
|
|
2858
|
+
if candidate in cache:
|
|
2859
|
+
cached = cache[candidate]
|
|
2860
|
+
if cached:
|
|
2861
|
+
module_dir, module_path = candidate, cached
|
|
2862
|
+
break
|
|
2863
|
+
go_mod = candidate / "go.mod"
|
|
2864
|
+
if not go_mod.is_file():
|
|
2865
|
+
continue
|
|
2866
|
+
try:
|
|
2867
|
+
match = re.search(
|
|
2868
|
+
r"(?m)^\s*module\s+([^\s]+)",
|
|
2869
|
+
go_mod.read_text(encoding="utf-8"),
|
|
2870
|
+
)
|
|
2871
|
+
except (OSError, UnicodeError):
|
|
2872
|
+
match = None
|
|
2873
|
+
module_dir = candidate
|
|
2874
|
+
module_path = match.group(1) if match else None
|
|
2875
|
+
cache[candidate] = module_path
|
|
2876
|
+
break
|
|
2877
|
+
|
|
2878
|
+
if not module_dir or not module_path:
|
|
2879
|
+
return None
|
|
2880
|
+
try:
|
|
2881
|
+
relative_dir = directory.relative_to(module_dir)
|
|
2882
|
+
except ValueError:
|
|
2883
|
+
return None
|
|
2884
|
+
suffix = relative_dir.as_posix()
|
|
2885
|
+
return module_path if suffix == "." else f"{module_path}/{suffix}"
|
|
2886
|
+
|
|
2887
|
+
|
|
2888
|
+
def _resolve_go_type_references(
|
|
2889
|
+
per_file: list[dict],
|
|
2890
|
+
paths: list[Path],
|
|
2891
|
+
all_nodes: list[dict],
|
|
2892
|
+
all_edges: list[dict],
|
|
2893
|
+
root: Path,
|
|
2894
|
+
resolution_context_nodes: list[dict] | None = None,
|
|
2895
|
+
resolution_context_edges: list[dict] | None = None,
|
|
2896
|
+
) -> None:
|
|
2897
|
+
"""Resolve qualified Go types through aliases and exact module paths."""
|
|
2898
|
+
imports_by_file: dict[str, dict[str, str]] = {}
|
|
2899
|
+
actual_path_by_file: dict[str, Path] = {}
|
|
2900
|
+
for path, result in zip(paths, per_file):
|
|
2901
|
+
imports = result.get("go_imports") or {}
|
|
2902
|
+
for node in result.get("nodes", []):
|
|
2903
|
+
source_file = node.get("source_file")
|
|
2904
|
+
if source_file:
|
|
2905
|
+
imports_by_file[str(source_file)] = imports
|
|
2906
|
+
actual_path_by_file[str(source_file)] = path
|
|
2907
|
+
|
|
2908
|
+
if not imports_by_file:
|
|
2909
|
+
return
|
|
2910
|
+
|
|
2911
|
+
definition_nodes = all_nodes + (resolution_context_nodes or [])
|
|
2912
|
+
definition_edges = all_edges + (resolution_context_edges or [])
|
|
2913
|
+
contained = {edge.get("target") for edge in definition_edges
|
|
2914
|
+
if edge.get("relation") == "contains"}
|
|
2915
|
+
module_cache: dict[Path, str | None] = {}
|
|
2916
|
+
fqn_to_ids: dict[str, list[str]] = {}
|
|
2917
|
+
for node in definition_nodes:
|
|
2918
|
+
source_file = str(node.get("source_file") or "")
|
|
2919
|
+
label = str(node.get("label") or "")
|
|
2920
|
+
nid = node.get("id")
|
|
2921
|
+
if (not source_file or not label or not nid or nid not in contained
|
|
2922
|
+
or not _is_type_like_definition(node)):
|
|
2923
|
+
continue
|
|
2924
|
+
actual_path = actual_path_by_file.get(source_file, Path(source_file))
|
|
2925
|
+
package_path = _go_import_path_for_file(actual_path, root, module_cache)
|
|
2926
|
+
if package_path:
|
|
2927
|
+
fqn_to_ids.setdefault(f"{package_path}.{label}", []).append(nid)
|
|
2928
|
+
|
|
2929
|
+
qualified_stubs = {
|
|
2930
|
+
node["id"]: str(node.get("label") or "")
|
|
2931
|
+
for node in all_nodes
|
|
2932
|
+
if node.get("id") and not node.get("source_file")
|
|
2933
|
+
and "." in str(node.get("label") or "")
|
|
2934
|
+
}
|
|
2935
|
+
if not qualified_stubs:
|
|
2936
|
+
return
|
|
2937
|
+
|
|
2938
|
+
node_ids = {node.get("id") for node in all_nodes if node.get("id")}
|
|
2939
|
+
external_stub_ids: dict[str, str] = {}
|
|
2940
|
+
new_nodes: list[dict] = []
|
|
2941
|
+
|
|
2942
|
+
def external_stub(fqn: str) -> str:
|
|
2943
|
+
existing = external_stub_ids.get(fqn)
|
|
2944
|
+
if existing:
|
|
2945
|
+
return existing
|
|
2946
|
+
nid = _make_id("go", "type", fqn)
|
|
2947
|
+
if nid not in node_ids:
|
|
2948
|
+
new_nodes.append({
|
|
2949
|
+
"id": nid,
|
|
2950
|
+
"label": fqn,
|
|
2951
|
+
"file_type": "code",
|
|
2952
|
+
"source_file": "",
|
|
2953
|
+
"source_location": "",
|
|
2954
|
+
})
|
|
2955
|
+
node_ids.add(nid)
|
|
2956
|
+
external_stub_ids[fqn] = nid
|
|
2957
|
+
return nid
|
|
2958
|
+
|
|
2959
|
+
repointed_from: set[str] = set()
|
|
2960
|
+
for edge in all_edges:
|
|
2961
|
+
if edge.get("relation") not in {"references", "embeds"}:
|
|
2962
|
+
continue
|
|
2963
|
+
target = edge.get("target")
|
|
2964
|
+
qualified = qualified_stubs.get(target)
|
|
2965
|
+
if not qualified:
|
|
2966
|
+
continue
|
|
2967
|
+
alias, _, type_name = qualified.rpartition(".")
|
|
2968
|
+
import_path = imports_by_file.get(
|
|
2969
|
+
str(edge.get("source_file") or ""), {}
|
|
2970
|
+
).get(alias)
|
|
2971
|
+
if not import_path or not type_name:
|
|
2972
|
+
continue
|
|
2973
|
+
fqn = f"{import_path}.{type_name}"
|
|
2974
|
+
candidates = fqn_to_ids.get(fqn, [])
|
|
2975
|
+
edge["target"] = candidates[0] if len(candidates) == 1 else external_stub(fqn)
|
|
2976
|
+
repointed_from.add(str(target))
|
|
2977
|
+
|
|
2978
|
+
if new_nodes:
|
|
2979
|
+
all_nodes.extend(new_nodes)
|
|
2980
|
+
|
|
2981
|
+
if not repointed_from:
|
|
2982
|
+
return
|
|
2983
|
+
referenced = {endpoint for edge in all_edges
|
|
2984
|
+
for endpoint in (edge.get("source"), edge.get("target"))}
|
|
2985
|
+
all_nodes[:] = [
|
|
2986
|
+
node for node in all_nodes
|
|
2987
|
+
if node.get("id") not in repointed_from or node.get("id") in referenced
|
|
2988
|
+
]
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _resolve_java_type_references(
|
|
2992
|
+
per_file: list[dict],
|
|
2993
|
+
paths: list[Path],
|
|
2994
|
+
all_nodes: list[dict],
|
|
2995
|
+
all_edges: list[dict],
|
|
2996
|
+
) -> None:
|
|
2997
|
+
"""Re-point dangling Java ``implements``/``inherits`` edges to the real
|
|
2998
|
+
definition, using the referencing file's ``import`` statements (+ package)
|
|
2999
|
+
for exact disambiguation.
|
|
3000
|
+
|
|
3001
|
+
Cross-file type references resolve by bare name and fall back to a no-source
|
|
3002
|
+
"shadow" stub. ``_rewire_unique_stub_nodes`` repairs that only when the name
|
|
3003
|
+
is globally unique; when two packages define a same-named type it bails, so
|
|
3004
|
+
the ``implements`` edge stays stuck on the shadow node and the real interface
|
|
3005
|
+
is wrongly isolated (#1318). An ``import com.a.handler.AIResponseHandler``
|
|
3006
|
+
names the exact package, so it disambiguates where bare-name matching cannot.
|
|
3007
|
+
|
|
3008
|
+
Mutates ``all_nodes``/``all_edges`` in place. Runs after id-disambiguation so
|
|
3009
|
+
target ids are final, but BEFORE ``_rewire_unique_stub_nodes`` (#2504): the
|
|
3010
|
+
rewire itself manufactures a false merge when the bare stub for an EXTERNAL
|
|
3011
|
+
import (``org.springframework.stereotype.Component``) collapses onto the
|
|
3012
|
+
only internal class with that simple name. References proven external by an
|
|
3013
|
+
explicit import are re-pointed to an FQN-labeled sourceless stub the
|
|
3014
|
+
bare-label rewire cannot collapse; references with no import/package facts
|
|
3015
|
+
are left untouched so the legacy unique-label rewire keeps handling plain
|
|
3016
|
+
same-package/default-package corpora (mirrors the PHP #1923 fix).
|
|
3017
|
+
"""
|
|
3018
|
+
try:
|
|
3019
|
+
import tree_sitter_java as tsjava
|
|
3020
|
+
from tree_sitter import Language, Parser
|
|
3021
|
+
except ImportError:
|
|
3022
|
+
return
|
|
3023
|
+
|
|
3024
|
+
language = Language(tsjava.language())
|
|
3025
|
+
parser = Parser(language)
|
|
3026
|
+
|
|
3027
|
+
# package + simple-name->FQN imports, keyed by the source_file string the
|
|
3028
|
+
# file's own nodes use (so it matches edge/node source_file exactly).
|
|
3029
|
+
pkg_by_file: dict[str, str] = {}
|
|
3030
|
+
imports_by_file: dict[str, dict[str, str]] = {}
|
|
3031
|
+
for path, result in zip(paths, per_file):
|
|
3032
|
+
srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
|
|
3033
|
+
if not srcs:
|
|
3034
|
+
continue
|
|
3035
|
+
try:
|
|
3036
|
+
source = path.read_bytes()
|
|
3037
|
+
tree = parser.parse(source)
|
|
3038
|
+
except Exception:
|
|
3039
|
+
continue
|
|
3040
|
+
pkg = ""
|
|
3041
|
+
imps: dict[str, str] = {}
|
|
3042
|
+
|
|
3043
|
+
def walk(n) -> None:
|
|
3044
|
+
nonlocal pkg
|
|
3045
|
+
if n.type == "package_declaration":
|
|
3046
|
+
pkg = _read_text(n, source).strip()[len("package"):].strip().rstrip(";").strip()
|
|
3047
|
+
elif n.type == "import_declaration":
|
|
3048
|
+
body = _read_text(n, source).strip()[len("import"):].strip().rstrip(";").strip()
|
|
3049
|
+
if body.startswith("static "):
|
|
3050
|
+
body = body[len("static "):].strip()
|
|
3051
|
+
if body.endswith(".*") or "." not in body:
|
|
3052
|
+
return
|
|
3053
|
+
simple = body.split(".")[-1]
|
|
3054
|
+
if simple and simple[0].isupper():
|
|
3055
|
+
imps[simple] = body
|
|
3056
|
+
for child in n.children:
|
|
3057
|
+
walk(child)
|
|
3058
|
+
|
|
3059
|
+
walk(tree.root_node)
|
|
3060
|
+
for s in srcs:
|
|
3061
|
+
pkg_by_file[s] = pkg
|
|
3062
|
+
imports_by_file[s] = imps
|
|
3063
|
+
|
|
3064
|
+
# FQN (package.Class or package.Outer.Inner) -> definition node id, for
|
|
3065
|
+
# type-like defs with a source. Nested declarations need their containing
|
|
3066
|
+
# type path because qualified annotation references preserve it.
|
|
3067
|
+
fqn_to_id: dict[str, str] = {}
|
|
3068
|
+
node_by_id = {
|
|
3069
|
+
node.get("id"): node for node in all_nodes if node.get("id")
|
|
3070
|
+
}
|
|
3071
|
+
type_parent_by_id: dict[str, str] = {}
|
|
3072
|
+
for edge in all_edges:
|
|
3073
|
+
if edge.get("relation") != "contains":
|
|
3074
|
+
continue
|
|
3075
|
+
child = node_by_id.get(edge.get("target"))
|
|
3076
|
+
parent = node_by_id.get(edge.get("source"))
|
|
3077
|
+
if not child or not parent:
|
|
3078
|
+
continue
|
|
3079
|
+
child_src = child.get("source_file", "")
|
|
3080
|
+
parent_label = parent.get("label", "")
|
|
3081
|
+
if (
|
|
3082
|
+
child_src
|
|
3083
|
+
and parent.get("source_file") == child_src
|
|
3084
|
+
and parent_label[:1].isupper()
|
|
3085
|
+
and not parent_label.endswith(".java")
|
|
3086
|
+
):
|
|
3087
|
+
type_parent_by_id[child["id"]] = parent["id"]
|
|
3088
|
+
|
|
3089
|
+
for node in all_nodes:
|
|
3090
|
+
label = node.get("label", "")
|
|
3091
|
+
src = node.get("source_file", "")
|
|
3092
|
+
nid = node.get("id", "")
|
|
3093
|
+
if not (label and src and nid) or src not in pkg_by_file:
|
|
3094
|
+
continue
|
|
3095
|
+
if not label[:1].isupper() or label.endswith(")") or label.endswith(".java"):
|
|
3096
|
+
continue
|
|
3097
|
+
pkg = pkg_by_file[src]
|
|
3098
|
+
fqn_to_id.setdefault(f"{pkg}.{label}" if pkg else label, nid)
|
|
3099
|
+
type_path = [label]
|
|
3100
|
+
seen = {nid}
|
|
3101
|
+
parent_id = type_parent_by_id.get(nid)
|
|
3102
|
+
while parent_id and parent_id not in seen:
|
|
3103
|
+
seen.add(parent_id)
|
|
3104
|
+
parent = node_by_id[parent_id]
|
|
3105
|
+
type_path.append(parent["label"])
|
|
3106
|
+
parent_id = type_parent_by_id.get(parent_id)
|
|
3107
|
+
if len(type_path) > 1:
|
|
3108
|
+
nested_name = ".".join(reversed(type_path))
|
|
3109
|
+
fqn_to_id.setdefault(
|
|
3110
|
+
f"{pkg}.{nested_name}" if pkg else nested_name,
|
|
3111
|
+
nid,
|
|
3112
|
+
)
|
|
3113
|
+
|
|
3114
|
+
# Shadow stubs: no source_file, type-like label. Dotted labels are included
|
|
3115
|
+
# for qualified inline annotations (`@com.example.anno.Loggable`), which the
|
|
3116
|
+
# engine mints with their full dotted name so a same-named local class can't
|
|
3117
|
+
# absorb them (#2504).
|
|
3118
|
+
stub_label: dict[str, str] = {
|
|
3119
|
+
node["id"]: node.get("label", "")
|
|
3120
|
+
for node in all_nodes
|
|
3121
|
+
if node.get("id") and not node.get("source_file")
|
|
3122
|
+
and (node.get("label", "")[:1].isupper() or "." in node.get("label", ""))
|
|
3123
|
+
}
|
|
3124
|
+
if not stub_label:
|
|
3125
|
+
return
|
|
3126
|
+
|
|
3127
|
+
# `imports` is included so the file-level import edge that also lands on the
|
|
3128
|
+
# shadow stub gets re-pointed too, leaving the stub unreferenced (and dropped).
|
|
3129
|
+
# External/stdlib imports never resolve (no internal def / same-package match),
|
|
3130
|
+
# so their edges correctly stay on their stub. `references` (field/parameter/
|
|
3131
|
+
# return-type uses) is included so a cross-module reference to a same-named
|
|
3132
|
+
# class doesn't dangle on a sourceless phantom node when two packages define
|
|
3133
|
+
# the same simple name — the node itself survives with a path-scoped id, but
|
|
3134
|
+
# the reference must point at the RIGHT one (#1744). Mirrors the C# resolver,
|
|
3135
|
+
# whose REPOINT set already covers `references`.
|
|
3136
|
+
REPOINT_RELATIONS = {"implements", "inherits", "extends", "imports", "references"}
|
|
3137
|
+
|
|
3138
|
+
node_ids = {n.get("id") for n in all_nodes if n.get("id")}
|
|
3139
|
+
external_stub_ids: dict[str, str] = {}
|
|
3140
|
+
new_nodes: list[dict] = []
|
|
3141
|
+
|
|
3142
|
+
def _external_stub(fqn: str) -> str:
|
|
3143
|
+
nid = external_stub_ids.get(fqn)
|
|
3144
|
+
if nid:
|
|
3145
|
+
return nid
|
|
3146
|
+
nid = _make_id(fqn)
|
|
3147
|
+
if nid not in node_ids:
|
|
3148
|
+
new_nodes.append({
|
|
3149
|
+
"id": nid,
|
|
3150
|
+
"label": fqn,
|
|
3151
|
+
"file_type": "code",
|
|
3152
|
+
"source_file": "",
|
|
3153
|
+
"source_location": "",
|
|
3154
|
+
})
|
|
3155
|
+
node_ids.add(nid)
|
|
3156
|
+
external_stub_ids[fqn] = nid
|
|
3157
|
+
return nid
|
|
3158
|
+
|
|
3159
|
+
repointed_from: set[str] = set()
|
|
3160
|
+
for edge in all_edges:
|
|
3161
|
+
if edge.get("relation") not in REPOINT_RELATIONS:
|
|
3162
|
+
continue
|
|
3163
|
+
tgt = edge.get("target")
|
|
3164
|
+
label = stub_label.get(tgt)
|
|
3165
|
+
if not label:
|
|
3166
|
+
continue
|
|
3167
|
+
ref_file = edge.get("source_file", "")
|
|
3168
|
+
if "." in label:
|
|
3169
|
+
# FQN-labeled stub (qualified inline annotation): resolve it against
|
|
3170
|
+
# the internal definitions; an external FQN stays parked as-is.
|
|
3171
|
+
resolved = fqn_to_id.get(label)
|
|
3172
|
+
if resolved and resolved != tgt:
|
|
3173
|
+
edge["target"] = resolved
|
|
3174
|
+
repointed_from.add(tgt)
|
|
3175
|
+
continue
|
|
3176
|
+
fqn = imports_by_file.get(ref_file, {}).get(label)
|
|
3177
|
+
if fqn:
|
|
3178
|
+
resolved = fqn_to_id.get(fqn)
|
|
3179
|
+
if resolved is None:
|
|
3180
|
+
# `import p.Outer.Inner`: strip trailing type-like segments to
|
|
3181
|
+
# find the defining package of an internal nested type.
|
|
3182
|
+
head = fqn.split(".")[:-1]
|
|
3183
|
+
while resolved is None and head and head[-1][:1].isupper():
|
|
3184
|
+
head.pop()
|
|
3185
|
+
resolved = fqn_to_id.get(".".join(head + [label]))
|
|
3186
|
+
if resolved is None:
|
|
3187
|
+
# Explicit import with no internal definition: proven EXTERNAL.
|
|
3188
|
+
# Park the edge on an FQN-labeled stub the bare-name rewire
|
|
3189
|
+
# cannot collapse onto a same-named local class (#2504 — this
|
|
3190
|
+
# is the Java counterpart of the PHP #1923 fix).
|
|
3191
|
+
edge["target"] = _external_stub(fqn)
|
|
3192
|
+
repointed_from.add(tgt)
|
|
3193
|
+
continue
|
|
3194
|
+
else: # same-package reference (no explicit import)
|
|
3195
|
+
pkg = pkg_by_file.get(ref_file, "")
|
|
3196
|
+
resolved = fqn_to_id.get(f"{pkg}.{label}" if pkg else label)
|
|
3197
|
+
if resolved and resolved != tgt:
|
|
3198
|
+
edge["target"] = resolved
|
|
3199
|
+
repointed_from.add(tgt)
|
|
3200
|
+
|
|
3201
|
+
if new_nodes:
|
|
3202
|
+
all_nodes.extend(new_nodes)
|
|
3203
|
+
|
|
3204
|
+
# Bare imported and inline-qualified annotation references can start on
|
|
3205
|
+
# different stubs, then converge on one source-backed node above. Collapse
|
|
3206
|
+
# only indistinguishable Java attribute-reference facts after that rewire.
|
|
3207
|
+
seen_attribute_refs: set[tuple] = set()
|
|
3208
|
+
deduped_edges: list[dict] = []
|
|
3209
|
+
for edge in all_edges:
|
|
3210
|
+
if (
|
|
3211
|
+
edge.get("relation") == "references"
|
|
3212
|
+
and edge.get("context") == "attribute"
|
|
3213
|
+
and edge.get("source_file", "") in pkg_by_file
|
|
3214
|
+
):
|
|
3215
|
+
key = (
|
|
3216
|
+
edge.get("source"),
|
|
3217
|
+
edge.get("target"),
|
|
3218
|
+
edge.get("relation"),
|
|
3219
|
+
edge.get("context"),
|
|
3220
|
+
edge.get("source_file"),
|
|
3221
|
+
edge.get("source_location"),
|
|
3222
|
+
)
|
|
3223
|
+
if key in seen_attribute_refs:
|
|
3224
|
+
continue
|
|
3225
|
+
seen_attribute_refs.add(key)
|
|
3226
|
+
deduped_edges.append(edge)
|
|
3227
|
+
all_edges[:] = deduped_edges
|
|
3228
|
+
|
|
3229
|
+
if not repointed_from:
|
|
3230
|
+
return
|
|
3231
|
+
|
|
3232
|
+
# Drop shadow stubs that no edge references anymore.
|
|
3233
|
+
still_referenced: set[str] = set()
|
|
3234
|
+
for edge in all_edges:
|
|
3235
|
+
still_referenced.add(edge.get("source"))
|
|
3236
|
+
still_referenced.add(edge.get("target"))
|
|
3237
|
+
all_nodes[:] = [
|
|
3238
|
+
node for node in all_nodes
|
|
3239
|
+
if node.get("id") not in repointed_from or node.get("id") in still_referenced
|
|
3240
|
+
]
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
_PHP_SUPERTYPE_RELATIONS = ("inherits", "implements", "mixes_in")
|
|
3244
|
+
_PHP_REPOINT_RELATIONS = frozenset({"inherits", "implements", "mixes_in", "imports", "references"})
|
|
3245
|
+
|
|
3246
|
+
|
|
3247
|
+
def _php_fqn_from_raw(raw: str, ns: str, uses: dict[str, str]) -> str:
|
|
3248
|
+
"""Resolve a raw (possibly qualified) PHP class reference to an FQN.
|
|
3249
|
+
|
|
3250
|
+
PHP name-resolution for class names:
|
|
3251
|
+
\\A\\B -> absolute: A\\B
|
|
3252
|
+
A\\B -> first segment through the `use` map (group-prefix semantics),
|
|
3253
|
+
else relative to the current namespace
|
|
3254
|
+
B -> `use` map, else current namespace (class names do NOT fall
|
|
3255
|
+
back to the global namespace)
|
|
3256
|
+
"""
|
|
3257
|
+
raw = raw.strip()
|
|
3258
|
+
if raw.startswith("\\"):
|
|
3259
|
+
return raw[1:]
|
|
3260
|
+
if "\\" in raw:
|
|
3261
|
+
first, rest = raw.split("\\", 1)
|
|
3262
|
+
mapped = uses.get(first.lower())
|
|
3263
|
+
if mapped:
|
|
3264
|
+
return f"{mapped}\\{rest}"
|
|
3265
|
+
return f"{ns}\\{raw}" if ns else raw
|
|
3266
|
+
mapped = uses.get(raw.lower())
|
|
3267
|
+
if mapped:
|
|
3268
|
+
return mapped
|
|
3269
|
+
return f"{ns}\\{raw}" if ns else raw
|
|
3270
|
+
|
|
3271
|
+
|
|
3272
|
+
def _resolve_php_type_references(
|
|
3273
|
+
per_file: list[dict],
|
|
3274
|
+
paths: list[Path],
|
|
3275
|
+
all_nodes: list[dict],
|
|
3276
|
+
all_edges: list[dict],
|
|
3277
|
+
) -> None:
|
|
3278
|
+
"""Disambiguate PHP inherits/implements/mixes_in/imports/references targets
|
|
3279
|
+
using each file's ``namespace`` declaration and ``use`` imports (#1923).
|
|
3280
|
+
|
|
3281
|
+
Mirrors ``_resolve_java_type_references`` (a re-parse pass), but MUST run
|
|
3282
|
+
BEFORE ``_rewire_unique_stub_nodes``: the false edge is manufactured by the
|
|
3283
|
+
rewire itself — a bare ``Page`` stub collapses onto the only internal class
|
|
3284
|
+
labeled ``Page`` even though the referencing file ``use``d a different
|
|
3285
|
+
namespace (``Filament\\Pages\\Page`` vs ``App\\Models\\Page``). References
|
|
3286
|
+
proven external by a ``use`` FQN or a qualified name are re-pointed to an
|
|
3287
|
+
FQN-labeled sourceless stub, which the bare-label rewire cannot collapse.
|
|
3288
|
+
References with no namespace facts are left untouched so the unique-label
|
|
3289
|
+
rewire keeps handling plain (non-namespaced) PHP as before.
|
|
3290
|
+
"""
|
|
3291
|
+
try:
|
|
3292
|
+
import tree_sitter_php as tsphp
|
|
3293
|
+
from tree_sitter import Language, Parser
|
|
3294
|
+
except ImportError:
|
|
3295
|
+
return
|
|
3296
|
+
|
|
3297
|
+
lang_fn = getattr(tsphp, "language_php", None) or getattr(tsphp, "language", None)
|
|
3298
|
+
if lang_fn is None:
|
|
3299
|
+
return
|
|
3300
|
+
language = Language(lang_fn())
|
|
3301
|
+
parser = Parser(language)
|
|
3302
|
+
|
|
3303
|
+
ns_by_file: dict[str, str] = {}
|
|
3304
|
+
uses_by_file: dict[str, dict[str, str]] = {} # lower alias -> FQN
|
|
3305
|
+
raw_by_file: dict[str, dict[tuple[str, str], str | None]] = {} # (relation, lower bare) -> raw | None(ambiguous)
|
|
3306
|
+
|
|
3307
|
+
for path, result in zip(paths, per_file):
|
|
3308
|
+
srcs = {n.get("source_file") for n in result.get("nodes", []) if n.get("source_file")}
|
|
3309
|
+
if not srcs:
|
|
3310
|
+
continue
|
|
3311
|
+
try:
|
|
3312
|
+
source = path.read_bytes()
|
|
3313
|
+
tree = parser.parse(source)
|
|
3314
|
+
except Exception:
|
|
3315
|
+
continue
|
|
3316
|
+
|
|
3317
|
+
namespaces: list[str] = []
|
|
3318
|
+
uses: dict[str, str] = {}
|
|
3319
|
+
raws: dict[tuple[str, str], str | None] = {}
|
|
3320
|
+
|
|
3321
|
+
def _record_raw(relation: str, raw: str) -> None:
|
|
3322
|
+
bare = raw.rsplit("\\", 1)[-1].strip().lower()
|
|
3323
|
+
if not bare:
|
|
3324
|
+
return
|
|
3325
|
+
key = (relation, bare)
|
|
3326
|
+
if key in raws and raws[key] != raw:
|
|
3327
|
+
raws[key] = None # e.g. `implements A\I, B\I` — never guess
|
|
3328
|
+
else:
|
|
3329
|
+
raws.setdefault(key, raw)
|
|
3330
|
+
|
|
3331
|
+
def _record_use_clause(clause, prefix: str) -> None:
|
|
3332
|
+
target = None
|
|
3333
|
+
alias = None
|
|
3334
|
+
saw_as = False
|
|
3335
|
+
for c in clause.children:
|
|
3336
|
+
if c.type in ("function", "const"):
|
|
3337
|
+
return # not a class import
|
|
3338
|
+
if c.type == "as":
|
|
3339
|
+
saw_as = True
|
|
3340
|
+
elif c.type in ("qualified_name", "name"):
|
|
3341
|
+
if saw_as:
|
|
3342
|
+
alias = _read_text(c, source)
|
|
3343
|
+
elif target is None:
|
|
3344
|
+
target = _read_text(c, source)
|
|
3345
|
+
if not target:
|
|
3346
|
+
return
|
|
3347
|
+
fqn = (f"{prefix}\\{target}" if prefix else target).lstrip("\\")
|
|
3348
|
+
key = (alias or fqn.rsplit("\\", 1)[-1]).strip().lower()
|
|
3349
|
+
if key:
|
|
3350
|
+
uses.setdefault(key, fqn)
|
|
3351
|
+
|
|
3352
|
+
def walk(n) -> None:
|
|
3353
|
+
t = n.type
|
|
3354
|
+
if t == "namespace_definition":
|
|
3355
|
+
for c in n.children:
|
|
3356
|
+
if c.type == "namespace_name":
|
|
3357
|
+
namespaces.append(_read_text(c, source))
|
|
3358
|
+
break
|
|
3359
|
+
elif t == "namespace_use_declaration":
|
|
3360
|
+
prefix = ""
|
|
3361
|
+
group = None
|
|
3362
|
+
for c in n.children:
|
|
3363
|
+
if c.type == "namespace_name":
|
|
3364
|
+
prefix = _read_text(c, source) # group-use prefix
|
|
3365
|
+
elif c.type == "namespace_use_group":
|
|
3366
|
+
group = c
|
|
3367
|
+
elif c.type == "namespace_use_clause":
|
|
3368
|
+
_record_use_clause(c, "")
|
|
3369
|
+
if group is not None:
|
|
3370
|
+
for c in group.children:
|
|
3371
|
+
if c.type == "namespace_use_clause":
|
|
3372
|
+
_record_use_clause(c, prefix)
|
|
3373
|
+
return
|
|
3374
|
+
elif t == "class_declaration":
|
|
3375
|
+
for child in n.children:
|
|
3376
|
+
if child.type == "base_clause":
|
|
3377
|
+
for sub in child.children:
|
|
3378
|
+
if sub.type in ("name", "qualified_name"):
|
|
3379
|
+
_record_raw("inherits", _read_text(sub, source))
|
|
3380
|
+
elif child.type == "class_interface_clause":
|
|
3381
|
+
for sub in child.children:
|
|
3382
|
+
if sub.type in ("name", "qualified_name"):
|
|
3383
|
+
_record_raw("implements", _read_text(sub, source))
|
|
3384
|
+
elif child.type == "declaration_list":
|
|
3385
|
+
for member in child.children:
|
|
3386
|
+
if member.type != "use_declaration":
|
|
3387
|
+
continue
|
|
3388
|
+
for sub in member.children:
|
|
3389
|
+
if sub.type in ("name", "qualified_name"):
|
|
3390
|
+
_record_raw("mixes_in", _read_text(sub, source))
|
|
3391
|
+
for child in n.children:
|
|
3392
|
+
walk(child)
|
|
3393
|
+
|
|
3394
|
+
walk(tree.root_node)
|
|
3395
|
+
if len(set(namespaces)) > 1:
|
|
3396
|
+
continue # multi-namespace file (PSR-1 violation): keep legacy behavior
|
|
3397
|
+
ns = namespaces[0] if namespaces else ""
|
|
3398
|
+
for s in srcs:
|
|
3399
|
+
ns_by_file[s] = ns
|
|
3400
|
+
uses_by_file[s] = uses
|
|
3401
|
+
raw_by_file[s] = raws
|
|
3402
|
+
|
|
3403
|
+
if not ns_by_file:
|
|
3404
|
+
return
|
|
3405
|
+
|
|
3406
|
+
# lower FQN -> definition node id (PHP class names are case-insensitive).
|
|
3407
|
+
fqn_to_id: dict[str, str] = {}
|
|
3408
|
+
for node in all_nodes:
|
|
3409
|
+
label = node.get("label", "")
|
|
3410
|
+
src = node.get("source_file", "")
|
|
3411
|
+
nid = node.get("id", "")
|
|
3412
|
+
if not (label and src and nid) or src not in ns_by_file:
|
|
3413
|
+
continue
|
|
3414
|
+
if label.endswith(")") or "." in label: # methods / file nodes
|
|
3415
|
+
continue
|
|
3416
|
+
ns = ns_by_file[src]
|
|
3417
|
+
fqn = f"{ns}\\{label}" if ns else label
|
|
3418
|
+
fqn_to_id.setdefault(fqn.lower(), nid)
|
|
3419
|
+
|
|
3420
|
+
node_ids = {n.get("id") for n in all_nodes if n.get("id")}
|
|
3421
|
+
stub_label: dict[str, str] = {
|
|
3422
|
+
n["id"]: n.get("label", "")
|
|
3423
|
+
for n in all_nodes
|
|
3424
|
+
if n.get("id") and not n.get("source_file") and n.get("label")
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
external_stub_ids: dict[str, str] = {}
|
|
3428
|
+
new_nodes: list[dict] = []
|
|
3429
|
+
|
|
3430
|
+
def _external_stub(fqn: str) -> str:
|
|
3431
|
+
key = fqn.lower()
|
|
3432
|
+
nid = external_stub_ids.get(key)
|
|
3433
|
+
if nid:
|
|
3434
|
+
return nid
|
|
3435
|
+
nid = _make_id(fqn)
|
|
3436
|
+
if nid not in node_ids:
|
|
3437
|
+
new_nodes.append({
|
|
3438
|
+
"id": nid,
|
|
3439
|
+
"label": fqn,
|
|
3440
|
+
"file_type": "code",
|
|
3441
|
+
"source_file": "",
|
|
3442
|
+
"source_location": "",
|
|
3443
|
+
})
|
|
3444
|
+
node_ids.add(nid)
|
|
3445
|
+
external_stub_ids[key] = nid
|
|
3446
|
+
return nid
|
|
3447
|
+
|
|
3448
|
+
repointed_from: set[str] = set()
|
|
3449
|
+
for edge in all_edges:
|
|
3450
|
+
relation = edge.get("relation")
|
|
3451
|
+
if relation not in _PHP_REPOINT_RELATIONS:
|
|
3452
|
+
continue
|
|
3453
|
+
ref_file = edge.get("source_file", "")
|
|
3454
|
+
if ref_file not in ns_by_file:
|
|
3455
|
+
continue
|
|
3456
|
+
tgt = edge.get("target")
|
|
3457
|
+
label = stub_label.get(tgt)
|
|
3458
|
+
uses = uses_by_file.get(ref_file, {})
|
|
3459
|
+
if not label and relation == "imports":
|
|
3460
|
+
label = next((alias for alias in uses if _make_id(alias) == tgt), "")
|
|
3461
|
+
if not label:
|
|
3462
|
+
continue
|
|
3463
|
+
bare = label.strip().lower()
|
|
3464
|
+
ns = ns_by_file[ref_file]
|
|
3465
|
+
|
|
3466
|
+
raw = None
|
|
3467
|
+
if relation in _PHP_SUPERTYPE_RELATIONS:
|
|
3468
|
+
raw = raw_by_file.get(ref_file, {}).get((relation, bare))
|
|
3469
|
+
|
|
3470
|
+
explicit = False
|
|
3471
|
+
if raw and "\\" in raw:
|
|
3472
|
+
fqn = _php_fqn_from_raw(raw, ns, uses)
|
|
3473
|
+
explicit = True
|
|
3474
|
+
elif bare in uses:
|
|
3475
|
+
fqn = uses[bare]
|
|
3476
|
+
explicit = True
|
|
3477
|
+
elif ns:
|
|
3478
|
+
fqn = f"{ns}\\{label}"
|
|
3479
|
+
else:
|
|
3480
|
+
continue # no namespace facts: legacy unique-label rewire applies
|
|
3481
|
+
|
|
3482
|
+
resolved = fqn_to_id.get(fqn.lower())
|
|
3483
|
+
if resolved and resolved != tgt:
|
|
3484
|
+
edge["target"] = resolved
|
|
3485
|
+
repointed_from.add(tgt)
|
|
3486
|
+
elif explicit and resolved is None:
|
|
3487
|
+
# Proven external: park the edge on an FQN-labeled stub the
|
|
3488
|
+
# bare-name rewire cannot collapse (this is the #1923 fix).
|
|
3489
|
+
edge["target"] = _external_stub(fqn)
|
|
3490
|
+
repointed_from.add(tgt)
|
|
3491
|
+
# non-explicit miss: leave the bare stub for the legacy rewire
|
|
3492
|
+
|
|
3493
|
+
if new_nodes:
|
|
3494
|
+
all_nodes.extend(new_nodes)
|
|
3495
|
+
if not repointed_from:
|
|
3496
|
+
return
|
|
3497
|
+
|
|
3498
|
+
still_referenced: set[str] = set()
|
|
3499
|
+
for edge in all_edges:
|
|
3500
|
+
still_referenced.add(edge.get("source"))
|
|
3501
|
+
still_referenced.add(edge.get("target"))
|
|
3502
|
+
all_nodes[:] = [
|
|
3503
|
+
n for n in all_nodes
|
|
3504
|
+
if n.get("id") not in repointed_from or n.get("id") in still_referenced
|
|
3505
|
+
]
|
|
3506
|
+
|
|
3507
|
+
|
|
3508
|
+
_pascal_unit_cache: dict[str, dict[str, str]] = {}
|
|
3509
|
+
|
|
3510
|
+
_pascal_class_stem_cache: dict[str, dict[str, str]] = {} # root_key → {stem_lower: _file_stem}
|
|
3511
|
+
|
|
3512
|
+
def _pascal_project_root(from_path: Path) -> Path:
|
|
3513
|
+
"""Return the highest ancestor directory that looks like a Pascal project root.
|
|
3514
|
+
|
|
3515
|
+
Walks up the directory tree and tracks the topmost directory that:
|
|
3516
|
+
- is NOT a filesystem root (e.g. D:/, C:/, /)
|
|
3517
|
+
- has at least 2 .pas files OR at least 1 .dpr file as direct children
|
|
3518
|
+
|
|
3519
|
+
The minimum-2 threshold avoids treating a level as the root just because a
|
|
3520
|
+
single stray .pas file was copied there. The filesystem-root exclusion
|
|
3521
|
+
prevents overshoot on drives that have a stray file directly at D:/.
|
|
3522
|
+
|
|
3523
|
+
Falls back to from_path.parent if nothing better is found.
|
|
3524
|
+
"""
|
|
3525
|
+
best = from_path.parent
|
|
3526
|
+
current = from_path.parent
|
|
3527
|
+
for _ in range(12):
|
|
3528
|
+
if len(current.parts) <= 1:
|
|
3529
|
+
break # never use a filesystem root (D:/, C:/, /)
|
|
3530
|
+
pas_count = sum(1 for _ in current.glob("*.pas"))
|
|
3531
|
+
dpr_count = sum(1 for _ in current.glob("*.dpr"))
|
|
3532
|
+
if pas_count >= 2 or dpr_count >= 1:
|
|
3533
|
+
best = current
|
|
3534
|
+
parent = current.parent
|
|
3535
|
+
if parent == current:
|
|
3536
|
+
break
|
|
3537
|
+
current = parent
|
|
3538
|
+
return best
|
|
3539
|
+
|
|
3540
|
+
def _pascal_resolve_unit(from_path: Path, unit_name: str) -> str:
|
|
3541
|
+
"""Resolve a Pascal unit name to the graphify node ID of its source file.
|
|
3542
|
+
|
|
3543
|
+
Scans all Pascal files under the project root (the highest ancestor that
|
|
3544
|
+
directly contains .pas/.dpr files) and returns _make_id(str(matched_path)).
|
|
3545
|
+
Result is cached per project root so the rglob runs at most once per
|
|
3546
|
+
project. Falls back to _make_id(unit_name) for units not found on disk
|
|
3547
|
+
(e.g. standard RTL units like SysUtils, Windows).
|
|
3548
|
+
"""
|
|
3549
|
+
root = _pascal_project_root(from_path)
|
|
3550
|
+
root_key = str(root)
|
|
3551
|
+
if root_key not in _pascal_unit_cache:
|
|
3552
|
+
unit_map: dict[str, str] = {}
|
|
3553
|
+
for ext in (".pas", ".pp", ".dpr", ".dpk", ".inc"):
|
|
3554
|
+
for f in root.rglob("*" + ext):
|
|
3555
|
+
unit_map[f.stem.lower()] = _make_id(str(f))
|
|
3556
|
+
_pascal_unit_cache[root_key] = unit_map
|
|
3557
|
+
return _pascal_unit_cache[root_key].get(unit_name.lower(), _make_id(unit_name))
|
|
3558
|
+
|
|
3559
|
+
def _pascal_resolve_class(from_path: Path, class_name: str) -> str | None:
|
|
3560
|
+
"""Resolve a Pascal class/interface name to the node ID of its defining file's class node.
|
|
3561
|
+
|
|
3562
|
+
Pascal convention: TFooBar is defined in FooBar.pas, IFooBar in FooBar.pas.
|
|
3563
|
+
Strips the leading T/I prefix, finds the file, and returns
|
|
3564
|
+
_make_id(_file_stem(found_file), class_name).
|
|
3565
|
+
|
|
3566
|
+
Returns None when no matching file is found on disk (RTL, stdlib, or
|
|
3567
|
+
unconventionally-named class — caller should create a stub node).
|
|
3568
|
+
"""
|
|
3569
|
+
prefix = class_name[:1]
|
|
3570
|
+
unit_name = class_name[1:] if prefix in ("T", "I") else class_name
|
|
3571
|
+
|
|
3572
|
+
root = _pascal_project_root(from_path)
|
|
3573
|
+
root_key = str(root)
|
|
3574
|
+
if root_key not in _pascal_class_stem_cache:
|
|
3575
|
+
stem_map: dict[str, str] = {}
|
|
3576
|
+
for ext in (".pas", ".pp", ".dpr", ".dpk"):
|
|
3577
|
+
for f in root.rglob("*" + ext):
|
|
3578
|
+
stem_map[f.stem.lower()] = _file_stem(f)
|
|
3579
|
+
_pascal_class_stem_cache[root_key] = stem_map
|
|
3580
|
+
|
|
3581
|
+
file_stem = _pascal_class_stem_cache[root_key].get(unit_name.lower())
|
|
3582
|
+
if file_stem:
|
|
3583
|
+
return _make_id(file_stem, class_name)
|
|
3584
|
+
return None
|