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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. 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)