codegraph-engine 3.0.0__2-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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/agent_brain.py +2212 -0
- codegraph/agent_capabilities.py +4058 -0
- codegraph/agent_rules.py +531 -0
- codegraph/api_drift.py +455 -0
- codegraph/architecture.py +371 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/audit.py +106 -0
- codegraph/binding_resolver.py +616 -0
- codegraph/branch_comparison.py +197 -0
- codegraph/cache.py +95 -0
- codegraph/change_impact.py +547 -0
- codegraph/cli.py +2436 -0
- codegraph/cli_output.py +205 -0
- codegraph/config.py +45 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +2182 -0
- codegraph/context_freshness.py +351 -0
- codegraph/database/__init__.py +72 -0
- codegraph/database/extractor.py +3651 -0
- codegraph/database/interrogation.py +1494 -0
- codegraph/database/models.py +310 -0
- codegraph/database/schema_drift.py +862 -0
- codegraph/epistemic.py +200 -0
- codegraph/errors.py +282 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +418 -0
- codegraph/evidence_contract.py +486 -0
- codegraph/frameworks.py +960 -0
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +335 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +362 -0
- codegraph/git_state.py +545 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +56 -0
- codegraph/graph/traversal.py +1993 -0
- codegraph/indexing/__init__.py +24 -0
- codegraph/indexing/classifier.py +564 -0
- codegraph/indexing/indexer.py +2578 -0
- codegraph/indexing/models.py +427 -0
- codegraph/indexing/parser.py +3404 -0
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/scanner.py +211 -0
- codegraph/indexing/telemetry.py +326 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/installer.py +1702 -0
- codegraph/interrogation.py +2355 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +1476 -0
- codegraph/mcp_diagnostics.py +882 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +310 -0
- codegraph/monorepo.py +925 -0
- codegraph/observability.py +262 -0
- codegraph/optimizer.py +1014 -0
- codegraph/planner.py +417 -0
- codegraph/process_lifecycle.py +1153 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +408 -0
- codegraph/refactor/__init__.py +36 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/resolver.py +3968 -0
- codegraph/resources/__init__.py +46 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +219 -0
- codegraph/resources/monitor.py +102 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +320 -0
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +363 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +51 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +692 -0
- codegraph/runtime/models.py +130 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/reconciliation.py +492 -0
- codegraph/runtime/runner.py +353 -0
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/search/__init__.py +25 -0
- codegraph/search/hybrid.py +846 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +51 -0
- codegraph/security/paths.py +189 -0
- codegraph/security/redaction.py +633 -0
- codegraph/semantic_conflicts.py +270 -0
- codegraph/semantic_decorators.py +372 -0
- codegraph/structural_diff.py +542 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +277 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph/tool_selection_eval.py +1158 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +755 -0
- codegraph/watcher.py +323 -0
- codegraph_engine-3.0.0.dist-info/METADATA +868 -0
- codegraph_engine-3.0.0.dist-info/RECORD +130 -0
- codegraph_engine-3.0.0.dist-info/WHEEL +6 -0
- codegraph_engine-3.0.0.dist-info/entry_points.txt +2 -0
- codegraph_engine-3.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-3.0.0.dist-info/top_level.txt +1 -0
codegraph/monorepo.py
ADDED
|
@@ -0,0 +1,925 @@
|
|
|
1
|
+
"""Monorepo, Workspace, and Package Boundary Intelligence for CodeGraph MCP.
|
|
2
|
+
|
|
3
|
+
Core Principle:
|
|
4
|
+
The AI reasons.
|
|
5
|
+
CodeGraph interrogates the repository.
|
|
6
|
+
|
|
7
|
+
Invariants:
|
|
8
|
+
1. Never infer a package boundary solely from directory names (apps/, packages/, services/).
|
|
9
|
+
2. A package boundary must be supported by concrete evidence (manifests, workspace configs).
|
|
10
|
+
3. Collision-safe, machine-independent package identities (workspace_id::root_path).
|
|
11
|
+
4. No arbitrary command execution — static AST/JSON/TOML/YAML parsing only.
|
|
12
|
+
5. Reuse Phase 8 artifact classification: vendor/build/generated directories are never workspace packages.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import posixpath
|
|
19
|
+
import re
|
|
20
|
+
import sqlite3
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from enum import StrEnum
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from codegraph.errors import SecurityError
|
|
28
|
+
from codegraph.indexing.classifier import FileCategory, classify_file
|
|
29
|
+
from codegraph.security import is_sensitive, safe_path
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
import yaml
|
|
33
|
+
_HAS_YAML = True
|
|
34
|
+
except ImportError:
|
|
35
|
+
_HAS_YAML = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PackageType(StrEnum):
|
|
39
|
+
APPLICATION = "APPLICATION"
|
|
40
|
+
LIBRARY = "LIBRARY"
|
|
41
|
+
SERVICE = "SERVICE"
|
|
42
|
+
UNKNOWN = "UNKNOWN"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_APP_FRAMEWORKS = {
|
|
46
|
+
"express",
|
|
47
|
+
"next",
|
|
48
|
+
"nextjs",
|
|
49
|
+
"react",
|
|
50
|
+
"vue",
|
|
51
|
+
"svelte",
|
|
52
|
+
"fastapi",
|
|
53
|
+
"flask",
|
|
54
|
+
"django",
|
|
55
|
+
"tornado",
|
|
56
|
+
"aiohttp",
|
|
57
|
+
"starlette",
|
|
58
|
+
"nestjs",
|
|
59
|
+
"koa",
|
|
60
|
+
"remix",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_BACKEND_SERVER_FRAMEWORKS = {
|
|
64
|
+
"express",
|
|
65
|
+
"fastapi",
|
|
66
|
+
"flask",
|
|
67
|
+
"django",
|
|
68
|
+
"tornado",
|
|
69
|
+
"aiohttp",
|
|
70
|
+
"starlette",
|
|
71
|
+
"nestjs",
|
|
72
|
+
"koa",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_SERVICE_INDICATORS = {
|
|
76
|
+
"microservice",
|
|
77
|
+
"daemon",
|
|
78
|
+
"worker",
|
|
79
|
+
"grpc",
|
|
80
|
+
"kafka",
|
|
81
|
+
"rabbitmq",
|
|
82
|
+
"celery",
|
|
83
|
+
"pika",
|
|
84
|
+
"bullmq",
|
|
85
|
+
"dramatiq",
|
|
86
|
+
"rq",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_PRUNED_WALK_DIRS = frozenset({
|
|
90
|
+
".git",
|
|
91
|
+
".hg",
|
|
92
|
+
".svn",
|
|
93
|
+
".venv",
|
|
94
|
+
"venv",
|
|
95
|
+
"env",
|
|
96
|
+
"node_modules",
|
|
97
|
+
"dist",
|
|
98
|
+
"build",
|
|
99
|
+
"out",
|
|
100
|
+
"target",
|
|
101
|
+
"vendor",
|
|
102
|
+
"third_party",
|
|
103
|
+
"__pycache__",
|
|
104
|
+
".next",
|
|
105
|
+
".nuxt",
|
|
106
|
+
".tox",
|
|
107
|
+
".mypy_cache",
|
|
108
|
+
".pytest_cache",
|
|
109
|
+
".ruff_cache",
|
|
110
|
+
"coverage",
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class PackageDependency:
|
|
116
|
+
source_package_id: str
|
|
117
|
+
target_package_id: str
|
|
118
|
+
source_name: str
|
|
119
|
+
target_name: str
|
|
120
|
+
specifier: str = ""
|
|
121
|
+
evidence: str = ""
|
|
122
|
+
is_workspace: bool = False
|
|
123
|
+
|
|
124
|
+
def as_dict(self) -> dict[str, object]:
|
|
125
|
+
return {
|
|
126
|
+
"source_package_id": self.source_package_id,
|
|
127
|
+
"target_package_id": self.target_package_id,
|
|
128
|
+
"source_name": self.source_name,
|
|
129
|
+
"target_name": self.target_name,
|
|
130
|
+
"relationship": "DEPENDS_ON_PACKAGE",
|
|
131
|
+
"specifier": self.specifier,
|
|
132
|
+
"evidence": self.evidence,
|
|
133
|
+
"is_workspace": self.is_workspace,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class PackageInfo:
|
|
139
|
+
package_id: str
|
|
140
|
+
name: str
|
|
141
|
+
root_path: str
|
|
142
|
+
manifest_path: str
|
|
143
|
+
language: str
|
|
144
|
+
package_manager: str
|
|
145
|
+
workspace_id: str
|
|
146
|
+
package_type: PackageType
|
|
147
|
+
entrypoints: list[str] = field(default_factory=list)
|
|
148
|
+
exports: dict[str, str] = field(default_factory=dict)
|
|
149
|
+
dependency_names: list[str] = field(default_factory=list)
|
|
150
|
+
dependency_package_ids: list[str] = field(default_factory=list)
|
|
151
|
+
source_directories: list[str] = field(default_factory=list)
|
|
152
|
+
test_directories: list[str] = field(default_factory=list)
|
|
153
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
154
|
+
|
|
155
|
+
def as_dict(self) -> dict[str, object]:
|
|
156
|
+
return {
|
|
157
|
+
"package_id": self.package_id,
|
|
158
|
+
"name": self.name,
|
|
159
|
+
"root_path": self.root_path,
|
|
160
|
+
"manifest_path": self.manifest_path,
|
|
161
|
+
"language": self.language,
|
|
162
|
+
"package_manager": self.package_manager,
|
|
163
|
+
"workspace_id": self.workspace_id,
|
|
164
|
+
"package_type": self.package_type.value,
|
|
165
|
+
"entrypoints": self.entrypoints,
|
|
166
|
+
"exports": self.exports,
|
|
167
|
+
"dependency_names": self.dependency_names,
|
|
168
|
+
"dependency_package_ids": self.dependency_package_ids,
|
|
169
|
+
"source_directories": self.source_directories,
|
|
170
|
+
"test_directories": self.test_directories,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class WorkspaceInfo:
|
|
176
|
+
workspace_id: str
|
|
177
|
+
root_path: str
|
|
178
|
+
workspace_type: str # "npm" | "pnpm" | "yarn" | "python" | "standalone"
|
|
179
|
+
packages: dict[str, PackageInfo] = field(default_factory=dict)
|
|
180
|
+
package_by_name: dict[str, list[PackageInfo]] = field(default_factory=dict)
|
|
181
|
+
package_by_path: dict[str, PackageInfo] = field(default_factory=dict)
|
|
182
|
+
dependencies: list[PackageDependency] = field(default_factory=list)
|
|
183
|
+
unresolved_dependencies: list[dict[str, str]] = field(default_factory=list)
|
|
184
|
+
ambiguous_packages: dict[str, list[str]] = field(default_factory=dict)
|
|
185
|
+
|
|
186
|
+
def get_package_for_file(self, file_path: str) -> PackageInfo | None:
|
|
187
|
+
"""Find the package that owns file_path based on longest matching root_path."""
|
|
188
|
+
clean = file_path.replace("\\", "/").lstrip("./")
|
|
189
|
+
best_pkg: PackageInfo | None = None
|
|
190
|
+
best_len = -1
|
|
191
|
+
|
|
192
|
+
for pkg in self.packages.values():
|
|
193
|
+
pkg_root = pkg.root_path
|
|
194
|
+
if pkg_root == "" or pkg_root == ".":
|
|
195
|
+
if best_len < 0:
|
|
196
|
+
best_pkg = pkg
|
|
197
|
+
best_len = 0
|
|
198
|
+
elif clean == pkg_root or clean.startswith(f"{pkg_root}/"):
|
|
199
|
+
if len(pkg_root) > best_len:
|
|
200
|
+
best_pkg = pkg
|
|
201
|
+
best_len = len(pkg_root)
|
|
202
|
+
return best_pkg
|
|
203
|
+
|
|
204
|
+
def get_dependent_packages(self, package_id: str) -> list[PackageInfo]:
|
|
205
|
+
"""Return all packages in the workspace that depend on package_id."""
|
|
206
|
+
dependents: list[PackageInfo] = []
|
|
207
|
+
for dep in self.dependencies:
|
|
208
|
+
if dep.target_package_id == package_id:
|
|
209
|
+
source_pkg = self.packages.get(dep.source_package_id)
|
|
210
|
+
if source_pkg and source_pkg not in dependents:
|
|
211
|
+
dependents.append(source_pkg)
|
|
212
|
+
return dependents
|
|
213
|
+
|
|
214
|
+
def resolve_package_import_detailed(
|
|
215
|
+
self,
|
|
216
|
+
import_specifier: str,
|
|
217
|
+
source_file: str | None = None,
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
"""Resolve a package import specifier with explicit AMBIGUOUS reporting when duplicate
|
|
220
|
+
package names exist in the workspace. Never silently guesses `candidates[0]`.
|
|
221
|
+
"""
|
|
222
|
+
del source_file
|
|
223
|
+
if not import_specifier:
|
|
224
|
+
return {"status": "UNRESOLVED", "package": None, "entrypoint": None, "candidates": []}
|
|
225
|
+
|
|
226
|
+
# 1. Exact match on package name
|
|
227
|
+
candidates = self.package_by_name.get(import_specifier)
|
|
228
|
+
if candidates:
|
|
229
|
+
if len(candidates) > 1:
|
|
230
|
+
return {
|
|
231
|
+
"status": "AMBIGUOUS",
|
|
232
|
+
"package": None,
|
|
233
|
+
"entrypoint": None,
|
|
234
|
+
"candidates": sorted(c.package_id for c in candidates),
|
|
235
|
+
"reason": "duplicate_package_name",
|
|
236
|
+
}
|
|
237
|
+
pkg = candidates[0]
|
|
238
|
+
target_file = pkg.exports.get(".") or (pkg.entrypoints[0] if pkg.entrypoints else None)
|
|
239
|
+
return {
|
|
240
|
+
"status": "RESOLVED",
|
|
241
|
+
"package": pkg,
|
|
242
|
+
"entrypoint": target_file,
|
|
243
|
+
"candidates": [pkg.package_id],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
# 2. Subpath import (e.g. '@company/auth/service' or 'shared/utils')
|
|
247
|
+
for name, pkg_list in self.package_by_name.items():
|
|
248
|
+
if import_specifier.startswith(f"{name}/"):
|
|
249
|
+
if len(pkg_list) > 1:
|
|
250
|
+
return {
|
|
251
|
+
"status": "AMBIGUOUS",
|
|
252
|
+
"package": None,
|
|
253
|
+
"entrypoint": None,
|
|
254
|
+
"candidates": sorted(c.package_id for c in pkg_list),
|
|
255
|
+
"reason": "duplicate_package_name",
|
|
256
|
+
}
|
|
257
|
+
subpath = "." + import_specifier[len(name):]
|
|
258
|
+
pkg = pkg_list[0]
|
|
259
|
+
if subpath in pkg.exports:
|
|
260
|
+
return {
|
|
261
|
+
"status": "RESOLVED",
|
|
262
|
+
"package": pkg,
|
|
263
|
+
"entrypoint": pkg.exports[subpath],
|
|
264
|
+
"candidates": [pkg.package_id],
|
|
265
|
+
}
|
|
266
|
+
rel_candidate = posixpath.normpath(posixpath.join(pkg.root_path, subpath.lstrip("./")))
|
|
267
|
+
return {
|
|
268
|
+
"status": "RESOLVED",
|
|
269
|
+
"package": pkg,
|
|
270
|
+
"entrypoint": rel_candidate,
|
|
271
|
+
"candidates": [pkg.package_id],
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {"status": "UNRESOLVED", "package": None, "entrypoint": None, "candidates": []}
|
|
275
|
+
|
|
276
|
+
def resolve_package_import(
|
|
277
|
+
self,
|
|
278
|
+
import_specifier: str,
|
|
279
|
+
source_file: str | None = None,
|
|
280
|
+
) -> tuple[PackageInfo | None, str | None]:
|
|
281
|
+
"""Resolve a package import specifier to `(PackageInfo, entrypoint_file)`.
|
|
282
|
+
|
|
283
|
+
If multiple workspace packages share `import_specifier`, returns `(None, None)`
|
|
284
|
+
rather than guessing `candidates[0]`. Use `resolve_package_import_detailed` to
|
|
285
|
+
inspect `AMBIGUOUS` candidate lists.
|
|
286
|
+
"""
|
|
287
|
+
detail = self.resolve_package_import_detailed(import_specifier, source_file=source_file)
|
|
288
|
+
if detail["status"] == "RESOLVED":
|
|
289
|
+
return detail["package"], detail["entrypoint"]
|
|
290
|
+
return None, None
|
|
291
|
+
|
|
292
|
+
def as_dict(self) -> dict[str, object]:
|
|
293
|
+
out: dict[str, object] = {
|
|
294
|
+
"workspace_id": self.workspace_id,
|
|
295
|
+
"root_path": self.root_path,
|
|
296
|
+
"workspace_type": self.workspace_type,
|
|
297
|
+
"package_count": len(self.packages),
|
|
298
|
+
"packages": [p.as_dict() for p in self.packages.values()],
|
|
299
|
+
"package_dependencies": [d.as_dict() for d in self.dependencies],
|
|
300
|
+
"unresolved_dependencies": self.unresolved_dependencies,
|
|
301
|
+
}
|
|
302
|
+
if self.ambiguous_packages:
|
|
303
|
+
out["ambiguous_packages"] = self.ambiguous_packages
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _parse_yaml_packages(content: str) -> list[str]:
|
|
308
|
+
"""Parse packages list from pnpm-workspace.yaml safely."""
|
|
309
|
+
if _HAS_YAML:
|
|
310
|
+
try:
|
|
311
|
+
data = yaml.safe_load(content)
|
|
312
|
+
if isinstance(data, dict):
|
|
313
|
+
pkgs = data.get("packages")
|
|
314
|
+
if isinstance(pkgs, list):
|
|
315
|
+
return [str(p) for p in pkgs if isinstance(p, (str, int))]
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
|
|
319
|
+
# Simple line-based fallback parser
|
|
320
|
+
patterns: list[str] = []
|
|
321
|
+
in_packages = False
|
|
322
|
+
for line in content.splitlines():
|
|
323
|
+
stripped = line.strip()
|
|
324
|
+
if not stripped or stripped.startswith("#"):
|
|
325
|
+
continue
|
|
326
|
+
if stripped.startswith("packages:"):
|
|
327
|
+
in_packages = True
|
|
328
|
+
continue
|
|
329
|
+
if in_packages:
|
|
330
|
+
if stripped.startswith("-"):
|
|
331
|
+
pat = stripped.lstrip("-").strip().strip("'\"")
|
|
332
|
+
if pat:
|
|
333
|
+
patterns.append(pat)
|
|
334
|
+
elif not line.startswith(" ") and not line.startswith("\t"):
|
|
335
|
+
break
|
|
336
|
+
return patterns
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _scan_manifests(
|
|
340
|
+
repository: Path,
|
|
341
|
+
con: sqlite3.Connection | None = None,
|
|
342
|
+
max_depth: int = 5,
|
|
343
|
+
) -> list[Path]:
|
|
344
|
+
"""Discover manifest files safely with bounded recursion (`max_depth`),
|
|
345
|
+
skipping vendor/build/generated artifacts and sensitive paths.
|
|
346
|
+
"""
|
|
347
|
+
root = repository.resolve(strict=True)
|
|
348
|
+
manifest_paths: list[Path] = []
|
|
349
|
+
target_names = {"package.json", "pyproject.toml", "setup.cfg"}
|
|
350
|
+
|
|
351
|
+
# Priority: if database connection is available, use indexed files table
|
|
352
|
+
if con is not None:
|
|
353
|
+
try:
|
|
354
|
+
rows = con.execute(
|
|
355
|
+
"SELECT path, category FROM files WHERE "
|
|
356
|
+
"path LIKE '%package.json' OR path LIKE '%pyproject.toml' "
|
|
357
|
+
"OR path LIKE '%setup.py' OR path LIKE '%setup.cfg'"
|
|
358
|
+
).fetchall()
|
|
359
|
+
for r in rows:
|
|
360
|
+
p_str = str(r[0])
|
|
361
|
+
cat = str(r[1])
|
|
362
|
+
if cat in ("VENDOR", "BUILD_ARTIFACT", "BINARY"):
|
|
363
|
+
continue
|
|
364
|
+
if p_str.count("/") > max_depth:
|
|
365
|
+
continue
|
|
366
|
+
try:
|
|
367
|
+
p = safe_path(root, p_str)
|
|
368
|
+
if p.is_file():
|
|
369
|
+
manifest_paths.append(p)
|
|
370
|
+
except SecurityError:
|
|
371
|
+
continue
|
|
372
|
+
if manifest_paths:
|
|
373
|
+
return sorted(set(manifest_paths))
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
# Bounded recursive filesystem walk (never descends beyond max_depth or into vendor/build dirs)
|
|
378
|
+
def _walk(current_dir: Path, depth: int) -> None:
|
|
379
|
+
if depth > max_depth:
|
|
380
|
+
return
|
|
381
|
+
try:
|
|
382
|
+
entries = sorted(current_dir.iterdir(), key=lambda e: e.name)
|
|
383
|
+
except OSError:
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
for entry in entries:
|
|
387
|
+
try:
|
|
388
|
+
if entry.is_symlink():
|
|
389
|
+
# Verify symlink stays inside repository root
|
|
390
|
+
resolved_entry = entry.resolve(strict=False)
|
|
391
|
+
resolved_entry.relative_to(root)
|
|
392
|
+
rel = entry.relative_to(root)
|
|
393
|
+
except (ValueError, OSError, SecurityError):
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
if is_sensitive(rel):
|
|
397
|
+
continue
|
|
398
|
+
|
|
399
|
+
if entry.is_dir():
|
|
400
|
+
if entry.name in _PRUNED_WALK_DIRS or (entry.name.startswith(".") and entry.name != "."):
|
|
401
|
+
continue
|
|
402
|
+
cat_dir = classify_file(rel)
|
|
403
|
+
if cat_dir in (FileCategory.VENDOR, FileCategory.BUILD_ARTIFACT, FileCategory.BINARY):
|
|
404
|
+
continue
|
|
405
|
+
_walk(entry, depth + 1)
|
|
406
|
+
elif entry.is_file() and entry.name in target_names:
|
|
407
|
+
cat_file = classify_file(rel)
|
|
408
|
+
if cat_file in (FileCategory.VENDOR, FileCategory.BUILD_ARTIFACT, FileCategory.BINARY):
|
|
409
|
+
continue
|
|
410
|
+
manifest_paths.append(entry)
|
|
411
|
+
|
|
412
|
+
_walk(root, 0)
|
|
413
|
+
return sorted(set(manifest_paths))
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _resolve_entrypoints_js(
|
|
417
|
+
root_rel: str,
|
|
418
|
+
pkg_data: dict[str, Any],
|
|
419
|
+
manifest_dir: Path,
|
|
420
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
421
|
+
"""Extract entrypoints and exports map from package.json."""
|
|
422
|
+
entrypoints: list[str] = []
|
|
423
|
+
exports_map: dict[str, str] = {}
|
|
424
|
+
|
|
425
|
+
def _add_entry(cand: str, key: str = ".") -> None:
|
|
426
|
+
cand_clean = cand.replace("\\", "/").lstrip("./")
|
|
427
|
+
full_rel = posixpath.normpath(posixpath.join(root_rel, cand_clean)) if root_rel else cand_clean
|
|
428
|
+
if full_rel not in entrypoints:
|
|
429
|
+
entrypoints.append(full_rel)
|
|
430
|
+
if key not in exports_map:
|
|
431
|
+
exports_map[key] = full_rel
|
|
432
|
+
|
|
433
|
+
# 1. package.json "main"
|
|
434
|
+
main_field = pkg_data.get("main")
|
|
435
|
+
if isinstance(main_field, str) and main_field.strip():
|
|
436
|
+
_add_entry(main_field, ".")
|
|
437
|
+
|
|
438
|
+
# 2. package.json "module"
|
|
439
|
+
module_field = pkg_data.get("module")
|
|
440
|
+
if isinstance(module_field, str) and module_field.strip():
|
|
441
|
+
_add_entry(module_field, ".")
|
|
442
|
+
|
|
443
|
+
# 3. package.json "bin"
|
|
444
|
+
bin_field = pkg_data.get("bin")
|
|
445
|
+
if isinstance(bin_field, str):
|
|
446
|
+
_add_entry(bin_field, "./bin")
|
|
447
|
+
elif isinstance(bin_field, dict):
|
|
448
|
+
for b_name, b_path in bin_field.items():
|
|
449
|
+
if isinstance(b_path, str):
|
|
450
|
+
_add_entry(b_path, f"./bin/{b_name}")
|
|
451
|
+
|
|
452
|
+
# 4. package.json "exports"
|
|
453
|
+
exports_field = pkg_data.get("exports")
|
|
454
|
+
if isinstance(exports_field, str):
|
|
455
|
+
_add_entry(exports_field, ".")
|
|
456
|
+
elif isinstance(exports_field, dict):
|
|
457
|
+
for exp_key, exp_val in exports_field.items():
|
|
458
|
+
if isinstance(exp_val, str):
|
|
459
|
+
_add_entry(exp_val, exp_key)
|
|
460
|
+
elif isinstance(exp_val, dict):
|
|
461
|
+
# Conditional exports (import, require, default)
|
|
462
|
+
for cond in ("import", "require", "default", "types"):
|
|
463
|
+
c_val = exp_val.get(cond)
|
|
464
|
+
if isinstance(c_val, str):
|
|
465
|
+
_add_entry(c_val, exp_key)
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
# 5. Common conventions if no explicit entrypoint
|
|
469
|
+
if not entrypoints:
|
|
470
|
+
for standard in (
|
|
471
|
+
"src/index.ts",
|
|
472
|
+
"src/index.js",
|
|
473
|
+
"src/index.tsx",
|
|
474
|
+
"src/main.ts",
|
|
475
|
+
"src/main.js",
|
|
476
|
+
"index.ts",
|
|
477
|
+
"index.js",
|
|
478
|
+
):
|
|
479
|
+
if (manifest_dir / standard).is_file():
|
|
480
|
+
_add_entry(standard, ".")
|
|
481
|
+
break
|
|
482
|
+
|
|
483
|
+
return entrypoints, exports_map
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _resolve_entrypoints_py(
|
|
487
|
+
root_rel: str,
|
|
488
|
+
py_data: dict[str, Any],
|
|
489
|
+
manifest_dir: Path,
|
|
490
|
+
) -> tuple[list[str], dict[str, str]]:
|
|
491
|
+
"""Extract entrypoints and scripts from pyproject.toml."""
|
|
492
|
+
entrypoints: list[str] = []
|
|
493
|
+
exports_map: dict[str, str] = {}
|
|
494
|
+
|
|
495
|
+
def _add_entry(cand: str, key: str = ".") -> None:
|
|
496
|
+
cand_clean = cand.replace("\\", "/").lstrip("./")
|
|
497
|
+
full_rel = posixpath.normpath(posixpath.join(root_rel, cand_clean)) if root_rel else cand_clean
|
|
498
|
+
if full_rel not in entrypoints:
|
|
499
|
+
entrypoints.append(full_rel)
|
|
500
|
+
if key not in exports_map:
|
|
501
|
+
exports_map[key] = full_rel
|
|
502
|
+
|
|
503
|
+
# Check project.scripts
|
|
504
|
+
proj = py_data.get("project", {}) if isinstance(py_data, dict) else {}
|
|
505
|
+
scripts = proj.get("scripts", {}) if isinstance(proj, dict) else {}
|
|
506
|
+
if isinstance(scripts, dict):
|
|
507
|
+
for s_name, s_spec in scripts.items():
|
|
508
|
+
if isinstance(s_spec, str) and ":" in s_spec:
|
|
509
|
+
mod = s_spec.split(":")[0].replace(".", "/")
|
|
510
|
+
_add_entry(f"{mod}.py", f"./bin/{s_name}")
|
|
511
|
+
|
|
512
|
+
# Check poetry.scripts
|
|
513
|
+
tool = py_data.get("tool", {}) if isinstance(py_data, dict) else {}
|
|
514
|
+
poetry = tool.get("poetry", {}) if isinstance(tool, dict) else {}
|
|
515
|
+
p_scripts = poetry.get("scripts", {}) if isinstance(poetry, dict) else {}
|
|
516
|
+
if isinstance(p_scripts, dict):
|
|
517
|
+
for s_name, s_spec in p_scripts.items():
|
|
518
|
+
if isinstance(s_spec, str) and ":" in s_spec:
|
|
519
|
+
mod = s_spec.split(":")[0].replace(".", "/")
|
|
520
|
+
_add_entry(f"{mod}.py", f"./bin/{s_name}")
|
|
521
|
+
|
|
522
|
+
# Look for package module / __init__.py
|
|
523
|
+
pkg_name = proj.get("name") or poetry.get("name")
|
|
524
|
+
if isinstance(pkg_name, str) and pkg_name:
|
|
525
|
+
clean_pkg = pkg_name.replace("-", "_")
|
|
526
|
+
for cand in (
|
|
527
|
+
f"src/{clean_pkg}/__init__.py",
|
|
528
|
+
f"{clean_pkg}/__init__.py",
|
|
529
|
+
f"src/{clean_pkg}.py",
|
|
530
|
+
f"{clean_pkg}.py",
|
|
531
|
+
):
|
|
532
|
+
if (manifest_dir / cand).is_file():
|
|
533
|
+
_add_entry(cand, ".")
|
|
534
|
+
break
|
|
535
|
+
|
|
536
|
+
if not entrypoints:
|
|
537
|
+
for cand in ("src/__init__.py", "app.py", "main.py"):
|
|
538
|
+
if (manifest_dir / cand).is_file():
|
|
539
|
+
_add_entry(cand, ".")
|
|
540
|
+
break
|
|
541
|
+
|
|
542
|
+
return entrypoints, exports_map
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _matches_ws_pattern(rel_path: str, pat: str) -> bool:
|
|
546
|
+
"""Match a package relative path against a workspace glob pattern."""
|
|
547
|
+
clean_pat = pat.strip().rstrip("/")
|
|
548
|
+
if not clean_pat:
|
|
549
|
+
return False
|
|
550
|
+
return (
|
|
551
|
+
fnmatch.fnmatch(rel_path, clean_pat)
|
|
552
|
+
or fnmatch.fnmatch(rel_path, pat.rstrip("/*"))
|
|
553
|
+
or (clean_pat.endswith("/*") and rel_path.startswith(clean_pat[:-2] + "/"))
|
|
554
|
+
or (clean_pat.endswith("/**") and (rel_path == clean_pat[:-3] or rel_path.startswith(clean_pat[:-3] + "/")))
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _has_typescript_files_bounded(manifest_dir: Path, entrypoints: list[str]) -> bool:
|
|
559
|
+
"""Check whether a JS/TS package uses TypeScript without unbounded globbing."""
|
|
560
|
+
if (manifest_dir / "tsconfig.json").is_file():
|
|
561
|
+
return True
|
|
562
|
+
if any(ep.endswith((".ts", ".tsx")) for ep in entrypoints):
|
|
563
|
+
return True
|
|
564
|
+
try:
|
|
565
|
+
for child in manifest_dir.iterdir():
|
|
566
|
+
if child.is_file() and child.suffix in (".ts", ".tsx"):
|
|
567
|
+
return True
|
|
568
|
+
if child.is_dir() and child.name not in _PRUNED_WALK_DIRS and not child.name.startswith("."):
|
|
569
|
+
for sub in child.iterdir():
|
|
570
|
+
if sub.is_file() and sub.suffix in (".ts", ".tsx"):
|
|
571
|
+
return True
|
|
572
|
+
except OSError:
|
|
573
|
+
pass
|
|
574
|
+
return False
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _classify_package_type(
|
|
578
|
+
name: str,
|
|
579
|
+
root_rel: str,
|
|
580
|
+
dependencies: list[str],
|
|
581
|
+
entrypoints: list[str],
|
|
582
|
+
has_bin: bool,
|
|
583
|
+
con: sqlite3.Connection | None,
|
|
584
|
+
) -> PackageType:
|
|
585
|
+
"""Statically determine PackageType based on verified evidence without guessing from directory names.
|
|
586
|
+
|
|
587
|
+
Precedence:
|
|
588
|
+
1. Explicit service indicators (celery, kafka, grpc, worker, daemon, etc.) -> SERVICE
|
|
589
|
+
2. Backend server frameworks (FastAPI, Flask, Django, Express, etc.) paired with service
|
|
590
|
+
evidence (ASGI/WSGI runners, service entrypoints, or service package identity) -> SERVICE
|
|
591
|
+
3. Application frameworks (Next, React, Vue, standalone web apps) -> APPLICATION
|
|
592
|
+
4. Indexed framework routes under package root -> APPLICATION
|
|
593
|
+
5. CLI binary without application server -> APPLICATION
|
|
594
|
+
6. Library entrypoints without binary -> LIBRARY
|
|
595
|
+
7. Otherwise -> UNKNOWN
|
|
596
|
+
"""
|
|
597
|
+
all_deps_lower = {d.lower() for d in dependencies}
|
|
598
|
+
name_lower = name.lower()
|
|
599
|
+
|
|
600
|
+
# Evidence 1: Service markers in dependencies (checked BEFORE generic app frameworks)
|
|
601
|
+
if any(ind in all_deps_lower for ind in _SERVICE_INDICATORS):
|
|
602
|
+
return PackageType.SERVICE
|
|
603
|
+
|
|
604
|
+
# Evidence 2: Backend server framework acting as a service (service runner / service entrypoint / service identity)
|
|
605
|
+
has_backend_server = any(fw in all_deps_lower for fw in _BACKEND_SERVER_FRAMEWORKS)
|
|
606
|
+
has_service_runner = any(srv in all_deps_lower for srv in ("uvicorn", "gunicorn", "hypercorn", "daphne"))
|
|
607
|
+
has_service_entrypoint = any(
|
|
608
|
+
ep.endswith(("service.py", "service.ts", "service.js", "worker.py", "worker.ts", "daemon.py"))
|
|
609
|
+
for ep in entrypoints
|
|
610
|
+
)
|
|
611
|
+
has_service_identity = (
|
|
612
|
+
name_lower.endswith(("-service", "_service", "-svc", "_svc", "-worker", "_worker", "-daemon"))
|
|
613
|
+
or root_rel.startswith("services/")
|
|
614
|
+
)
|
|
615
|
+
if has_backend_server and (has_service_runner or has_service_entrypoint or has_service_identity):
|
|
616
|
+
return PackageType.SERVICE
|
|
617
|
+
|
|
618
|
+
# Evidence 3: Application frameworks
|
|
619
|
+
if any(fw in all_deps_lower for fw in _APP_FRAMEWORKS):
|
|
620
|
+
return PackageType.APPLICATION
|
|
621
|
+
|
|
622
|
+
# Evidence 4: Database routes registered under this package root
|
|
623
|
+
if con is not None:
|
|
624
|
+
try:
|
|
625
|
+
count = con.execute(
|
|
626
|
+
"SELECT count(*) FROM framework_routes WHERE file_path LIKE ?",
|
|
627
|
+
(f"{root_rel}/%" if root_rel else "%",),
|
|
628
|
+
).fetchone()[0]
|
|
629
|
+
if count > 0:
|
|
630
|
+
return PackageType.APPLICATION
|
|
631
|
+
except Exception:
|
|
632
|
+
pass
|
|
633
|
+
|
|
634
|
+
# Evidence 5: CLI binary without application server
|
|
635
|
+
if has_bin and not any(fw in all_deps_lower for fw in _APP_FRAMEWORKS):
|
|
636
|
+
return PackageType.APPLICATION
|
|
637
|
+
|
|
638
|
+
# Evidence 6: Library characteristics (entrypoints exist, no app frameworks)
|
|
639
|
+
if entrypoints and not has_bin:
|
|
640
|
+
return PackageType.LIBRARY
|
|
641
|
+
|
|
642
|
+
return PackageType.UNKNOWN
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def detect_workspace(
|
|
646
|
+
repository: Path,
|
|
647
|
+
con: sqlite3.Connection | None = None,
|
|
648
|
+
) -> WorkspaceInfo:
|
|
649
|
+
"""Deterministically analyze repository workspace structure, packages, and dependencies."""
|
|
650
|
+
root = repository.resolve(strict=True)
|
|
651
|
+
|
|
652
|
+
# 1. Detect root workspace type and declared package patterns
|
|
653
|
+
workspace_type = "standalone"
|
|
654
|
+
workspace_patterns: list[str] = []
|
|
655
|
+
root_name = root.name
|
|
656
|
+
|
|
657
|
+
# Check pnpm-workspace.yaml
|
|
658
|
+
pnpm_ws = root / "pnpm-workspace.yaml"
|
|
659
|
+
if pnpm_ws.is_file():
|
|
660
|
+
workspace_type = "pnpm"
|
|
661
|
+
try:
|
|
662
|
+
text = pnpm_ws.read_text(encoding="utf-8", errors="replace")
|
|
663
|
+
workspace_patterns.extend(_parse_yaml_packages(text))
|
|
664
|
+
except OSError:
|
|
665
|
+
pass
|
|
666
|
+
|
|
667
|
+
# Check root package.json
|
|
668
|
+
root_pkg_json = root / "package.json"
|
|
669
|
+
if root_pkg_json.is_file():
|
|
670
|
+
try:
|
|
671
|
+
data = json.loads(root_pkg_json.read_text(encoding="utf-8", errors="replace"))
|
|
672
|
+
if isinstance(data, dict):
|
|
673
|
+
r_name = data.get("name")
|
|
674
|
+
if isinstance(r_name, str) and r_name:
|
|
675
|
+
root_name = r_name
|
|
676
|
+
ws = data.get("workspaces")
|
|
677
|
+
if ws:
|
|
678
|
+
if workspace_type == "standalone":
|
|
679
|
+
workspace_type = "npm"
|
|
680
|
+
if isinstance(ws, list):
|
|
681
|
+
workspace_patterns.extend([str(p) for p in ws if isinstance(p, str)])
|
|
682
|
+
elif isinstance(ws, dict):
|
|
683
|
+
pkgs = ws.get("packages")
|
|
684
|
+
if isinstance(pkgs, list):
|
|
685
|
+
workspace_patterns.extend([str(p) for p in pkgs if isinstance(p, str)])
|
|
686
|
+
except Exception:
|
|
687
|
+
pass
|
|
688
|
+
|
|
689
|
+
# Check root pyproject.toml
|
|
690
|
+
root_pyproj = root / "pyproject.toml"
|
|
691
|
+
if root_pyproj.is_file() and workspace_type == "standalone":
|
|
692
|
+
try:
|
|
693
|
+
p_data = tomllib.loads(root_pyproj.read_text(encoding="utf-8", errors="replace"))
|
|
694
|
+
if isinstance(p_data, dict):
|
|
695
|
+
proj = p_data.get("project", {})
|
|
696
|
+
if isinstance(proj, dict) and proj.get("name"):
|
|
697
|
+
root_name = str(proj["name"])
|
|
698
|
+
tool = p_data.get("tool", {})
|
|
699
|
+
if isinstance(tool, dict) and "poetry" in tool:
|
|
700
|
+
workspace_type = "python"
|
|
701
|
+
except Exception:
|
|
702
|
+
pass
|
|
703
|
+
|
|
704
|
+
workspace_id = root_name
|
|
705
|
+
|
|
706
|
+
pos_patterns = [p.strip() for p in workspace_patterns if p.strip() and not p.strip().startswith("!")]
|
|
707
|
+
neg_patterns = [p.strip()[1:].strip() for p in workspace_patterns if p.strip().startswith("!") and p.strip()[1:].strip()]
|
|
708
|
+
|
|
709
|
+
# 2. Discover candidate package manifests
|
|
710
|
+
manifest_files = _scan_manifests(root, con=con)
|
|
711
|
+
|
|
712
|
+
packages: dict[str, PackageInfo] = {}
|
|
713
|
+
package_by_name: dict[str, list[PackageInfo]] = {}
|
|
714
|
+
package_by_path: dict[str, PackageInfo] = {}
|
|
715
|
+
|
|
716
|
+
for manifest_path in manifest_files:
|
|
717
|
+
try:
|
|
718
|
+
rel_manifest = manifest_path.relative_to(root).as_posix()
|
|
719
|
+
except ValueError:
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
manifest_dir = manifest_path.parent
|
|
723
|
+
try:
|
|
724
|
+
root_rel = manifest_dir.relative_to(root).as_posix()
|
|
725
|
+
if root_rel == ".":
|
|
726
|
+
root_rel = ""
|
|
727
|
+
except ValueError:
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
# Respect negated workspace globs (e.g. '!packages/legacy')
|
|
731
|
+
if root_rel != "" and neg_patterns:
|
|
732
|
+
if any(_matches_ws_pattern(root_rel, np) for np in neg_patterns):
|
|
733
|
+
continue
|
|
734
|
+
|
|
735
|
+
# If workspace patterns are defined (e.g. apps/*, packages/*), verify membership for child packages
|
|
736
|
+
is_workspace_member = True
|
|
737
|
+
if pos_patterns and root_rel != "":
|
|
738
|
+
is_workspace_member = any(_matches_ws_pattern(root_rel, pat) for pat in pos_patterns)
|
|
739
|
+
|
|
740
|
+
# Package identity is strictly deterministic and collision-safe
|
|
741
|
+
package_id = f"{workspace_id}::{root_rel}" if root_rel else f"{workspace_id}::root"
|
|
742
|
+
|
|
743
|
+
# Parse manifest data
|
|
744
|
+
m_name = manifest_path.name
|
|
745
|
+
pkg_name = root_rel if root_rel else workspace_id
|
|
746
|
+
pkg_lang = "unknown"
|
|
747
|
+
pkg_mgr = "unknown"
|
|
748
|
+
dep_names: list[str] = []
|
|
749
|
+
entrypoints: list[str] = []
|
|
750
|
+
exports: dict[str, str] = {}
|
|
751
|
+
has_bin = False
|
|
752
|
+
raw_metadata: dict[str, Any] = {"is_workspace_member": is_workspace_member}
|
|
753
|
+
|
|
754
|
+
if m_name == "package.json":
|
|
755
|
+
pkg_mgr = workspace_type if workspace_type in ("npm", "pnpm", "yarn") else "npm"
|
|
756
|
+
try:
|
|
757
|
+
p_dict = json.loads(manifest_path.read_text(encoding="utf-8", errors="replace"))
|
|
758
|
+
if isinstance(p_dict, dict):
|
|
759
|
+
raw_metadata.update(p_dict)
|
|
760
|
+
if isinstance(p_dict.get("name"), str) and p_dict["name"].strip():
|
|
761
|
+
pkg_name = p_dict["name"].strip()
|
|
762
|
+
has_bin = bool(p_dict.get("bin"))
|
|
763
|
+
entrypoints, exports = _resolve_entrypoints_js(root_rel, p_dict, manifest_dir)
|
|
764
|
+
|
|
765
|
+
# Extract all dependencies
|
|
766
|
+
for dep_key in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
767
|
+
d_dict = p_dict.get(dep_key)
|
|
768
|
+
if isinstance(d_dict, dict):
|
|
769
|
+
for d_name, _d_spec in d_dict.items():
|
|
770
|
+
if d_name not in dep_names:
|
|
771
|
+
dep_names.append(d_name)
|
|
772
|
+
except Exception:
|
|
773
|
+
continue
|
|
774
|
+
pkg_lang = "typescript" if _has_typescript_files_bounded(manifest_dir, entrypoints) else "javascript"
|
|
775
|
+
|
|
776
|
+
elif m_name in ("pyproject.toml", "setup.cfg", "setup.py"):
|
|
777
|
+
pkg_lang = "python"
|
|
778
|
+
pkg_mgr = "poetry" if workspace_type == "python" else "pip"
|
|
779
|
+
if m_name == "pyproject.toml":
|
|
780
|
+
try:
|
|
781
|
+
py_dict = tomllib.loads(manifest_path.read_text(encoding="utf-8", errors="replace"))
|
|
782
|
+
if isinstance(py_dict, dict):
|
|
783
|
+
raw_metadata = py_dict
|
|
784
|
+
proj = py_dict.get("project", {})
|
|
785
|
+
if isinstance(proj, dict) and proj.get("name"):
|
|
786
|
+
pkg_name = str(proj["name"])
|
|
787
|
+
deps = proj.get("dependencies", [])
|
|
788
|
+
if isinstance(deps, list):
|
|
789
|
+
for d in deps:
|
|
790
|
+
if isinstance(d, str):
|
|
791
|
+
dep_names.append(re.split(r"[><=~;!]", d)[0].strip())
|
|
792
|
+
tool = py_dict.get("tool", {})
|
|
793
|
+
if isinstance(tool, dict) and "poetry" in tool:
|
|
794
|
+
p_sec = tool["poetry"]
|
|
795
|
+
if isinstance(p_sec, dict) and p_sec.get("name"):
|
|
796
|
+
pkg_name = str(p_sec["name"])
|
|
797
|
+
p_deps = p_sec.get("dependencies", {})
|
|
798
|
+
if isinstance(p_deps, dict):
|
|
799
|
+
for d in p_deps.keys():
|
|
800
|
+
if d != "python" and d not in dep_names:
|
|
801
|
+
dep_names.append(d)
|
|
802
|
+
entrypoints, exports = _resolve_entrypoints_py(root_rel, py_dict, manifest_dir)
|
|
803
|
+
except Exception:
|
|
804
|
+
continue
|
|
805
|
+
|
|
806
|
+
pkg_type = _classify_package_type(
|
|
807
|
+
name=pkg_name,
|
|
808
|
+
root_rel=root_rel,
|
|
809
|
+
dependencies=dep_names,
|
|
810
|
+
entrypoints=entrypoints,
|
|
811
|
+
has_bin=has_bin,
|
|
812
|
+
con=con,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
# Separate source and test directories under this package
|
|
816
|
+
source_dirs: set[str] = set()
|
|
817
|
+
test_dirs: set[str] = set()
|
|
818
|
+
for sub in manifest_dir.iterdir():
|
|
819
|
+
if sub.is_dir() and not sub.name.startswith("."):
|
|
820
|
+
sub_rel = sub.relative_to(root).as_posix()
|
|
821
|
+
if sub.name in ("tests", "test", "__tests__", "spec", "specs"):
|
|
822
|
+
test_dirs.add(sub_rel)
|
|
823
|
+
elif sub.name not in ("node_modules", "dist", "build", ".venv", "vendor"):
|
|
824
|
+
source_dirs.add(sub_rel)
|
|
825
|
+
|
|
826
|
+
info = PackageInfo(
|
|
827
|
+
package_id=package_id,
|
|
828
|
+
name=pkg_name,
|
|
829
|
+
root_path=root_rel,
|
|
830
|
+
manifest_path=rel_manifest,
|
|
831
|
+
language=pkg_lang,
|
|
832
|
+
package_manager=pkg_mgr,
|
|
833
|
+
workspace_id=workspace_id,
|
|
834
|
+
package_type=pkg_type,
|
|
835
|
+
entrypoints=sorted(entrypoints),
|
|
836
|
+
exports=exports,
|
|
837
|
+
dependency_names=sorted(dep_names),
|
|
838
|
+
source_directories=sorted(source_dirs),
|
|
839
|
+
test_directories=sorted(test_dirs),
|
|
840
|
+
metadata=raw_metadata,
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
packages[package_id] = info
|
|
844
|
+
package_by_path[root_rel] = info
|
|
845
|
+
package_by_name.setdefault(pkg_name, []).append(info)
|
|
846
|
+
|
|
847
|
+
ambiguous_packages: dict[str, list[str]] = {
|
|
848
|
+
p_name: sorted(p.package_id for p in p_list)
|
|
849
|
+
for p_name, p_list in sorted(package_by_name.items())
|
|
850
|
+
if len(p_list) > 1
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
# 3. Resolve inter-package dependencies and detect unresolved or ambiguous dependencies
|
|
854
|
+
dependencies: list[PackageDependency] = []
|
|
855
|
+
unresolved_dependencies: list[dict[str, str]] = []
|
|
856
|
+
|
|
857
|
+
for _pkg_id, pkg in packages.items():
|
|
858
|
+
resolved_dep_ids: list[str] = []
|
|
859
|
+
raw_deps: dict[str, Any] = {}
|
|
860
|
+
if pkg.manifest_path.endswith("package.json") and isinstance(pkg.metadata, dict):
|
|
861
|
+
for k in ("dependencies", "devDependencies", "peerDependencies"):
|
|
862
|
+
d_sec = pkg.metadata.get(k)
|
|
863
|
+
if isinstance(d_sec, dict):
|
|
864
|
+
raw_deps.update(d_sec)
|
|
865
|
+
|
|
866
|
+
for dep_name in pkg.dependency_names:
|
|
867
|
+
spec = str(raw_deps.get(dep_name, ""))
|
|
868
|
+
is_ws_spec = "workspace:" in spec
|
|
869
|
+
target_pkgs = [p for p in (package_by_name.get(dep_name) or []) if p.package_id != pkg.package_id]
|
|
870
|
+
|
|
871
|
+
if len(target_pkgs) == 1:
|
|
872
|
+
target_pkg = target_pkgs[0]
|
|
873
|
+
dependencies.append(
|
|
874
|
+
PackageDependency(
|
|
875
|
+
source_package_id=pkg.package_id,
|
|
876
|
+
target_package_id=target_pkg.package_id,
|
|
877
|
+
source_name=pkg.name,
|
|
878
|
+
target_name=target_pkg.name,
|
|
879
|
+
specifier=spec,
|
|
880
|
+
evidence=f"{pkg.manifest_path}:dependencies['{dep_name}']",
|
|
881
|
+
is_workspace=is_ws_spec,
|
|
882
|
+
)
|
|
883
|
+
)
|
|
884
|
+
resolved_dep_ids.append(target_pkg.package_id)
|
|
885
|
+
elif len(target_pkgs) > 1:
|
|
886
|
+
# Duplicate package name in workspace: never guess target_pkgs[0]
|
|
887
|
+
unresolved_dependencies.append({
|
|
888
|
+
"source_package_id": pkg.package_id,
|
|
889
|
+
"dependency_name": dep_name,
|
|
890
|
+
"specifier": spec,
|
|
891
|
+
"status": "AMBIGUOUS",
|
|
892
|
+
"reason": "ambiguous_duplicate_package_name",
|
|
893
|
+
"candidates": ",".join(sorted(p.package_id for p in target_pkgs)),
|
|
894
|
+
"evidence": f"{pkg.manifest_path}:dependencies['{dep_name}']",
|
|
895
|
+
})
|
|
896
|
+
elif is_ws_spec:
|
|
897
|
+
# workspace:* dependency requested but package not found
|
|
898
|
+
unresolved_dependencies.append({
|
|
899
|
+
"source_package_id": pkg.package_id,
|
|
900
|
+
"dependency_name": dep_name,
|
|
901
|
+
"specifier": spec,
|
|
902
|
+
"status": "UNRESOLVED",
|
|
903
|
+
"reason": "unresolved_workspace_dependency",
|
|
904
|
+
"evidence": f"{pkg.manifest_path}:dependencies['{dep_name}']={spec}",
|
|
905
|
+
})
|
|
906
|
+
|
|
907
|
+
pkg.dependency_package_ids = sorted(set(resolved_dep_ids))
|
|
908
|
+
|
|
909
|
+
dependencies.sort(key=lambda d: (d.source_package_id, d.target_package_id))
|
|
910
|
+
unresolved_dependencies.sort(key=lambda u: (u["source_package_id"], u["dependency_name"]))
|
|
911
|
+
|
|
912
|
+
return WorkspaceInfo(
|
|
913
|
+
workspace_id=workspace_id,
|
|
914
|
+
root_path="",
|
|
915
|
+
workspace_type=workspace_type,
|
|
916
|
+
packages=packages,
|
|
917
|
+
package_by_name=package_by_name,
|
|
918
|
+
package_by_path=package_by_path,
|
|
919
|
+
dependencies=dependencies,
|
|
920
|
+
unresolved_dependencies=unresolved_dependencies,
|
|
921
|
+
ambiguous_packages=ambiguous_packages,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
discover_workspace = detect_workspace
|