graphite-code 0.3.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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
graphite/resolve.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Source resolution helpers for Graphite extraction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import posixpath
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Final, Iterable
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .ts_bridge import TypeScriptCompilerEdge, TypeScriptCompilerIndex, build_typescript_index
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@lru_cache(maxsize=8)
|
|
18
|
+
def _rel_path_set_digest(rel_paths: frozenset[str]) -> str:
|
|
19
|
+
"""Short, order-independent digest of a set of repo-relative paths.
|
|
20
|
+
|
|
21
|
+
Memoized on the frozenset because it is consulted once per extracted file
|
|
22
|
+
and the set is fixed for a build; recomputing it per file would be
|
|
23
|
+
quadratic in the repo size.
|
|
24
|
+
"""
|
|
25
|
+
digest = hashlib.blake2b(digest_size=8)
|
|
26
|
+
for rel in sorted(rel_paths):
|
|
27
|
+
digest.update(rel.encode("utf-8", errors="replace"))
|
|
28
|
+
digest.update(b"\0")
|
|
29
|
+
return digest.hexdigest()
|
|
30
|
+
|
|
31
|
+
_TS_EXTENSIONS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
|
32
|
+
_INDEX_NAMES = tuple(f"index{ext}" for ext in _TS_EXTENSIONS)
|
|
33
|
+
_BUILTIN_OBJECTS = {
|
|
34
|
+
"Array", "Boolean", "Date", "Error", "Intl", "JSON", "Math", "Number",
|
|
35
|
+
"Object", "Promise", "Reflect", "RegExp", "String", "Symbol", "URL",
|
|
36
|
+
"URLSearchParams", "console", "process", "window", "document",
|
|
37
|
+
}
|
|
38
|
+
_NOISY_MEMBER_CALLS = {
|
|
39
|
+
"addEventListener", "append", "appendChild", "catch", "concat", "delete", "endsWith",
|
|
40
|
+
"entries", "every", "filter", "find", "findIndex", "flat", "flatMap", "forEach",
|
|
41
|
+
"get", "has", "includes", "join", "keys", "log", "map", "match", "max", "min",
|
|
42
|
+
"parse", "push", "reduce", "remove", "removeEventListener", "replace", "set", "slice",
|
|
43
|
+
"some", "sort", "split", "startsWith", "stringify", "then", "toISOString",
|
|
44
|
+
"toLocaleString", "toLowerCase", "toString", "toUpperCase", "trim", "values",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ResolvedImport:
|
|
50
|
+
rel_path: str
|
|
51
|
+
confidence: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class RustUseResolution:
|
|
56
|
+
"""Outcome of resolving one Rust `use` path.
|
|
57
|
+
|
|
58
|
+
Three distinct states, and the difference matters to resolution_health:
|
|
59
|
+
bound (`rel_path` set), confirmed external, or unresolved. Only a
|
|
60
|
+
*confirmed* external may be tagged EXTERNAL_IMPORT; an unresolved target
|
|
61
|
+
must keep default confidence so it stays counted against the ratio.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
rel_path: str | None
|
|
65
|
+
external: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class SourceIndex:
|
|
70
|
+
"""Known project files plus tsconfig path aliases and optional TS compiler resolution."""
|
|
71
|
+
|
|
72
|
+
root: Path
|
|
73
|
+
rel_paths: frozenset[str]
|
|
74
|
+
# (scope dir rel path, pattern, target globs) per tsconfig.json found in
|
|
75
|
+
# the scan — an alias only applies to files under its tsconfig's directory,
|
|
76
|
+
# ordered longest scope first so the nearest tsconfig wins.
|
|
77
|
+
path_aliases: tuple[tuple[str, str, tuple[str, ...]], ...]
|
|
78
|
+
typescript: TypeScriptCompilerIndex
|
|
79
|
+
# (package name, package dir rel path, entry file rel path) per workspace
|
|
80
|
+
# package.json found in the scan — lets bare imports like "@repo/utils"
|
|
81
|
+
# resolve to the workspace source file instead of an external phantom.
|
|
82
|
+
workspace_packages: tuple[tuple[str, str, str], ...] = ()
|
|
83
|
+
# (crate ident, crate dir rel path, src root rel path) per Cargo.toml in the
|
|
84
|
+
# scan, so `use other_crate::…` binds to that crate's source instead of a
|
|
85
|
+
# phantom, and declared dependencies can be told apart from typos.
|
|
86
|
+
cargo_crates: tuple[tuple[str, str, str], ...] = ()
|
|
87
|
+
# Union across all manifests. FALLBACK ONLY, for a file no manifest claims.
|
|
88
|
+
cargo_dependencies: frozenset[str] = frozenset()
|
|
89
|
+
# (crate dir, declared idents) per manifest -- the authoritative answer to
|
|
90
|
+
# "did THIS file's crate declare that dependency". See issue #38.
|
|
91
|
+
cargo_crate_dependencies: tuple[tuple[str, frozenset[str]], ...] = ()
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_entries(cls, entries: Iterable[object], cfg: Config | None = None) -> "SourceIndex":
|
|
95
|
+
entries = list(entries)
|
|
96
|
+
root = Path.cwd().resolve()
|
|
97
|
+
rel_paths: set[str] = set()
|
|
98
|
+
if entries:
|
|
99
|
+
first = entries[0]
|
|
100
|
+
root = Path(first.abs_path).resolve()
|
|
101
|
+
for _ in PurePosixPath(first.rel_path).parts:
|
|
102
|
+
root = root.parent
|
|
103
|
+
for entry in entries:
|
|
104
|
+
rel_paths.add(PurePosixPath(entry.rel_path).as_posix())
|
|
105
|
+
cfg = cfg or Config()
|
|
106
|
+
ts_index = build_typescript_index(root, entries, cfg)
|
|
107
|
+
return cls(
|
|
108
|
+
root=root,
|
|
109
|
+
rel_paths=frozenset(rel_paths),
|
|
110
|
+
path_aliases=_load_tsconfig_aliases(root, frozenset(rel_paths)),
|
|
111
|
+
typescript=ts_index,
|
|
112
|
+
workspace_packages=_load_workspace_packages(root, frozenset(rel_paths)),
|
|
113
|
+
cargo_crates=_load_cargo_crates(root, frozenset(rel_paths)),
|
|
114
|
+
cargo_dependencies=_load_cargo_dependencies(root, frozenset(rel_paths)),
|
|
115
|
+
cargo_crate_dependencies=_load_cargo_crate_dependencies(root, frozenset(rel_paths)),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def file_set_digest(self) -> str:
|
|
119
|
+
"""Stable digest of the repo's file set.
|
|
120
|
+
|
|
121
|
+
Import resolution runs at extraction time, so a cached extraction
|
|
122
|
+
embeds conclusions about which sibling modules existed *then*. Folding
|
|
123
|
+
this into the cache key makes adding or removing a file re-resolve the
|
|
124
|
+
importers that never changed themselves (#2).
|
|
125
|
+
"""
|
|
126
|
+
return _rel_path_set_digest(self.rel_paths)
|
|
127
|
+
|
|
128
|
+
def resolve_ts_import(self, rel_path: str, source_lit: str) -> str | None:
|
|
129
|
+
"""Resolve TS/JS imports to a known relative file path when possible."""
|
|
130
|
+
resolved = self.resolve_ts_import_detail(rel_path, source_lit)
|
|
131
|
+
return resolved.rel_path if resolved else None
|
|
132
|
+
|
|
133
|
+
def resolve_ts_import_detail(self, rel_path: str, source_lit: str) -> ResolvedImport | None:
|
|
134
|
+
"""Resolve imports, preferring TypeScript compiler results over heuristics."""
|
|
135
|
+
compiler_edge = self.typescript.resolve_import(rel_path, source_lit)
|
|
136
|
+
if compiler_edge:
|
|
137
|
+
return ResolvedImport(compiler_edge.target, compiler_edge.confidence)
|
|
138
|
+
|
|
139
|
+
candidates: list[str] = []
|
|
140
|
+
if source_lit.startswith("."):
|
|
141
|
+
base = PurePosixPath(rel_path).parent.joinpath(source_lit)
|
|
142
|
+
candidates.extend(_candidate_paths(base))
|
|
143
|
+
else:
|
|
144
|
+
for scope, pattern, targets in self.path_aliases:
|
|
145
|
+
if scope and not rel_path.startswith(scope + "/"):
|
|
146
|
+
continue
|
|
147
|
+
matched = _match_alias(pattern, source_lit)
|
|
148
|
+
if matched is None:
|
|
149
|
+
continue
|
|
150
|
+
for target in targets:
|
|
151
|
+
candidates.extend(_candidate_paths(PurePosixPath(target.replace("*", matched))))
|
|
152
|
+
|
|
153
|
+
for candidate in candidates:
|
|
154
|
+
normalized = PurePosixPath(candidate).as_posix().lstrip("./")
|
|
155
|
+
if normalized in self.rel_paths:
|
|
156
|
+
return ResolvedImport(normalized, "EXACT_IMPORT")
|
|
157
|
+
|
|
158
|
+
workspace = self._resolve_workspace_import(source_lit)
|
|
159
|
+
if workspace is not None:
|
|
160
|
+
return workspace
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
def _resolve_workspace_import(self, source_lit: str) -> ResolvedImport | None:
|
|
164
|
+
"""Resolve a bare import against workspace packages (monorepo).
|
|
165
|
+
|
|
166
|
+
Exact name ("@repo/utils") resolves to the package entry file; a
|
|
167
|
+
subpath ("@repo/utils/money") resolves inside the package dir (also
|
|
168
|
+
trying src/). Longest package name wins so scoped packages nest safely.
|
|
169
|
+
"""
|
|
170
|
+
for name, pkg_dir, entry in sorted(
|
|
171
|
+
self.workspace_packages, key=lambda item: len(item[0]), reverse=True
|
|
172
|
+
):
|
|
173
|
+
if source_lit == name:
|
|
174
|
+
return ResolvedImport(entry, "WORKSPACE_IMPORT")
|
|
175
|
+
if source_lit.startswith(name + "/"):
|
|
176
|
+
remainder = source_lit[len(name) + 1:]
|
|
177
|
+
candidates: list[str] = []
|
|
178
|
+
candidates.extend(_candidate_paths(PurePosixPath(pkg_dir).joinpath(remainder)))
|
|
179
|
+
candidates.extend(_candidate_paths(PurePosixPath(pkg_dir).joinpath("src", remainder)))
|
|
180
|
+
for candidate in candidates:
|
|
181
|
+
normalized = PurePosixPath(candidate).as_posix().lstrip("./")
|
|
182
|
+
if normalized in self.rel_paths:
|
|
183
|
+
return ResolvedImport(normalized, "WORKSPACE_IMPORT")
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def supplemental_ts_edges(self, rel_path: str) -> tuple[TypeScriptCompilerEdge, ...]:
|
|
187
|
+
"""Compiler-discovered export/dynamic-import edges for a file."""
|
|
188
|
+
return self.typescript.supplemental_edges(rel_path)
|
|
189
|
+
|
|
190
|
+
def resolve_python_module(
|
|
191
|
+
self, importer_rel_path: str, module: str, relative_dots: int = 0
|
|
192
|
+
) -> str | None:
|
|
193
|
+
"""Lexically resolve a Python module path to a repo rel_path, else None.
|
|
194
|
+
|
|
195
|
+
Absolute: roots "" then "src"; relative: anchored at the importer's
|
|
196
|
+
directory, one level up per dot beyond the first. Purely repo-relative:
|
|
197
|
+
no sys.path, so a repo file shadowing a stdlib name wins.
|
|
198
|
+
"""
|
|
199
|
+
parts = [p for p in module.split(".") if p] if module else []
|
|
200
|
+
candidates: list[str] = []
|
|
201
|
+
if relative_dots > 0:
|
|
202
|
+
anchor = PurePosixPath(importer_rel_path).parent
|
|
203
|
+
for _ in range(relative_dots - 1):
|
|
204
|
+
anchor = anchor.parent
|
|
205
|
+
base = anchor.joinpath(*parts).as_posix() if parts else anchor.as_posix()
|
|
206
|
+
if parts:
|
|
207
|
+
candidates.extend(_python_module_candidates(base))
|
|
208
|
+
else:
|
|
209
|
+
candidates.append(posixpath.normpath(f"{base}/__init__.py"))
|
|
210
|
+
else:
|
|
211
|
+
if not parts:
|
|
212
|
+
return None
|
|
213
|
+
joined = "/".join(parts)
|
|
214
|
+
for root in ("", "src"):
|
|
215
|
+
base = f"{root}/{joined}" if root else joined
|
|
216
|
+
candidates.extend(_python_module_candidates(base))
|
|
217
|
+
for candidate in candidates:
|
|
218
|
+
normalized = posixpath.normpath(candidate).lstrip("./")
|
|
219
|
+
if normalized in self.rel_paths:
|
|
220
|
+
return normalized
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _rust_crate_for(self, rel_path: str) -> tuple[str, str, str] | None:
|
|
224
|
+
"""Innermost crate containing this file (longest matching crate dir).
|
|
225
|
+
|
|
226
|
+
Falls back to the nearest `src/` ancestor when no manifest matches, so
|
|
227
|
+
`crate::` still resolves in a repo whose Cargo.toml was not scanned.
|
|
228
|
+
Without this, crate-relative imports silently fail to bind.
|
|
229
|
+
"""
|
|
230
|
+
best: tuple[str, str, str] | None = None
|
|
231
|
+
for entry in self.cargo_crates:
|
|
232
|
+
crate_dir = entry[1]
|
|
233
|
+
if crate_dir and not rel_path.startswith(f"{crate_dir}/"):
|
|
234
|
+
continue
|
|
235
|
+
if best is None or len(crate_dir) > len(best[1]):
|
|
236
|
+
best = entry
|
|
237
|
+
if best is not None:
|
|
238
|
+
return best
|
|
239
|
+
parts = PurePosixPath(rel_path).parts
|
|
240
|
+
if "src" in parts:
|
|
241
|
+
index = len(parts) - 1 - parts[::-1].index("src")
|
|
242
|
+
return ("", "/".join(parts[:index]), "/".join(parts[: index + 1]))
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
def _rust_walk(self, anchor: str, rest: list[str]) -> str | None:
|
|
246
|
+
"""Longest-module-prefix-first search for a file under `anchor`."""
|
|
247
|
+
modules = _rust_module_segments(rest)
|
|
248
|
+
for depth in range(len(modules), -1, -1):
|
|
249
|
+
parts = modules[:depth]
|
|
250
|
+
if parts:
|
|
251
|
+
joined = "/".join(parts)
|
|
252
|
+
base = f"{anchor}/{joined}" if anchor else joined
|
|
253
|
+
candidates = _rust_module_candidates(base)
|
|
254
|
+
else:
|
|
255
|
+
candidates = [
|
|
256
|
+
f"{anchor}/{name}" if anchor else name
|
|
257
|
+
for name in ("lib.rs", "mod.rs", "main.rs")
|
|
258
|
+
]
|
|
259
|
+
for candidate in candidates:
|
|
260
|
+
normalized = posixpath.normpath(candidate).lstrip("./")
|
|
261
|
+
if normalized in self.rel_paths:
|
|
262
|
+
return normalized
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def resolve_rust_mod(self, importer_rel_path: str, mod_name: str) -> str | None:
|
|
266
|
+
"""Resolve a `mod foo;` declaration to the file it pulls in.
|
|
267
|
+
|
|
268
|
+
Unambiguous, unlike `use`: exactly two legal layouts. This is Rust's
|
|
269
|
+
file-inclusion mechanism and the only structural link between the files
|
|
270
|
+
of a multi-file crate.
|
|
271
|
+
"""
|
|
272
|
+
if not mod_name:
|
|
273
|
+
return None
|
|
274
|
+
base_dir = _rust_module_dir(importer_rel_path)
|
|
275
|
+
base = f"{base_dir}/{mod_name}" if base_dir else mod_name
|
|
276
|
+
for candidate in _rust_module_candidates(base):
|
|
277
|
+
normalized = posixpath.normpath(candidate).lstrip("./")
|
|
278
|
+
if normalized in self.rel_paths:
|
|
279
|
+
return normalized
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
def _rust_declares(self, importer_rel_path: str, name: str) -> bool:
|
|
283
|
+
"""Did the importing file's own crate declare this dependency?
|
|
284
|
+
|
|
285
|
+
Asking per-crate rather than repo-wide is issue #38: an import tagged
|
|
286
|
+
external is excluded from the resolution-health ratio, so letting a
|
|
287
|
+
sibling crate's dependency vouch for an undeclared import removed a real
|
|
288
|
+
miss from the denominator instead of counting it.
|
|
289
|
+
|
|
290
|
+
Falls back to the union when no manifest claims the file, so a repo whose
|
|
291
|
+
Cargo.toml was never scanned keeps resolving exactly as before rather
|
|
292
|
+
than regressing to "everything unresolved".
|
|
293
|
+
"""
|
|
294
|
+
crate = self._rust_crate_for(importer_rel_path)
|
|
295
|
+
if crate is not None:
|
|
296
|
+
for crate_dir, declared in self.cargo_crate_dependencies:
|
|
297
|
+
if crate_dir == crate[1]:
|
|
298
|
+
return name in declared
|
|
299
|
+
return name in self.cargo_dependencies
|
|
300
|
+
|
|
301
|
+
def resolve_rust_use(
|
|
302
|
+
self, importer_rel_path: str, use_path: str, inline_mod_depth: int = 0
|
|
303
|
+
) -> RustUseResolution:
|
|
304
|
+
"""Lexically resolve a Rust `use` path. See RustUseResolution."""
|
|
305
|
+
segments = [s for s in use_path.replace(" ", "").split("::") if s]
|
|
306
|
+
if not segments:
|
|
307
|
+
return _RUST_UNRESOLVED
|
|
308
|
+
head, rest = segments[0], segments[1:]
|
|
309
|
+
if head in _RUST_EXTERNAL_ROOTS:
|
|
310
|
+
return _RUST_EXTERNAL
|
|
311
|
+
|
|
312
|
+
if head == "crate":
|
|
313
|
+
crate = self._rust_crate_for(importer_rel_path)
|
|
314
|
+
if crate is None:
|
|
315
|
+
return _RUST_UNRESOLVED
|
|
316
|
+
anchor = crate[2]
|
|
317
|
+
elif head == "self":
|
|
318
|
+
anchor = _rust_module_dir(importer_rel_path)
|
|
319
|
+
elif head == "super":
|
|
320
|
+
if inline_mod_depth > 0:
|
|
321
|
+
# `super` from inside an inline `mod` block is still this file.
|
|
322
|
+
return RustUseResolution(importer_rel_path, False)
|
|
323
|
+
parent_raw = PurePosixPath(_rust_module_dir(importer_rel_path)).parent.as_posix()
|
|
324
|
+
anchor = "" if parent_raw == "." else parent_raw
|
|
325
|
+
else:
|
|
326
|
+
target = next((c for c in self.cargo_crates if c[0] == head), None)
|
|
327
|
+
if target is None:
|
|
328
|
+
declared = self._rust_declares(importer_rel_path, head)
|
|
329
|
+
return _RUST_EXTERNAL if declared else _RUST_UNRESOLVED
|
|
330
|
+
return RustUseResolution(self._rust_walk(target[2], rest), False)
|
|
331
|
+
|
|
332
|
+
return RustUseResolution(self._rust_walk(anchor, rest), False)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def should_keep_call_target(called: str) -> bool:
|
|
336
|
+
"""Drop common built-in/member calls that create graph noise."""
|
|
337
|
+
if not called:
|
|
338
|
+
return False
|
|
339
|
+
if "." not in called:
|
|
340
|
+
return True
|
|
341
|
+
obj, prop = called.rsplit(".", 1)
|
|
342
|
+
root = obj.split(".", 1)[0]
|
|
343
|
+
if root in _BUILTIN_OBJECTS:
|
|
344
|
+
return False
|
|
345
|
+
if prop in _NOISY_MEMBER_CALLS:
|
|
346
|
+
return False
|
|
347
|
+
return True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# TypeScript NodeNext/ESM writes relative import specifiers with a JS-family
|
|
351
|
+
# extension (the *emitted* file name) while the source on disk is the matching
|
|
352
|
+
# TS extension — e.g. `import '../db/queries.js'` resolves to `queries.ts`. Map
|
|
353
|
+
# each JS extension to the TS source extensions to try. Order matters: TS source
|
|
354
|
+
# is preferred over the literal path so a `.js` specifier resolves to the source,
|
|
355
|
+
# not a compiled `.js` twin sitting beside it.
|
|
356
|
+
_JS_TO_TS_EXTENSIONS: dict[str, tuple[str, ...]] = {
|
|
357
|
+
".js": (".ts", ".tsx"),
|
|
358
|
+
".jsx": (".tsx",),
|
|
359
|
+
".mjs": (".mts",),
|
|
360
|
+
".cjs": (".cts",),
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _candidate_paths(base: PurePosixPath) -> list[str]:
|
|
365
|
+
# Collapse any ".."/"." segments so a relative specifier like
|
|
366
|
+
# "../db/queries" from "src/routes/x.ts" becomes "src/db/queries" and can
|
|
367
|
+
# match a scanned rel_path (PurePosixPath.joinpath does NOT normalize "..").
|
|
368
|
+
base_str = posixpath.normpath(base.as_posix())
|
|
369
|
+
root, suffix = posixpath.splitext(base_str)
|
|
370
|
+
if suffix:
|
|
371
|
+
ts_swaps = _JS_TO_TS_EXTENSIONS.get(suffix)
|
|
372
|
+
if ts_swaps:
|
|
373
|
+
# Prefer the TS source of the same stem, then fall back to the
|
|
374
|
+
# literal path (a real .js/.jsx file with no TS source may exist).
|
|
375
|
+
return [f"{root}{ext}" for ext in ts_swaps] + [base_str]
|
|
376
|
+
return [base_str]
|
|
377
|
+
out = [base_str]
|
|
378
|
+
out.extend(f"{base_str}{ext}" for ext in _TS_EXTENSIONS)
|
|
379
|
+
out.extend(f"{base_str}/{name}" for name in _INDEX_NAMES)
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _python_module_candidates(base: str) -> list[str]:
|
|
384
|
+
normalized = posixpath.normpath(base)
|
|
385
|
+
if normalized in (".", ""):
|
|
386
|
+
return ["__init__.py"]
|
|
387
|
+
return [f"{normalized}.py", f"{normalized}/__init__.py"]
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _load_workspace_packages(
|
|
391
|
+
root: Path, rel_paths: frozenset[str]
|
|
392
|
+
) -> tuple[tuple[str, str, str], ...]:
|
|
393
|
+
"""Map workspace package names to their entry source file.
|
|
394
|
+
|
|
395
|
+
Every scanned package.json (node_modules is never scanned) whose "name"
|
|
396
|
+
can be tied to an in-repo entry file becomes (name, pkg_dir, entry).
|
|
397
|
+
Entry preference: exports["."] (string or the first string among
|
|
398
|
+
import/default/require/module/main condition keys), then "module", then
|
|
399
|
+
"main", then src/index.* / index.* fallbacks. The root package.json is
|
|
400
|
+
included too — resolving an import of the root package name is harmless.
|
|
401
|
+
"""
|
|
402
|
+
packages: list[tuple[str, str, str]] = []
|
|
403
|
+
for rel in sorted(rel_paths):
|
|
404
|
+
posix = PurePosixPath(rel)
|
|
405
|
+
if posix.name != "package.json":
|
|
406
|
+
continue
|
|
407
|
+
try:
|
|
408
|
+
data = json.loads((root / posix).read_text(encoding="utf-8"))
|
|
409
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
410
|
+
continue
|
|
411
|
+
if not isinstance(data, dict):
|
|
412
|
+
continue
|
|
413
|
+
name = data.get("name")
|
|
414
|
+
if not isinstance(name, str) or not name.strip():
|
|
415
|
+
continue
|
|
416
|
+
pkg_dir = posix.parent.as_posix()
|
|
417
|
+
if pkg_dir == ".":
|
|
418
|
+
pkg_dir = ""
|
|
419
|
+
entry = _package_entry(data, pkg_dir, rel_paths)
|
|
420
|
+
if entry is None:
|
|
421
|
+
continue
|
|
422
|
+
packages.append((name.strip(), pkg_dir, entry))
|
|
423
|
+
return tuple(packages)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _package_entry(
|
|
427
|
+
data: dict, pkg_dir: str, rel_paths: frozenset[str]
|
|
428
|
+
) -> str | None:
|
|
429
|
+
declared: list[str] = []
|
|
430
|
+
exports = data.get("exports")
|
|
431
|
+
if isinstance(exports, str):
|
|
432
|
+
declared.append(exports)
|
|
433
|
+
elif isinstance(exports, dict):
|
|
434
|
+
dot = exports.get(".")
|
|
435
|
+
if isinstance(dot, str):
|
|
436
|
+
declared.append(dot)
|
|
437
|
+
elif isinstance(dot, dict):
|
|
438
|
+
for key in ("import", "default", "require", "module", "main"):
|
|
439
|
+
value = dot.get(key)
|
|
440
|
+
if isinstance(value, str):
|
|
441
|
+
declared.append(value)
|
|
442
|
+
for key in ("module", "main", "types"):
|
|
443
|
+
value = data.get(key)
|
|
444
|
+
if isinstance(value, str):
|
|
445
|
+
declared.append(value)
|
|
446
|
+
|
|
447
|
+
candidates: list[str] = []
|
|
448
|
+
base = PurePosixPath(pkg_dir) if pkg_dir else PurePosixPath(".")
|
|
449
|
+
for spec in declared:
|
|
450
|
+
candidates.extend(_candidate_paths(base.joinpath(spec.lstrip("./"))))
|
|
451
|
+
for fallback in ("src/index", "index"):
|
|
452
|
+
candidates.extend(_candidate_paths(base.joinpath(fallback)))
|
|
453
|
+
|
|
454
|
+
for candidate in candidates:
|
|
455
|
+
normalized = PurePosixPath(candidate).as_posix().lstrip("./")
|
|
456
|
+
if normalized in rel_paths:
|
|
457
|
+
return normalized
|
|
458
|
+
return None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _load_tsconfig_aliases(
|
|
462
|
+
root: Path, rel_paths: frozenset[str]
|
|
463
|
+
) -> tuple[tuple[str, str, tuple[str, ...]], ...]:
|
|
464
|
+
"""Path aliases from every scanned tsconfig.json, scoped to its directory.
|
|
465
|
+
|
|
466
|
+
Monorepo packages declare their own aliases (a Remix app maps ``~/*`` to
|
|
467
|
+
``./app/*`` in apps/web/tsconfig.json), so reading only the root tsconfig
|
|
468
|
+
left every aliased import in a workspace package unresolved. Targets are
|
|
469
|
+
joined as ``<tsconfig dir>/<baseUrl>/<target>``. The root tsconfig.json is
|
|
470
|
+
also read straight from disk in case json files were excluded from the
|
|
471
|
+
scan. ``extends`` chains are not followed.
|
|
472
|
+
"""
|
|
473
|
+
configs = {rel for rel in rel_paths if PurePosixPath(rel).name == "tsconfig.json"}
|
|
474
|
+
if (root / "tsconfig.json").exists():
|
|
475
|
+
configs.add("tsconfig.json")
|
|
476
|
+
aliases: list[tuple[str, str, tuple[str, ...]]] = []
|
|
477
|
+
for rel in sorted(configs):
|
|
478
|
+
try:
|
|
479
|
+
data = json.loads((root / rel).read_text(encoding="utf-8"))
|
|
480
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
481
|
+
continue
|
|
482
|
+
compiler = data.get("compilerOptions", {}) if isinstance(data, dict) else {}
|
|
483
|
+
if not isinstance(compiler, dict):
|
|
484
|
+
continue
|
|
485
|
+
base_url = compiler.get("baseUrl", ".")
|
|
486
|
+
if not isinstance(base_url, str):
|
|
487
|
+
base_url = "."
|
|
488
|
+
paths = compiler.get("paths", {})
|
|
489
|
+
if not isinstance(paths, dict):
|
|
490
|
+
continue
|
|
491
|
+
scope = PurePosixPath(rel).parent.as_posix()
|
|
492
|
+
if scope == ".":
|
|
493
|
+
scope = ""
|
|
494
|
+
for pattern, targets in sorted(paths.items()):
|
|
495
|
+
if not (isinstance(pattern, str) and isinstance(targets, list)):
|
|
496
|
+
continue
|
|
497
|
+
normalized_targets = []
|
|
498
|
+
for target in targets:
|
|
499
|
+
if isinstance(target, str):
|
|
500
|
+
joined = PurePosixPath(scope).joinpath(base_url, target)
|
|
501
|
+
normalized_targets.append(posixpath.normpath(joined.as_posix()))
|
|
502
|
+
if normalized_targets:
|
|
503
|
+
aliases.append((scope, pattern, tuple(normalized_targets)))
|
|
504
|
+
# Longest scope first: candidates from the nearest tsconfig are tried
|
|
505
|
+
# before the root's, so the nearest match wins.
|
|
506
|
+
aliases.sort(key=lambda item: (-len(item[0]), item[0], item[1]))
|
|
507
|
+
return tuple(aliases)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _match_alias(pattern: str, import_name: str) -> str | None:
|
|
511
|
+
if "*" not in pattern:
|
|
512
|
+
return "" if pattern == import_name else None
|
|
513
|
+
prefix, suffix = pattern.split("*", 1)
|
|
514
|
+
if not import_name.startswith(prefix) or not import_name.endswith(suffix):
|
|
515
|
+
return None
|
|
516
|
+
return import_name[len(prefix): len(import_name) - len(suffix) if suffix else len(import_name)]
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# --- Rust ------------------------------------------------------------------
|
|
520
|
+
# Roots that always leave the repo. Kept deliberately small: EXTERNAL_IMPORT
|
|
521
|
+
# means "a resolver confirmed this is external", never "we failed to resolve
|
|
522
|
+
# it" -- tagging misses as external would hide real phantom linkage from
|
|
523
|
+
# resolution_health.
|
|
524
|
+
_RUST_EXTERNAL_ROOTS: Final = frozenset({"std", "core", "alloc", "proc_macro"})
|
|
525
|
+
_CARGO_DEPENDENCY_TABLES: Final = ("dependencies", "dev-dependencies", "build-dependencies")
|
|
526
|
+
_RUST_MODULE_ROOT_FILES: Final = frozenset({"lib.rs", "main.rs", "mod.rs"})
|
|
527
|
+
_RUST_UNRESOLVED: Final = RustUseResolution(None, False)
|
|
528
|
+
_RUST_EXTERNAL: Final = RustUseResolution(None, True)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _rust_module_dir(rel_path: str) -> str:
|
|
532
|
+
"""Directory Rust searches for this file's child modules.
|
|
533
|
+
|
|
534
|
+
`lib.rs`/`main.rs`/`mod.rs` are their module's root, so their children sit
|
|
535
|
+
beside them. Any other `foo.rs` owns a sibling directory `foo/`.
|
|
536
|
+
"""
|
|
537
|
+
path = PurePosixPath(rel_path)
|
|
538
|
+
parent_raw = path.parent.as_posix()
|
|
539
|
+
parent = "" if parent_raw == "." else parent_raw
|
|
540
|
+
if path.name in _RUST_MODULE_ROOT_FILES:
|
|
541
|
+
return parent
|
|
542
|
+
return f"{parent}/{path.stem}" if parent else path.stem
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _rust_module_candidates(base: str) -> list[str]:
|
|
546
|
+
"""The two legal on-disk layouts for a Rust module."""
|
|
547
|
+
return [f"{base}.rs", f"{base}/mod.rs"]
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _rust_module_segments(segments: list[str]) -> list[str]:
|
|
551
|
+
"""Leading segments of a `use` path that can be modules.
|
|
552
|
+
|
|
553
|
+
A `use` path blends module path and item name with no lexical boundary.
|
|
554
|
+
Rust modules are snake_case and items CamelCase, so an uppercase initial
|
|
555
|
+
ends the module portion.
|
|
556
|
+
"""
|
|
557
|
+
modules: list[str] = []
|
|
558
|
+
for segment in segments:
|
|
559
|
+
if segment[:1].isupper():
|
|
560
|
+
break
|
|
561
|
+
modules.append(segment)
|
|
562
|
+
return modules
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _normalize_crate_name(raw: str) -> str:
|
|
566
|
+
"""Cargo maps a package name's hyphens to underscores for the crate ident."""
|
|
567
|
+
return raw.strip().replace("-", "_")
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _read_cargo_manifest(root: Path, rel: str) -> dict[str, object] | None:
|
|
571
|
+
"""Parse one Cargo.toml. Total: unreadable or malformed yields None.
|
|
572
|
+
|
|
573
|
+
Extraction must never fail because a repo ships a bad manifest.
|
|
574
|
+
"""
|
|
575
|
+
try:
|
|
576
|
+
return tomllib.loads((root / rel).read_text(encoding="utf-8"))
|
|
577
|
+
except (OSError, ValueError, RecursionError):
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _cargo_manifest_paths(rel_paths: frozenset[str]) -> list[str]:
|
|
582
|
+
return sorted(rel for rel in rel_paths if PurePosixPath(rel).name == "Cargo.toml")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _load_cargo_crates(root: Path, rel_paths: frozenset[str]) -> tuple[tuple[str, str, str], ...]:
|
|
586
|
+
"""(crate ident, crate dir, src root) per real package manifest in the scan."""
|
|
587
|
+
crates: list[tuple[str, str, str]] = []
|
|
588
|
+
for rel in _cargo_manifest_paths(rel_paths):
|
|
589
|
+
data = _read_cargo_manifest(root, rel)
|
|
590
|
+
if data is None:
|
|
591
|
+
continue
|
|
592
|
+
package = data.get("package")
|
|
593
|
+
if not isinstance(package, dict):
|
|
594
|
+
continue # virtual/workspace-only manifest declares no crate
|
|
595
|
+
name = package.get("name")
|
|
596
|
+
if not isinstance(name, str) or not name.strip():
|
|
597
|
+
continue
|
|
598
|
+
parent = PurePosixPath(rel).parent.as_posix()
|
|
599
|
+
crate_dir = "" if parent == "." else parent
|
|
600
|
+
src_root = f"{crate_dir}/src" if crate_dir else "src"
|
|
601
|
+
crates.append((_normalize_crate_name(name), crate_dir, src_root))
|
|
602
|
+
return tuple(crates)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _dependency_names(scope: object) -> set[str]:
|
|
606
|
+
"""Dependency idents declared in one manifest scope."""
|
|
607
|
+
names: set[str] = set()
|
|
608
|
+
if not isinstance(scope, dict):
|
|
609
|
+
return names
|
|
610
|
+
for table in _CARGO_DEPENDENCY_TABLES:
|
|
611
|
+
section = scope.get(table)
|
|
612
|
+
if isinstance(section, dict):
|
|
613
|
+
names.update(_normalize_crate_name(key) for key in section if isinstance(key, str))
|
|
614
|
+
return names
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _load_cargo_dependencies(root: Path, rel_paths: frozenset[str]) -> frozenset[str]:
|
|
618
|
+
"""Union of declared dependency idents across every manifest.
|
|
619
|
+
|
|
620
|
+
Kept only as the fallback for a file that cannot be attributed to a crate.
|
|
621
|
+
Do NOT use it to decide whether a specific file's import is declared -- see
|
|
622
|
+
`_load_cargo_crate_dependencies` and issue #38.
|
|
623
|
+
"""
|
|
624
|
+
names: set[str] = set()
|
|
625
|
+
for rel in _cargo_manifest_paths(rel_paths):
|
|
626
|
+
data = _read_cargo_manifest(root, rel)
|
|
627
|
+
if data is None:
|
|
628
|
+
continue
|
|
629
|
+
names |= _dependency_names(data)
|
|
630
|
+
names |= _dependency_names(data.get("workspace"))
|
|
631
|
+
return frozenset(names)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _load_cargo_crate_dependencies(
|
|
635
|
+
root: Path, rel_paths: frozenset[str]
|
|
636
|
+
) -> tuple[tuple[str, frozenset[str]], ...]:
|
|
637
|
+
"""Declared dependency idents **per crate**, as (crate dir, idents).
|
|
638
|
+
|
|
639
|
+
Issue #38: the union above let one crate's dependency vouch for another
|
|
640
|
+
crate's undeclared import. Since an import classified external is EXCLUDED
|
|
641
|
+
from the resolution-health denominator, that quietly removed a broken import
|
|
642
|
+
from the ratio instead of counting it against -- inflating the number strict
|
|
643
|
+
graph-first enforcement gates on.
|
|
644
|
+
|
|
645
|
+
Workspace-level `[workspace.dependencies]` IS folded into every crate's set.
|
|
646
|
+
`dep = { workspace = true }` is legitimate inheritance, and those
|
|
647
|
+
declarations are shared by design; only cross-crate leakage is the defect.
|
|
648
|
+
The cost is that a member using a workspace dependency it never opted into
|
|
649
|
+
still reads as external -- narrower than the union, and deliberate.
|
|
650
|
+
"""
|
|
651
|
+
workspace: set[str] = set()
|
|
652
|
+
per_crate: list[tuple[str, set[str]]] = []
|
|
653
|
+
for rel in _cargo_manifest_paths(rel_paths):
|
|
654
|
+
data = _read_cargo_manifest(root, rel)
|
|
655
|
+
if data is None:
|
|
656
|
+
continue
|
|
657
|
+
workspace |= _dependency_names(data.get("workspace"))
|
|
658
|
+
parent = PurePosixPath(rel).parent.as_posix()
|
|
659
|
+
per_crate.append(("" if parent == "." else parent, _dependency_names(data)))
|
|
660
|
+
return tuple((crate_dir, frozenset(names | workspace)) for crate_dir, names in per_crate)
|